forms-and-input
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseForms and input
表单与输入
Text input is where most disposal leaks, un-localized strings, and jank enter a Flutter app. A form is a small state machine: fields hold text, a validates them, and a ViewModel owns anything that touches the network or the clock. Keep those three responsibilities separate.
FormStateRead the reference for the task at hand:
- — sync
references/validation-sync-and-async.mdreturning localizedvalidator,String?choice, and the debounced-async-in-a-Notifier pattern (why async must NOT live in the sync validator).AutovalidateMode - —
references/focus-and-keyboard.mdlifecycle, traversal order,FocusNode,autofocus,FocusTraversalGroup,TextInputAction/onFieldSubmitted,onEditingComplete,keyboardType,autofillHints, keyboard-avoidance.TextInputFormatter
Run before a PR.
scripts/check_forms.sh文本输入是 Flutter app 中大多数资源释放泄漏、未本地化字符串和卡顿的源头。表单是一个小型状态机:字段保存文本, 负责验证字段,而 ViewModel 负责任何与网络或时钟交互的工作。保持这三种职责相互分离。
FormState根据当前任务阅读参考资料:
- — 返回本地化
references/validation-sync-and-async.md的同步String?、validator选择,以及在 Notifier 中实现防抖异步的模式(说明为什么异步逻辑绝不能放在同步 validator 中)。AutovalidateMode - —
references/focus-and-keyboard.md生命周期、遍历顺序、FocusNode、autofocus、FocusTraversalGroup、TextInputAction/onFieldSubmitted、onEditingComplete、keyboardType、autofillHints和键盘避让。TextInputFormatter
提交 PR 前运行 。
scripts/check_forms.shNon-negotiable rules
不可妥协的规则
- Every and
TextEditingControllercreated in aFocusNodeis disposed inState. They hold native resources and listeners; a leak survives the widget and fires callbacks against a dead tree. If the value must outlive the widget, hold it in a Notifier instead — seedispose().state-management-riverpod - Validator messages are localized, never hardcoded. A returns
validator, notAppLocalizations.of(context).fieldRequired. Error text is user-facing UI copy and is owned by'Required'. Thei18n-rtl-l10ngrep fails on string literals returned from a validator.check_forms.sh - The sync is pure and instant — no
validator, no network, noawait.FutureisFormFieldValidator<T>; it cannot be async and Flutter calls it synchronously during layout. Availability/uniqueness checks belong in a Notifier (rule 4).String? Function(T?) - Async validation lives in a debounced Riverpod Notifier and surfaces through state. Debounce with a
dart:async(kept deterministic in tests viaTimer, not by the clock), run the check, and exposefakeAsync/a sealed status the field reads viaAsyncValue. Any timestamp the check records comes fromInputDecoration.errorText, neverref.read(clockProvider).now()— the Clock seam is owned byDateTime.now(). Never block a keystroke on I/O. Seeservice-boundary-and-nativefor cancel-on-dispose.async-safety - Submit-enabled is DERIVED from validity, never stored as a separate . A stored
boolflag drifts out of sync with the fields. Compute it from_isValid/Notifier state at build time. SeeFormState(derive-don't-store).flutter-performance - A keystroke rebuilds one field, not the whole form. Give each field its own controller/; do not lift raw text into a top-level
FormField/setStatethat rebuilds every sibling. Scope rebuilds with small widgets andwatch. Seeref.watch(....select(...))andwidget-composition.flutter-performance - Choose deliberately. Default to
AutovalidateMode: silent until the user touches a field, then live. NeverAutovalidateMode.onUserInteraction(screams before the user types). Validate-on-submit only for short forms where per-field feedback is noise.always - Keyboard type, capitalization, and autofill are declared per field. ,
keyboardType,textCapitalization, andautofillHintss are structural input contracts, not decoration. A missingTextInputFormatterbreaks OS autofill and password managers.autofillHints - Errors are announced, not just colored. /
InputDecoration.labelTextcarry semantics that screen readers read on change; never signal an error with color alone. SeeerrorText.accessibility-as-code
- 在 中创建的每个
State和TextEditingController都必须在FocusNode中释放。 它们持有原生资源和监听器;泄漏会跨越 widget 的生命周期,并继续针对已经销毁的组件树触发回调。如果某个值必须比 widget 存活更久,应将其保存在 Notifier 中,参见dispose()。state-management-riverpod - Validator 消息必须本地化,绝不能硬编码。 应返回
validator,而不是AppLocalizations.of(context).fieldRequired。错误文本是面向用户的 UI 文案,归'Required'管理。如果 validator 返回字符串字面量,i18n-rtl-l10n的 grep 检查就会失败。check_forms.sh - 同步 必须纯粹且即时完成——不能包含
validator、网络请求或await。Future是FormFieldValidator<T>;它不能是异步的,而且 Flutter 会在布局期间同步调用它。可用性或唯一性检查应放在 Notifier 中(规则 4)。String? Function(T?) - 异步验证应位于防抖的 Riverpod Notifier 中,并通过 state 暴露结果。 使用 的
dart:async进行防抖(测试中通过Timer保持确定性,而不是依赖时钟),执行检查,并公开fakeAsync或 sealed 状态,让字段通过AsyncValue读取。检查记录的任何时间戳都应来自InputDecoration.errorText,绝不能使用ref.read(clockProvider).now()——Clock 抽象边界由DateTime.now()管理。绝不能让一次按键被 I/O 阻塞。取消处理参见service-boundary-and-native中的 dispose 规则。async-safety - 提交按钮的启用状态应由有效性派生,绝不能作为独立的 存储。 单独存储的
bool标记会与字段实际状态失去同步。应在 build 时根据_isValid或 Notifier state 计算。参见FormState的 derive-don't-store 原则。flutter-performance - 一次按键只能重建一个字段,而不是整个表单。 为每个字段提供独立的 controller 和 ;不要在顶层
FormField或setState中保存原始文本并导致所有同级字段重建。使用小型 widget 和watch限定重建范围。参见ref.watch(....select(...))和widget-composition。flutter-performance - 有意选择 。 默认使用
AutovalidateMode:用户接触字段前保持静默,之后实时验证。不要使用AutovalidateMode.onUserInteraction(会在用户开始输入前就过早展示错误)。仅当表单很短、逐字段反馈会造成干扰时,才使用提交时验证。always - 按字段声明键盘类型、大小写策略和自动填充。 、
keyboardType、textCapitalization和autofillHints属于结构性的输入约定,而不是装饰配置。缺少TextInputFormatter会破坏操作系统的自动填充和密码管理器功能。autofillHints - 错误必须通过语义播报,而不只是用颜色表示。 和
InputDecoration.labelText包含屏幕阅读器在变化时读取的语义信息;绝不能仅依靠颜色来表达错误。参见errorText。accessibility-as-code
Form skeleton
表单骨架
FormGlobalKey<FormState>validate()save()dart
class TaskForm extends StatefulWidget {
const TaskForm({super.key, required this.onSubmit});
final void Function(String title) onSubmit;
State<TaskForm> createState() => _TaskFormState();
}
class _TaskFormState extends State<TaskForm> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _titleFocus = FocusNode();
void dispose() {
_titleController.dispose(); // rule 1: always dispose
_titleFocus.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState?.validate() ?? false) {
widget.onSubmit(_titleController.text.trim());
}
}
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction, // rule 7
child: Column(
children: [
TextFormField(
controller: _titleController,
focusNode: _titleFocus,
autofocus: true,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.taskTitleLabel),
validator: (value) => // rule 2 + 3: localized, pure
(value == null || value.trim().isEmpty) ? l10n.fieldRequired : null,
onFieldSubmitted: (_) => _submit(),
),
],
),
);
}
}FormGlobalKey<FormState>validate()save()dart
class TaskForm extends StatefulWidget {
const TaskForm({super.key, required this.onSubmit});
final void Function(String title) onSubmit;
State<TaskForm> createState() => _TaskFormState();
}
class _TaskFormState extends State<TaskForm> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _titleFocus = FocusNode();
void dispose() {
_titleController.dispose(); // rule 1: always dispose
_titleFocus.dispose();
super.dispose();
}
void _submit() {
if (_formKey.currentState?.validate() ?? false) {
widget.onSubmit(_titleController.text.trim());
}
}
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction, // rule 7
child: Column(
children: [
TextFormField(
controller: _titleController,
focusNode: _titleFocus,
autofocus: true,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.sentences,
decoration: InputDecoration(labelText: l10n.taskTitleLabel),
validator: (value) => // rule 2 + 3: localized, pure
(value == null || value.trim().isEmpty) ? l10n.fieldRequired : null,
onFieldSubmitted: (_) => _submit(),
),
],
),
);
}
}Sync validation
同步验证
The is a total, synchronous function of the field value. Return for valid, a localized message otherwise. Compose small checks; keep the closure short.
validatornulldart
String? validateTitle(String? value, AppLocalizations l10n) {
final text = value?.trim() ?? '';
if (text.isEmpty) return l10n.fieldRequired;
if (text.length > 120) return l10n.fieldTooLong; // structural bound, not design
return null;
}validatornulldart
String? validateTitle(String? value, AppLocalizations l10n) {
final text = value?.trim() ?? '';
if (text.isEmpty) return l10n.fieldRequired;
if (text.length > 120) return l10n.fieldTooLong; // structural bound, not design
return null;
}Async validation (out of the validator)
异步验证(移出 validator)
An availability check (is this account name taken?) is I/O. It runs in a Notifier, debounced against , and the field reads the result through . The sync stays pure and only guards the shape of the input. Full pattern in and .
clockProvidererrorTextvalidatorreferences/validation-sync-and-async.mdexamples/async_field_notifier.dartdart
// The field is driven by Notifier state, not by an async validator.
final status = ref.watch(nameAvailabilityNotifierProvider);
TextFormField(
controller: _nameController,
onChanged: ref.read(nameAvailabilityNotifierProvider.notifier).onNameChanged,
decoration: InputDecoration(
labelText: l10n.accountNameLabel,
errorText: switch (status) {
AsyncData(:final value) when value == NameCheck.taken => l10n.nameTaken,
AsyncError() => l10n.nameCheckFailed,
_ => null, // idle / loading / available: no error
},
),
);可用性检查(例如,该账户名是否已被占用?)属于 I/O。它在 Notifier 中运行,并针对 进行防抖,字段通过 读取结果。同步 保持纯粹,只负责校验输入形状。完整模式参见 和 。
clockProvidererrorTextvalidatorreferences/validation-sync-and-async.mdexamples/async_field_notifier.dartdart
// The field is driven by Notifier state, not by an async validator.
final status = ref.watch(nameAvailabilityNotifierProvider);
TextFormField(
controller: _nameController,
onChanged: ref.read(nameAvailabilityNotifierProvider.notifier).onNameChanged,
decoration: InputDecoration(
labelText: l10n.accountNameLabel,
errorText: switch (status) {
AsyncData(:final value) when value == NameCheck.taken => l10n.nameTaken,
AsyncError() => l10n.nameCheckFailed,
_ => null, // idle / loading / available: no error
},
),
);Focus and keyboard flow
焦点与键盘流程
TextInputAction.next.doneonFieldSubmittedFocusScope.of(context).nextFocus()FocusTraversalGroupreferences/focus-and-keyboard.mddart
TextFormField(
focusNode: _titleFocus,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _dueDateFocus.requestFocus(),
),TextInputAction.next.doneonFieldSubmittedFocusScope.of(context).nextFocus()FocusTraversalGroupreferences/focus-and-keyboard.mddart
TextFormField(
focusNode: _titleFocus,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) => _dueDateFocus.requestFocus(),
),Submit button derived from validity
根据有效性派生提交按钮状态
Do not store an bool. Derive enablement each build; disable while an async submit is in flight (from Notifier state).
_isFormValiddart
final submitting = ref.watch(taskFormNotifierProvider).isLoading;
FilledButton(
onPressed: submitting ? null : _submit, // rule 5
child: Text(l10n.saveAction),
);不要存储 bool。应在每次 build 时派生按钮启用状态;如果异步提交正在进行(状态来自 Notifier),则禁用按钮。
_isFormValiddart
final submitting = ref.watch(taskFormNotifierProvider).isLoading;
FilledButton(
onPressed: submitting ? null : _submit, // rule 5
child: Text(l10n.saveAction),
);Keyboard avoidance
键盘避让
Wrap long forms so the focused field scrolls above the keyboard: a inside the body lets (with , the default) push content up. For last-field submit, ensure the submit button is reachable — put it in the scroll view or a .
SingleChildScrollViewScaffoldresizeToAvoidBottomInset: truebottomNavigationBar使用滚动视图包裹长表单,让获得焦点的字段能够滚动到键盘上方:在 body 中放入 ,这样 (默认的 )会将内容向上推移。对于最后一个字段的提交场景,应确保提交按钮可以访问——将其放入滚动视图或 中。
SingleChildScrollViewScaffoldresizeToAvoidBottomInset: truebottomNavigationBarAnti-patterns
反模式
- — a validator cannot be async; the
validator: (v) async => await repo.isTaken(v)is truthy so it always "passes." Move to a Notifier (rule 4).Future - Returning /
'Required'from a validator — un-localized; breaks every non-English locale. Use'Invalid email'.AppLocalizations - Creating a /
TextEditingControllerinFocusNode— a fresh one every rebuild, losing text and cursor. Create inbuild(), dispose inState.dispose() - toggled in
bool _isValidto enable submit — drifts from real validity. Derive it.onChanged - — errors shout before the user types a character.
autovalidateMode: AutovalidateMode.always - One listener that calls
TextEditingControlleron the whole form — every keystroke rebuilds every field. Scope the rebuild.setState - timing rolled by hand with
debouncediffs — use aDateTime.now()dart:async(deterministic underTimer); and any timestamp the check records comes fromfakeAsync, neverref.read(clockProvider).now().DateTime.now()
- ——validator 不能是异步的;
validator: (v) async => await repo.isTaken(v)始终为真,因此它总是会“通过”。应将检查移至 Notifier(规则 4)。Future - 从 validator 返回 或
'Required'——这些消息未本地化,会破坏所有非英语 locale。请使用'Invalid email'。AppLocalizations - 在 中创建
build()或TextEditingController——每次重建都会创建新实例,导致文本和光标丢失。应在FocusNode中创建,并在State中释放。dispose() - 使用 并在
bool _isValid中切换,以控制提交按钮——该值会与实际有效性失去同步。应通过派生计算。onChanged - ——会在用户输入第一个字符前就过早展示错误。
autovalidateMode: AutovalidateMode.always - 使用单个 监听器调用
TextEditingController重建整个表单——每次按键都会重建所有字段。应限定重建范围。setState - 手动使用 差值实现
DateTime.now()——应使用debounce的dart:async,以便在Timer下保持确定性;同时,检查记录的任何时间戳都应来自fakeAsync,绝不能使用ref.read(clockProvider).now()。DateTime.now()
Definition of done
完成标准
- Every controller/disposed (or state lives in a Notifier);
FocusNodeclean.check_forms.sh - No string literal returned from any ; all messages via
validator.AppLocalizations - No /
awaitinside a syncFuture; async checks in a debounced Notifier surfaced throughvalidator.errorText - (or an intentional submit-only choice).
AutovalidateMode.onUserInteraction - Submit enablement derived, not stored; disabled during in-flight submit.
- Each field declares ,
keyboardType, andtextInputActionwhere applicable; focus advances correctly.autofillHints - Errors announced via semantics, never color-only.
InputDecoration
- 每个 controller 和 都已释放(或其 state 保存在 Notifier 中);
FocusNode检查通过。check_forms.sh - 任何 都不返回字符串字面量;所有消息均通过
validator获取。AppLocalizations - 同步 中不包含
validator或await;异步检查位于防抖 Notifier 中,并通过Future暴露。errorText - 使用 ,或有意识地选择仅在提交时验证。
AutovalidateMode.onUserInteraction - 提交按钮的启用状态通过派生计算,而不是单独存储;异步提交进行中时禁用。
- 每个字段都声明了适用的 、
keyboardType和textInputAction;焦点能够正确推进。autofillHints - 错误通过 语义播报,而不是仅依赖颜色。
InputDecoration
Related skills
相关 skills
- — the Notifier that owns async validation and submit state.
state-management-riverpod - — cancel debounce timers/subscriptions on dispose; mounted guards after await.
async-safety - — localized validator messages and labels; the non-null
i18n-rtl-l10ngetter.AppLocalizations.of(context) - — field labels, error announcement, never-color-alone, target sizes.
accessibility-as-code - — scoped rebuilds and derive-don't-store.
flutter-performance - — small const field widgets over
widget-compositionmethods._buildField - —
navigation-and-routingfor unsaved-changes confirmation when leaving a dirty form.PopScope - — the Clock seam (
service-boundary-and-native) any async check reads timestamps from.clockProvider
- — 负责异步验证和提交状态的 Notifier。
state-management-riverpod - — 在 dispose 时取消防抖定时器和订阅;在 await 之后使用 mounted guard。
async-safety - — 本地化 validator 消息和标签;使用非空的
i18n-rtl-l10ngetter。AppLocalizations.of(context) - — 字段标签、错误播报、禁止仅依靠颜色表达错误,以及目标尺寸。
accessibility-as-code - — 限定重建范围,并遵循 derive-don't-store 原则。
flutter-performance - — 使用小型 const 字段 widget,而不是
widget-composition方法。_buildField - — 离开脏表单时,使用
navigation-and-routing确认未保存的更改。PopScope - — 任何异步检查读取时间戳时使用的 Clock 抽象边界(
service-boundary-and-native)。clockProvider
References
参考资料
- Form: https://api.flutter.dev/flutter/widgets/Form-class.html
- TextFormField: https://api.flutter.dev/flutter/material/TextFormField-class.html
- FocusNode: https://api.flutter.dev/flutter/widgets/FocusNode-class.html
- TextInputAction: https://api.flutter.dev/flutter/services/TextInputAction.html
- Autofill: https://api.flutter.dev/flutter/services/AutofillHints-class.html
- Forms cookbook: https://docs.flutter.dev/cookbook/forms/validation
- Form:https://api.flutter.dev/flutter/widgets/Form-class.html
- TextFormField:https://api.flutter.dev/flutter/material/TextFormField-class.html
- FocusNode:https://api.flutter.dev/flutter/widgets/FocusNode-class.html
- TextInputAction:https://api.flutter.dev/flutter/services/TextInputAction-class.html
- Autofill:https://api.flutter.dev/flutter/services/AutofillHints-class.html
- Forms cookbook:https://docs.flutter.dev/cookbook/forms/validation