WinUI 参考: 完整的属性表面与设计建议,参见 Forms。
表单是 Microsoft.UI.Reactor(Reactor)中大多数应用投入 UI 时间最多的表面。本页每个输入控件都遵循同一套受控输入契约:你持有当前值,你提供变更处理器,控件渲染你传入的值,并在用户编辑时回调你的处理器。这里没有双向 Binding,没有 INotifyPropertyChanged,没有 DependencyProperty.SetValue —— 值朝一个方向流动(状态 → 控件),编辑朝另一个方向流回(处理器 → 状态)。这个形状源自 hooks 和 components,它让每个表单都变得可测试:表单就是 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 跟踪和错误显示策略的大表单,请用下面介绍的校验框架。
保持提交按钮可达¶
一个常见的表单模式 —— 在表单合法之前禁用提交按钮 —— 一旦与 NumberBox、DatePicker、CalendarDatePicker 这类控件组合,就成了键盘无障碍陷阱:它们在失焦时才提交值,而非每次击键都提交。用户修好最后一个非法字段,按 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()不该用在哪儿: 只有按钮。对于数据录入控件(TextBox、NumberBox、CheckBox等),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 会依据其内容上挂的 .Validate() 自动推断字段名。字段被 touch 之后(聚焦再失焦),错误显示在字段下方。ShowWhen 参数控制错误何时可见:WhenTouched(默认)、WhenDirty、AfterFirstSubmit、Always 或 Never。
内置校验器¶
| 校验器 | 用途 |
|---|---|
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);
}
}

内置格式化器:PhoneUS、Currency()、UpperCase、LowerCase、TitleCase、TrimWhitespace、MaxLength(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);
}
}

| 流畅方法 | 效果 |
|---|---|
.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);
}
}

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);
}
}

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);
}
}

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

| 流畅方法 | 效果 |
|---|---|
.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 风格的色板网格),请用 Button 加 Background(color) 自己搭表面,而不是用这个控件 —— 只有在用户需要从完整色彩空间连续取色时,ColorPicker 才是正确的形态。
WinUI 设计页:Color picker。
注意: 失焦提交的输入(
NumberBox、DatePicker、CalendarDatePicker、TimePicker)不会每次击键都触发变更处理器 —— 它们在焦点离开字段时才触发。因此,从这些控件的值推导出的校验在用户 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 状态,并移除所有消息。