Skip to content

WinUI 参考: 完整的属性表面与设计建议,参见 Forms

表单是 Microsoft.UI.Reactor(Reactor)中大多数应用投入 UI 时间最多的表面。本页每个输入控件都遵循同一套受控输入契约:你持有当前值,你提供变更处理器,控件渲染你传入的值,并在用户编辑时回调你的处理器。这里没有双向 Binding,没有 INotifyPropertyChanged,没有 DependencyProperty.SetValue —— 值朝一个方向流动(状态 → 控件),编辑朝另一个方向流回(处理器 → 状态)。这个形状源自 hookscomponents,它让每个表单都变得可测试:表单就是 UseState 快照所描述的样子。校验通过 UseValidationContext.Validate(...) 修饰符叠加在上层 —— 校验器每次渲染都跑一遍,FormField 包装器负责标签 / 必填标记 / 错误展示,而 ValidationContext 逐字段跟踪 touched/dirty,因此错误只在用户交互过之后才出现。先读受控输入那一节;本页其余内容都是它的特化。

表单与输入

Reactor 中每个表单控件都遵循受控输入模式:你持有值,你提供变更处理器,控件反映你的状态。不存在双向绑定。数据始终单向流动。

受控输入模式

传入当前值和一个 setter。用户键入时,onChange 带着新值触发。你调用 setter,Reactor 重新渲染,控件显示更新后的文本:

class ControlledInputDemo : Component
{
    public override Element Render()
    {
        var (name, setName) = UseState("");

        return VStack(12,
            SubHeading("Controlled Input"),
            TextBox(name, setName, placeholderText: "Type your name",
                header: "Name"),
            TextBlock($"You typed: {name}").Opacity(0.6)
        ).Padding(24);
    }
}

受控输入

这和 快速上手 里那个 UseState 模式是同一套,只是用在了表单输入上。控件从不持有自己的状态 —— 你的组件是唯一真相来源。

输入控件类型

Reactor 为每种常见输入类型都提供了控件。每个都遵循同一模式:当前值传入,变更处理器传出。

class InputTypesDemo : Component
{
    public override Element Render()
    {
        var (text, setText) = UseState("");
        var (password, setPassword) = UseState("");
        var (volume, setVolume) = UseState(50.0);
        var (count, setCount) = UseState(1.0);
        var (agree, setAgree) = UseState(false);
        var (notify, setNotify) = UseState(true);
        var (role, setRole) = UseState(0);
        var (priority, setPriority) = UseState(0);

        return VStack(12,
            TextBox(text, setText, placeholderText: "Email",
                header: "Email"),
            PasswordBox(password, setPassword,
                placeholderText: "Enter password")
                .Header("Password"),
            Slider(volume, 0, 100, setVolume).Header("Volume"),
            NumberBox(count, setCount, header: "Quantity"),
            CheckBox(agree, setAgree, label: "I agree to the terms"),
            ToggleSwitch(notify, setNotify,
                header: "Notifications"),
            ComboBox(["Admin", "Editor", "Viewer"],
                role, setRole).Header("Role"),
            (RadioButtons(["Low", "Medium", "High"],
                priority, setPriority) with { Header = "Priority" })
        ).Padding(24);
    }
}

所有输入类型

控件 值类型 变更处理器
TextBox string Action<string>
PasswordBox string Action<string>
Slider double Action<double>
NumberBox double Action<double>
CheckBox bool Action<bool>
ToggleSwitch bool Action<bool>
ComboBox int(索引) Action<int>
RadioButtons int(索引) Action<int>

所有控件都接受标签、标题头和占位符文本等可选参数。各控件完整签名请查 API 参考。

配置 TextBox

TextBox 通过专门的流畅方法覆盖了 WinUI TextBox 的常用开关,因此你很少需要用到 .Set(...)。那些命名输入形态会设置相应的 InputScope,用于软键盘与输入法提示:

class TextBoxConfigDemo : Component
{
    public override Element Render()
    {
        var (qty, setQty) = UseState("");
        var (email, setEmail) = UseState("");
        var (url, setUrl) = UseState("");
        var (phone, setPhone) = UseState("");
        var (search, setSearch) = UseState("");
        var (note, setNote) = UseState("");

        return VStack(12,
            TextBox(qty, setQty, header: "Quantity")
                .NumericInput(),
            TextBox(email, setEmail, header: "Email")
                .EmailInput(),
            TextBox(url, setUrl, header: "URL")
                .UrlInput(),
            TextBox(phone, setPhone, header: "Phone")
                .PhoneInput(),
            TextBox(search, setSearch, placeholderText: "Search…",
                header: "Search")
                .SearchInput(),
            TextBox(note, setNote, header: "Reference code")
                .MaxLength(8)
                .CharacterCasing(CharacterCasing.Upper)
                .TextAlignment(TextAlignment.Center)
                .IsSpellCheckEnabled(false)
                .Description("Eight characters, automatically uppercased.")
        ).Padding(24);
    }
}
流畅方法 效果
.NumericInput() InputScope = Number —— 数字软键盘
.EmailInput() InputScope = EmailSmtpAddress
.UrlInput() InputScope = Url
.PhoneInput() InputScope = TelephoneNumber
.SearchInput() InputScope = Search
.MaxLength(n) 把输入长度限制在 n 个字符
.IsSpellCheckEnabled(bool) 开关拼写波浪下划线
.CharacterCasing(casing) Normal / Upper / Lower
.TextAlignment(alignment) 字段内文本对齐方式
.Description(text) 渲染在字段下方的辅助文本

这些流畅方法可以自由串联;要给移动 / 触屏键盘提供提示,命名输入形态是规范做法,而不是去够 .Set(c => c.InputScope = ...)。理由参见 spec 039 的 §2.3 与 §4.7。

简单校验

对于快速上手的表单,直接从状态推导校验结果。每次渲染都算出布尔值,就地显示错误消息,并在提交处理器里再检查一遍这些布尔值:

class ValidationDemo : Component
{
    public override Element Render()
    {
        var (email, setEmail) = UseState("");
        var (age, setAge) = UseState(0.0);
        var (showErrors, setShowErrors) = UseState(false);

        var emailValid = email.Contains('@') && email.Contains('.');
        var ageValid = age >= 18 && age <= 120;
        var formValid = emailValid && ageValid
            && !string.IsNullOrWhiteSpace(email);

        return VStack(12,
            SubHeading("Simple Validation"),
            TextBox(email, setEmail, placeholderText: "user@example.com",
                header: "Email"),
            When(!string.IsNullOrEmpty(email) && !emailValid, () =>
                TextBlock("Enter a valid email address")
                    .Foreground(Theme.SystemCritical).FontSize(12)),
            NumberBox(age, setAge, header: "Age"),
            When((showErrors || age > 0) && !ageValid, () =>
                TextBlock("Age must be between 18 and 120")
                    .Foreground(Theme.SystemCritical).FontSize(12)),
            Button("Submit", () =>
            {
                setShowErrors(true);
                if (!formValid) return;
                // submit...
            }).Margin(0, 8, 0, 0)
        ).Padding(24);
    }
}

校验演示

小表单这样处理很合适。对于有跨字段规则、touched/dirty 跟踪和错误显示策略的大表单,请用下面介绍的校验框架。

保持提交按钮可达

一个常见的表单模式 —— 在表单合法之前禁用提交按钮 —— 一旦与 NumberBoxDatePickerCalendarDatePicker 这类控件组合,就成了键盘无障碍陷阱:它们在失焦时才提交值,而非每次击键都提交。用户修好最后一个非法字段,按 Tab 想去够提交按钮,焦点却跳过了那个仍然禁用的按钮。等字段提交完成、按钮变为可用时,焦点早已移走。用户被困住了:那个现在可以按下的按钮,键盘已经够不着。

Reactor 提供两个可选启用的修复。它们可以组合使用:

class KeepSubmitReachableDemo : Component
{
    public override Element Render()
    {
        var (email, setEmail) = UseState("");
        var (age, setAge) = UseState(0.0);

        var emailValid = email.Contains('@') && email.Contains('.');
        var ageValid = age >= 18 && age <= 120;
        var formValid = emailValid && ageValid;

        return VStack(12,
            SubHeading("Keeping Submit Reachable"),
            TextBox(email, setEmail, header: "Email",
                placeholderText: "user@example.com"),

            // .Immediate() 把 NumberBox 从失焦提交切换为击键提交,
            // 因此校验会随着用户输入即时响应。
            NumberBox(age, setAge, header: "Age").Immediate(),

            // .IsDisabledFocusable() 让按钮保持 Tab 可达并视觉变暗,
            // 同时阻止其被触发。该模式对应 Fluent UI 的 `disabledFocusable`
            // 与 ARIA 的 `aria-disabled`。
            Button("Submit", () => { /* submit */ })
                .IsDisabledFocusable(!formValid)
                .Margin(0, 8, 0, 0)
        ).Padding(24);
    }
}

保持提交按钮可达

Button 上的 .IsDisabledFocusable(bool) 让按钮保持键盘可聚焦、Tab 可达,同时呈现为禁用态(变暗;点击被抑制)。对应 Fluent UI React 的 disabledFocusable 与 ARIA 的 aria-disabled。任何由校验、忙碌状态或其他派生条件把门的提交按钮都该用它。

NumberBox 上的 .Immediate()(位于 Microsoft.UI.Reactor.Controls.Validation)把控件从失焦提交切换为击键提交,因此校验会对每一个可解析的数字作出反应,而不必等焦点离开字段。刻意设计成可选启用 —— 失焦提交是 WinUI 的默认行为,当中间值开销很大或令人意外时(编辑过程中把 2.50 吸附成 2.5),它才是正确的选择。当校验把守着 UI 状态、而你希望它有实时感时,就启用它。

只要按钮在表单中是条件性禁用的,就用 .IsDisabledFocusable() —— 即便你已经给每个失焦提交的输入都加了 .Immediate()。两者覆盖的是不同的失效模式:.Immediate() 让合法性与键入保持同步;.IsDisabledFocusable() 则保证当合法性取决于异步检查、必填但未填写的字段、跨字段规则,或任何无法做到瞬时的派生条件时,按钮依然可被发现。

.IsDisabledFocusable() 不该用在哪儿: 只有按钮。对于数据录入控件(TextBoxNumberBoxCheckBox 等),IsEnabled=false 通常意味着"这个字段不属于你当前的任务"(由另一个输入级联而来),此时跳过 Tab 焦点才是正确的 UX。如果你需要一个可见但不可编辑的文本控件,用 IsReadOnly

校验上下文

UseValidationContext() 创建一个 ValidationContext,跟踪消息、touched/dirty 状态以及字段注册。用 .Validate() 把校验器挂到控件上:

class ValidationContextDemo : Component
{
    public override Element Render()
    {
        var ctx = this.UseValidationContext();
        var (email, setEmail) = UseState("");
        var (password, setPassword) = UseState("");
        var (submitted, setSubmitted) = UseState(false);

        return VStack(12,
            SubHeading("Validation Context"),
            TextBox(email, v => { setEmail(v); ctx.NotifyValueChanged("email", v); },
                placeholderText: "user@example.com", header: "Email")
                .Validate("email", email,
                    Validate.Required(),
                    Validate.Email()),
            When(ctx.IsTouched("email") && ctx.HasError("email"), () =>
                TextBlock(ctx.GetMessages("email").First().Text)
                    .Foreground(Theme.SystemCritical).FontSize(12)),
            PasswordBox(password, v => { setPassword(v); ctx.NotifyValueChanged("password", v); },
                placeholderText: "Min 8 characters")
                .Header("Password")
                .Validate("password", password,
                    Validate.Required(),
                    Validate.MinLength(8)),
            When(ctx.IsTouched("password") && ctx.HasError("password"), () =>
                TextBlock(ctx.GetMessages("password").First().Text)
                    .Foreground(Theme.SystemCritical).FontSize(12)),
            Button("Register", () =>
            {
                ctx.MarkAllTouched();
                if (ctx.IsValid()) setSubmitted(true);
            }).IsEnabled(!submitted),
            When(submitted, () =>
                TextBlock("Registration successful!")
                    .Foreground(Theme.SystemSuccess).SemiBold())
        ).Padding(24);
    }
}

校验上下文

关键部件:

  • UseValidationContext() 创建或取回最近的校验上下文。
  • .Validate(fieldName, value, validators...) 把校验器挂到控件上。
  • Validate.Required()Validate.Email() 是 11 个内置校验器。
  • ctx.IsValid() 在不存在 error 级别消息时返回 true
  • ctx.MarkAllTouched() 在尝试提交时一次性揭示所有错误。

FormField 辅助器

FormField() 用标签、必填标识、说明文本和行内错误展示把控件包起来:

class FormFieldDemo : Component
{
    public override Element Render()
    {
        var ctx = this.UseValidationContext();
        var (name, setName) = UseState("");
        var (email, setEmail) = UseState("");

        return VStack(12,
            SubHeading("FormField Helper"),
            FormField(
                TextBox(name, v => { setName(v); ctx.NotifyValueChanged("name", v); })
                    .AutomationName("Full Name")
                    .Validate("name", name, Validate.Required()),
                label: "Full Name",
                required: true,
                description: "As it appears on your ID"),
            FormField(
                TextBox(email, v => { setEmail(v); ctx.NotifyValueChanged("email", v); })
                    .AutomationName("Email Address")
                    .Validate("email", email,
                        Validate.Required(), Validate.Email()),
                label: "Email Address",
                required: true)
        ).Padding(24);
    }
}

FormField 辅助器

FormField 会依据其内容上挂的 .Validate() 自动推断字段名。字段被 touch 之后(聚焦再失焦),错误显示在字段下方。ShowWhen 参数控制错误何时可见:WhenTouched(默认)、WhenDirtyAfterFirstSubmitAlwaysNever

内置校验器

校验器 用途
Validate.Required() 非空、非空白、非默认值
Validate.MinLength(n) 字符串长度 >= n
Validate.MaxLength(n) 字符串长度 <= n
Validate.Range(min, max) 数值落在区间内
Validate.Match(regex) 正则匹配
Validate.Email() 合法电子邮件地址
Validate.Url() 合法 URL(http/https)
Validate.Must<T>(predicate, message) 自定义谓词
Validate.MustAsync<T>(predicate, message) 异步谓词
Validate.MustBeTrue() 布尔为真(复选框)
Validate.EqualTo<T>(value) 相等性检查(密码确认)

每个校验器都接受一个可选的自定义错误消息作为最后一个参数。

遮罩输入

MaskEngine 应用带自动插入字面量的输入遮罩。可以用内置预设,也可以自定义模式:

class MaskedInputDemo : Component
{
    public override Element Render()
    {
        var phoneMask = UseMemo(() => new MaskEngine(MaskPreset.PhoneUS));
        var dateMask = UseMemo(() => new MaskEngine(MaskPreset.Date));
        var (phone, setPhone) = UseState("");
        var (date, setDate) = UseState("");

        return VStack(12,
            SubHeading("Masked Input"),
            TextBox(phoneMask.Apply(phone), v => setPhone(phoneMask.GetRawValue(v)),
                placeholderText: "(___) ___-____", header: "Phone"),
            TextBlock($"Raw: {phone}").FontSize(12).Opacity(0.6),
            TextBox(dateMask.Apply(date), v => setDate(dateMask.GetRawValue(v)),
                placeholderText: "__/__/____", header: "Date"),
            TextBlock($"Raw: {date}").FontSize(12).Opacity(0.6)
        ).Padding(24);
    }
}

遮罩输入

遮罩记号:0 = 必填数字,9 = 可选数字,A = 必填字母,a = 可选字母,* = 必填字母或数字。其他所有字符都是自动插入的字面量。

预设 模式
MaskPreset.PhoneUS (000) 000-0000
MaskPreset.SSN 000-00-0000
MaskPreset.CreditCard 0000 0000 0000 0000
MaskPreset.Date 00/00/0000
MaskPreset.ZipCode 00000
MaskPreset.IPv4 099.099.099.099

输入格式化器

InputFormatter 在用户键入时转换文本。把格式化器串成管线可以完成复杂格式化:

class InputFormattersDemo : Component
{
    public override Element Render()
    {
        var (currency, setCurrency) = UseState("");
        var (upper, setUpper) = UseState("");

        var currencyFmt = UseMemo(() => InputFormatter.Currency());
        var upperFmt = UseMemo(() => InputFormatter.UpperCase);

        return VStack(12,
            SubHeading("Input Formatters"),
            TextBox(currencyFmt.Format(currency, 0).Output,
                v => setCurrency(currencyFmt.Parse(v)),
                placeholderText: "$0.00", header: "Amount"),
            TextBox(upperFmt.Format(upper, 0).Output,
                v => setUpper(upperFmt.Parse(v)),
                placeholderText: "UPPERCASE", header: "Code")
        ).Padding(24);
    }
}

输入格式化器

内置格式化器:PhoneUSCurrency()UpperCaseLowerCaseTitleCaseTrimWhitespaceMaxLength(n)AllowOnly(regex)DenyOnly(regex),以及 Custom(format, parse)

AutoSuggestBox

AutoSuggestBox 是"带建议的搜索"输入 —— 键入前缀,看到筛选后的列表,选一项或自由提交。工厂方法接受文本值、一个 onTextChanged 处理器,以及一个可选的 onQuerySubmitted 处理器(回车或选中建议时触发):

class AutoSuggestDemo : Component
{
    static readonly string[] Catalog =
    [
        "Aardvark", "Albatross", "Antelope", "Badger",
        "Beaver", "Buffalo", "Camel", "Capybara"
    ];

    public override Element Render()
    {
        var (text, setText) = UseState("");

        var matches = string.IsNullOrEmpty(text)
            ? Array.Empty<string>()
            : Catalog.Where(c =>
                c.StartsWith(text, StringComparison.OrdinalIgnoreCase))
                .ToArray();

        return VStack(8,
            SubHeading("AutoSuggestBox"),
            AutoSuggestBox(text, setText,
                onQuerySubmitted: q => setText(q))
                .Header("Animal")
                .QueryIcon(SymbolIcon("Find"))
                .Width(280),
            // 建议列表 —— 需要控件内置下拉时,通过 .Set 绑定到
            // AutoSuggestBox.ItemsSource;下面的内联列表是自定义呈现,
            // 换来完全的样式控制权。
            When(matches.Length > 0, () =>
                VStack(2,
                    ForEach(matches, m =>
                        TextBlock(m).Padding(8, 4).WithKey(m))
                ).Background(Theme.CardBackground).Width(280))
        ).Padding(24);
    }
}

带筛选建议的 AutoSuggestBox

流畅方法 效果
.Header(string) 渲染在输入框上方的标签。
.QueryIcon(IconData) 尾部图标(通常是搜索字形)。
.IsSuggestionListOpen(bool) 强制下拉展开/收起。
.SuggestionChosen(Action<string>) 用户从底层下拉中选中某项时触发。
.Set(b => b.ItemsSource = ...) 绑定到 WinUI 的建议列表。

上面的代码片段以内联列表作为自定义呈现来渲染建议 —— 比内置下拉有更大的布局自由度,代价是视觉表面要你自己写。规范化的搜索结果范例参见 recipes/search-with-suggestions

WinUI 设计页:Auto-suggest box

日期与时间控件

Reactor 暴露了三个日期/时间输入和一个日历表面,各自覆盖同一问题的不同形状:

控件 形态 何时使用
DatePicker DateTimeOffset(非空) 内联的三栏滚选器 日期必定需要,且有内联空间可用。
CalendarDatePicker DateTimeOffset? 打开弹出日历的按钮 日期可选;你想要一个紧凑触发器。
CalendarView IReadOnlyList<DateTimeOffset> 完整月视图网格 从网格中单选、多选或区间选择。
TimePicker TimeSpan 时 / 分 / 上午下午滚选器 与日期无关的时刻。
class DatePickerDemo : Component
{
    public override Element Render()
    {
        var (date, setDate) = UseState(DateTimeOffset.Now);
        var (optionalDate, setOptionalDate) = UseState<DateTimeOffset?>(null);

        return VStack(8,
            SubHeading("DatePicker — always-set value"),
            DatePicker(date, setDate)
                .DayFormat("{day.integer(2)}")
                .MonthFormat("{month.abbreviated}")
                .YearFormat("{year.full}"),
            TextBlock($"Selected: {date:yyyy-MM-dd}").Opacity(0.6),
            SubHeading("CalendarDatePicker — nullable, popup calendar"),
            CalendarDatePicker(optionalDate, setOptionalDate)
                .DateFormat("{month.abbreviated} {day.integer(2)}, {year.full}")
                .IsTodayHighlighted(),
            TextBlock(optionalDate is null
                ? "No date selected."
                : $"Selected: {optionalDate:yyyy-MM-dd}").Opacity(0.6)
        ).Padding(24);
    }
}

DatePicker(必有值)与 CalendarDatePicker(可为空)

class TimePickerDemo : Component
{
    public override Element Render()
    {
        var (time, setTime) = UseState(TimeSpan.FromHours(9));

        return VStack(8,
            SubHeading("TimePicker"),
            TimePicker(time, setTime),
            TextBlock($"Selected: {time:hh\\:mm}").Opacity(0.6)
        ).Padding(24);
    }
}

绑定到 TimeSpan 的 TimePicker

class CalendarViewDemo : Component
{
    public override Element Render()
    {
        var (dates, setDates) = UseState<IReadOnlyList<DateTimeOffset>>(
            Array.Empty<DateTimeOffset>());

        return VStack(8,
            SubHeading("CalendarView — month grid"),
            CalendarView()
                .MinDate(DateTimeOffset.Now.AddYears(-1))
                .MaxDate(DateTimeOffset.Now.AddYears(1))
                .NumberOfWeeksInView(6)
                .SelectedDatesChanged(setDates)
                .SelectedDates(dates),
            TextBlock($"{dates.Count} day(s) selected").Opacity(0.6)
        ).Padding(24);
    }
}

多选的 CalendarView 月视图网格

DatePicker / TimePicker / CalendarDatePicker 与底层 WinUI DateTimeFormatter 共用格式字符串 —— "{day.integer(2)}""{month.abbreviated}""{year.full}" 之类。CalendarView 暴露 .MinDate / .MaxDate 用于区间约束,以及 .NumberOfWeeksInView 用于压缩网格。要做多日选择,把 .SelectedDates(...).SelectedDatesChanged(...) 配对使用 —— 事件交给你的当前选择的完整快照,而不是增删增量(与 ListView 多选 形状相同)。

WinUI 设计页:Date pickerTime pickerCalendar view

ColorPicker

ColorPicker 接受一个 Windows.UI.Color 和一个变更处理器 —— 与 Slider 相同的受控模式。它的表面是本页所有输入中最大的;按你需要的取色器形态来配置它:

class ColorPickerDemo : Component
{
    public override Element Render()
    {
        var (color, setColor) = UseState(
            global::Windows.UI.Color.FromArgb(255, 0, 120, 215));

        return VStack(8,
            SubHeading("ColorPicker"),
            ColorPicker(color, setColor)
                .AlphaEnabled()
                .HexInputVisible(true)
                .ColorSpectrumShape(
                    Microsoft.UI.Xaml.Controls.ColorSpectrumShape.Ring),
            TextBlock($"Selected: #{color.A:X2}{color.R:X2}{color.G:X2}{color.B:X2}")
                .FontSize(12)
                .Foreground(Theme.SecondaryText)
        ).Padding(24);
    }
}

带 alpha 与十六进制输入的 ColorPicker

流畅方法 效果
.AlphaEnabled(bool) 显示 alpha 滑块并按 alpha 值做预乘。
.ColorSpectrumShape(shape) Box(默认)或 Ring
.HexInputVisible(bool) 开关十六进制输入字段。
.ColorSpectrumVisible(bool) 开关主二维色谱。
.ColorSliderVisible(bool) 开关色相滑块。
.ColorChannelTextInputVisible(bool) 开关 RGB 数字输入。
.HueRange(min, max) / .SaturationRange(min, max) / .ValueRange(min, max) 约束可取的色相 / 饱和度 / 明度。
.MoreButtonVisible(bool) 展开取色器的"更多"V 形按钮。

要做调色板取色器(Material 风格的色板网格),请用 ButtonBackground(color) 自己搭表面,而不是用这个控件 —— 只有在用户需要从完整色彩空间连续取色时,ColorPicker 才是正确的形态。

WinUI 设计页:Color picker

注意: 失焦提交的输入(NumberBoxDatePickerCalendarDatePickerTimePicker不会每次击键都触发变更处理器 —— 它们在焦点离开字段时才触发。因此,从这些控件的值推导出的校验在用户 Tab 出去之前都是过期的。经典的失效模式:一个"表单合法前禁用"的提交按钮,在用户仍停留在某个已经填了合法值的 NumberBox 里时,一直是禁用的。用户修好最后一个字段,按 Tab 想去够提交按钮,焦点跳过那个仍被禁用的按钮,于是用户被困住。两个修复可以叠加:在输入上加 .Immediate() 把它切成逐击键提交(见保持提交按钮可达),在提交按钮上加 .IsDisabledFocusable() 让它在被把门期间仍保持键盘可聚焦。

模式

共享 ValidationContext 的多步表单

向导式表单跨越多个页面,但校验只存在于一处。把 UseValidationContext() 提升到向导组件,并通过 context 向下传递 —— 每一步的 .Validate(...) 都写入同一个存储,最终提交时用 ctx.IsValid() 跨所有步骤检查。recipes/multi-step-form 范例走完了完整模式;关键在于每个表单一个上下文,而不是每页一个。

回车提交

对于单字段表单(搜索框、评论输入),把提交接到回车上,而不是放一个提交按钮。AutoSuggestBox 通过 onQuerySubmitted 自动支持这一点。对于 TextBox,走 .OnKeyDown 并检查 VirtualKey.Enter;路由事件的表面参见 input-and-gestures

异步校验(唯一性检查)

Validate.MustAsync<T>(...) 运行一个返回 Task<bool> 的谓词。ValidationContext 跟踪进行中的异步工作,并逐字段报告 IsValidating,因此提交按钮可以在异步校验运行期间置为禁用。配合 .IsDisabledFocusable() 使用,让按钮在校验期间仍留在 Tab 序中 —— 与保持提交按钮可达是同一个无障碍考量。

常见错误

让控件持有自己的状态

// 不要这样 —— 没有 `initial:` 参数。裸字符串会绑定一个值却没有 `onChanged`,
// 于是它给输入框种了个初始值,然后没有任何东西把它读回来:
// 用户键入的内容只存在于原生控件里。
TextBox("default value")
class ControlledInputDemo : Component
{
    public override Element Render()
    {
        var (name, setName) = UseState("");

        return VStack(12,
            SubHeading("Controlled Input"),
            TextBox(name, setName, placeholderText: "Type your name",
                header: "Name"),
            TextBlock($"You typed: {name}").Opacity(0.6)
        ).Padding(24);
    }
}

真正要紧的区分不是"受控 vs. 不受控",而是绑定是否完整。把值整个省略,控件就拥有自己的状态,父组件无法读取、重置或预填。像上面那样传了值却没有 onChanged,你得到的是一个半截绑定:值被预填了,父组件仍可以通过传另一个值来替换它,但没有任何东西流回来。用户的编辑停留在原生控件里,永远到不了应用状态,因此之后读取你的状态,返回的仍是最初那个字符串,就好像什么都没键入过。

注意这不会逐击键地与用户作对:元素在两次渲染之间没有变化,于是协调器的浅相等跳过生效,永远不会重写输入框。这正是这个 bug 安静的原因 —— 文本看起来被接受了,只是从未被收集。如果文本确实是固定的,就用 .IsReadOnly(true) 明说;否则请把两半都提供。Reactor 的输入是一起接受 (value, onChanged) 的;两半都给,控件就是状态的一个被动视图。

在点击处理器里做校验

// 不要这样:
Button("Submit", () =>
{
    if (string.IsNullOrEmpty(email)) { setError("…"); return; }
    if (!email.Contains('@'))         { setError("…"); return; }
    if (age < 18)                      { setError("…"); return; }
    // submit…
})

命令式检查会在提交处理器里把每个字段的校验复制一遍,与行内错误展示逐渐脱节,并在新增字段时过期失效。请改用 UseValidationContext + .Validate(...),让每个字段自带规则;提交处理器就收缩成一次 ctx.IsValid() 调用。

提示

始终使用受控输入。 绝不让控件自行管理状态。你的 Render() 方法是唯一真相来源 —— 需要预填、校验或重置某个字段时,只要设置状态值即可。

生产环境表单用 FormField 它以一致的样式处理标签、必填标识和错误展示。别自己搭字段包装器。

自定义规则用 Validate.Must<T>() 当内置校验器覆盖不到你的场景时,Must 接受任意 Func<T, bool> 谓词。

提交时调用 ctx.MarkAllTouched() 这会一次性揭示所有校验错误,让用户看到所有需要修的地方。

ctx.ResetAll() 重置表单。 它把所有字段恢复到初始值,清空 touched/dirty 状态,并移除所有消息。

下一步

  • Flex 布局 —— 上一篇:面向自适应 UI 的弹性盒布局
  • 集合 —— 下一篇:渲染列表、网格与虚拟化数据集
  • Hooks —— UseStateUseMemo 以及其他驱动表单逻辑的 Hook
  • 命令 —— 把提交按钮接到带忙碌/错误处理的异步命令上
  • 样式与主题 —— 用主题令牌与轻量样式化美化你的表单