元素
Input
Input 是原生文本输入框:iOS 和 Mac Catalyst 上是 UITextField(单行)或 UITextView(多行),Android 上是 EditText。输入、光标、选区、自动更正和自动填充都由平台自己处理。Rust 用 nsh_base::input() 创建,TSX 用从 nsh/base 导入的 <Input>。
import { type InputRef, type JSX, mount, signal, Text, View } from "nsh";
import { Input } from "nsh/base";
import { button, buttonText, field, Inspector, Panel, Stage, ui, wide } from "./palette";
function App() {
const name = signal("");
const password = signal("");
const notes = signal("");
const submitted = signal("");
let passwordInput!: InputRef;
const signIn = () => submitted.set(`Signed in as ${name() || "nobody"}`);
const nameField = () => (
<Input
{...field()}
value={name()}
onChange={({ value }) => name.set(value)}
placeholder="Ada Lovelace"
autoCapitalize="words"
maxLength={20}
returnKeyType="next"
onSubmit={() => passwordInput.focus()}
/>
);
const count = () => <Text style={{ fontSize: 12, color: ui().muted }}>{name().length} / 20</Text>;
const passwordField = () => (
<Input
ref={passwordInput}
{...field()}
value={password()}
onChange={({ value }) => password.set(value)}
masked
autoComplete="current-password"
returnKeyType="done"
onSubmit={signIn}
/>
);
const notesField = () => (
<Input
{...field({ style: { height: wide() ? 80 : 100 } })}
multiline
value={notes()}
onChange={({ value }) => notes.set(value)}
placeholder="Anything else?"
/>
);
const signInButton = () => (
<View onPress={signIn} disabled={password().length === 0} {...button("primary", { style: { marginTop: 4 } })}>
<Text style={buttonText("primary")}>Sign in</Text>
</View>
);
/** A label above its field, and what describes it below. */
const group = (label: string, input: JSX.Element, after?: JSX.Element) => (
<View style={{ gap: 6 }}>
<Text style={{ fontSize: 13, color: ui().muted }}>{label}</Text>
{input}
{after}
</View>
);
return (
<View style={{ flex: 1, backgroundColor: ui().page }}>
{() =>
wide() ? (
<View style={{ flex: 1, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>input</Text>
<Text style={{ color: ui().muted }}>Tab moves between the fields; Return in the name goes to the password</Text>
<View style={{ flex: 1, flexDirection: "row", gap: 16 }}>
<Stage width={340} gap={16}>
{group("Name (controlled, maxLength 20)", nameField(), count())}
{group("Password", passwordField())}
{group("Notes (multiline)", notesField())}
{signInButton()}
</Stage>
<Inspector>
<Panel title="value">
<Text style={{ fontSize: 13, color: ui().text }}>name {name() ? `“${name()}”` : "—"}</Text>
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>password {password().length} characters</Text>
<Text style={{ fontSize: 13, color: ui().text, fontVariant: ["tabular-nums"] }}>notes {notes().length} characters</Text>
</Panel>
<Panel title="onSubmit" grow>
<Text style={{ fontSize: 13, color: ui().up }}>{submitted()}</Text>
</Panel>
</Inspector>
</View>
</View>
) : (
<View style={{ flex: 1, width: "100%", maxWidth: 480, alignSelf: "center", padding: 20, paddingTop: 24, gap: 10 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>input</Text>
<Text style={{ color: ui().muted }}>Name (controlled, maxLength 20)</Text>
{nameField()}
{count()}
<Text style={{ color: ui().muted }}>Password</Text>
{passwordField()}
<Text style={{ color: ui().muted }}>Notes (multiline)</Text>
{notesField()}
{signInButton()}
<Text style={{ color: ui().up }}>{submitted()}</Text>
</View>
)
}
</View>
);
}
mount(App);use nsh_base::input;
use nsh_core::prelude::*;
fn name_field() -> impl IntoElement {
let name = signal(String::new());
input()
.value(name)
.on_change(move |text: &str| name.set(text.to_string()))
.placeholder("你的名字")
.auto_capitalize(AutoCapitalize::Words)
.return_key_type(ReturnKeyType::Done)
.text_base()
.p_3()
.border_1()
.border_color(rgb(0xd0d7de))
.rounded(px(8.0))
}import { signal } from "nsh";
import { Input } from "nsh/base";
function NameField() {
const name = signal("");
return (
<Input
value={name()}
onChange={({ value }) => name.set(value)}
placeholder="你的名字"
autoCapitalize="words"
returnKeyType="done"
style={{ fontSize: 16, padding: 12, borderWidth: 1, borderColor: "#d0d7de", borderRadius: 8 }}
/>
);
}样式和父视图都没有给定宽度时,Input 的宽度是当前字体下 20 个数字的宽度,与 HTML 的 <input> 一样,不随内容变化。
#受控与非受控
- 受控:传入
value,并在onChange中更新它。应用可以改写用户的输入(去掉空白、格式化):输入框会立即显示用户的编辑,新的value到达后再被修正。每次原生编辑都带有一个计数,基于最新一次编辑的value才会被应用,因此用户更新的输入总是优先于较慢的更新。 - 非受控:传入
defaultValue(.default_value(..),或不传),从onChange或onSubmit读取文本。
用户输入过程中必须遵守的规则以属性形式给出,由原生侧直接判断:maxLength 限制长度,submitBehavior 决定回车键的行为。参见表单与键盘。
#属性
除通用属性外(style 的类型为 TextStyle:字体、颜色、对齐方式和视图样式都适用):
#文本
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
value | .value(..) | string | 由应用控制的文本。 | |
defaultValue | .default_value(..) | string | 初始文本。 | |
placeholder | .placeholder(..) | string | 输入框为空时显示的提示。 | |
multiline | .multiline(..) | boolean | false | 多行;回车插入换行(submitBehavior 另有设置时除外)。 |
scrollbar | .scrollbar(..) | boolean | ScrollbarTheme | true | 多行输入框的滚动条:平台的滚动指示器,应用设置主题后则是内置滚动条(见 Scrollbar);对象是它自己的主题,false 表示不显示。 |
maxLength | .max_length(..) | number | 用户最多能输入的字符数。 | |
readonly | .readonly(..) | boolean | false | 用户可以选择和复制文本,但不能修改。 |
masked | .masked(..) | boolean | false | 隐藏文本(密码)。 |
allowFontScaling | boolean | true | 跟随系统文字大小。 | |
maxFontSizeMultiplier | number | 0 | 系统缩放的上限(0 表示不限)。 |
#键盘
| 属性 | Rust | 类型 | 默认值 | 说明 |
|---|---|---|---|---|
keyboardType | .keyboard_type(KeyboardType::Email) | "default" | "number-pad" | "decimal-pad" | "numeric" | "numbers-and-punctuation" | "email-address" | "phone-pad" | "url" | "default" | 键盘布局(各取值;numbers-and-punctuation 在 iOS 上有负号)。 |
returnKeyType | .return_key_type(ReturnKeyType::Next) | "default" | "done" | "go" | "next" | "search" | "send" | "default" | 回车键的标签。 |
autoCapitalize | .auto_capitalize(AutoCapitalize::None) | "none" | "sentences" | "words" | "characters" | "sentences" | 自动大写。 |
autoCorrect | .auto_correct(..) | boolean | true | 系统自动更正。 |
autoComplete | .auto_fill(AutoFill::OneTimeCode) | AutoComplete | TextContentType | 输入框内容的类型,用于 iOS、Android 和 Web 上的自动填充:"username"、"current-password"、"new-password"、"one-time-code"、"email"、"tel"、"postal-code"、"cc-number"、"off" 等。 | |
pasteBehavior | .paste_behavior(PasteBehavior::Report) | "insert" | "report" | "insert" | 粘贴做什么:"insert" 插入粘贴的文本,"report" 不插入任何内容,由应用处理 onPaste 报告的内容。图片和文件从不插入。 |
submitBehavior | .submit_behavior(SubmitBehavior::Submit) | "submit" | "blurAndSubmit" | "newline" | "blurAndSubmit"(单行),"newline"(多行) | 回车键做什么:"submit" 触发 onSubmit 并保持焦点,"blurAndSubmit" 提交后再失去焦点,"newline" 插入换行(多行;单行总是提交)。会提交的多行输入框在硬件键盘上仍可用 Shift+Return 或 Option / Alt+Return 换行。"submit" 让键盘保持弹出(例如连续发送多条消息)。 |
autoFocus | .auto_focus(..) | boolean | false | 出现时自动获得焦点。 |
#光标与选区
| 属性 | Rust | 类型 | 说明 |
|---|---|---|---|
caret | .caret(..) | boolean | 绘制光标(默认 true;false 隐藏光标)。 |
selectedRange | .selected_range((start, end)) | { start: number; end?: number } | 受控选区,以 UTF-16 为单位;end 默认等于 start(即光标)。 |
光标在窗口显示且处于活动状态时闪烁。在 Linux 的 GPUI 窗口中,十秒内没有输入或移动光标后,光标保持显示、不再闪烁(与 GTK 应用相同),因此无人操作的焦点输入框不再绘制帧。
#颜色
提示文字、光标和选区高亮的颜色是样式键,有对应的 Tailwind 类,在 Rust 中是方法(UIKit 用同一种颜色绘制光标和选区,设置了光标颜色时以它为准):
| 样式键 | Rust | 类 | 说明 |
|---|---|---|---|
placeholderColor | .placeholder_color(..) | placeholder:text-* | 提示文字的颜色。 |
caretColor | .caret_color(..) | caret-* | 光标的颜色。 |
selectionColor | .selection_color(..) | selection:text-* | 选区高亮的颜色。 |
input()
.placeholder("搜索")
.text_color(rgb(0x18181b))
.placeholder_color(rgb(0x9ca3af))
.caret_color(rgb(0x2563eb))
.selection_color(rgb(0x93c5fd))<Input placeholder="搜索" class="text-zinc-900 placeholder:text-zinc-400 selection:text-blue-600" />
<Input placeholder="搜索" style={{ placeholderColor: "#9ca3af", caretColor: "#2563eb", selectionColor: "#93c5fd" }} />#iOS
ios 键 | Rust | 类型 | 说明 |
|---|---|---|---|
textContentType | .auto_fill(..) | AutoComplete | TextContentType | autoComplete 在 iOS 上的名称("oneTimeCode"、"newPassword" 等)。 |
inputAccessoryViewID | .input_accessory(id) | string | 一个 InputAccessoryView(InputAccessoryView)的 nativeID,输入框获得焦点时显示在键盘上方。 |
keyboardAppearance | .keyboard_appearance(KeyboardAppearance::Dark) | "default" | "light" | "dark" | 键盘的外观;"default" 跟随系统外观。Android 的键盘跟随系统主题。 |
use nsh_core::element::AutoFill;
input().auto_fill(AutoFill::OneTimeCode).keyboard_type(KeyboardType::Number)<Input autoComplete="one-time-code" keyboardType="number-pad" ios={{ textContentType: "oneTimeCode" }} />#事件
| 事件 | Rust | 载荷 | 触发时机 |
|---|---|---|---|
onChange | .on_change(|text: &str| ..) | { value } | 用户修改了文本:value 是现在的文本。 |
onSubmit | .on_submit(|text: &str| ..) | { text } | 在会提交的输入框中按下了回车键(单行,或 submitBehavior 为 "submit" / "blurAndSubmit")。 |
onFocus / onBlur | .on_focus / .on_blur | 无 | 输入框获得 / 失去焦点。 |
onSelectionChange | .on_selection_change(|s: &Selection| ..) | { start, end } | 光标或选区移动(UTF-16 单位)。 |
onPaste | .on_paste(|e: &PasteEvent| ..) | { text, html, files, uris } | 用户粘贴了内容:文本、带格式的 HTML(Linux、Web)、作为 File 的图片(如截图)、本地文件的 file: URL。在它插入的文本的 onChange 之前(表单与键盘)。 |
onCaretMove | .on_caret_move(|e: &CaretEvent| ..) | { bounds, pageBounds } | 光标随选区移动,或因文本滚动、重新换行而移动:bounds 是它在输入框中的位置,pageBounds 是它在窗口中的位置,可作为 Overlay 的 anchor(表单与键盘)。 |
onKeyPress | .on_key_press(|key: &str| ..) | { key } | 文本变化之前:key 是输入的文本、"Backspace" 或 "Enter"。 |
onContentSizeChange | .on_content_size_change(|size: &Size<Points>| ..) | { size } | 文本尺寸变化(多行时按输入框宽度计算),随输入报告;输入框为空时报告一行空行的尺寸,与占位文字无关。 |
onCompositionChange | .on_composition_change(|e: &Composition| ..) | { composing, text } | 输入法的组字开始、变化或结束:text 是正在组的字(选字前的拼音、变换前的假名),结束时为 ""。上屏的文本先经 onChange 报告,然后才报告结束。 |
随文本增高的多行输入框:
let height = signal(px(40.0));
input()
.multiline(true)
.on_content_size_change(move |size: &Size<Points>| {
height.set(px((size.height.0 + 16.0).clamp(40.0, 120.0)));
})
.h(move || height.get())
.px_3()
.py_2()const height = signal(40);
<Input
multiline
onContentSizeChange={({ size }) => height.set(Math.min(120, Math.max(40, size.height + 16)))}
style={{ height: height(), paddingHorizontal: 12, paddingVertical: 8 }}
/>;#ref 方法
| 方法 | Rust | 说明 |
|---|---|---|
focus() | focus() | 让输入框获得焦点并弹出键盘。 |
blur() | blur() | 让它失去焦点。 |
setSelection(start, end?) | set_selection(start, end) | 选中 start..end(UTF-16 单位);两者相等时为光标。 |
按回车把焦点移到下一个输入框:
use nsh_core::element::AutoFill;
let password = element_ref();
view()
.child(input().return_key_type(ReturnKeyType::Next).on_submit(move |_: &str| password.focus()))
.child(input().element_ref(&password).masked(true).auto_fill(AutoFill::Password))let password!: InputRef;
<>
<Input returnKeyType="next" onSubmit={() => password.focus()} />
<Input ref={password} masked autoComplete="current-password" />
</>;无论哪个输入框持有键盘,nsh 的 Keyboard.dismiss()(Rust 中为 dismiss_keyboard())都能把它收起。
#平台说明
- 在 macOS 上,单行文字、提示文字和光标使用原生输入框一致的字体度量,在内容区域内一起垂直居中。
lineHeight参与布局;多行编辑器同时用它控制段落行高。 - 滚动视图中的输入框会在原生侧自动避开键盘。其他位置请使用
env(keyboard-inset-height)作为底部内边距或外边距。 disabled除了应用禁用样式,还会禁用输入框(不能获得焦点);readonly的输入框仍可获得焦点和选择文本。inputAccessoryViewID在 iOS 上生效,那里的键盘有附件栏。