forms-and-input

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Forms 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
FormState
validates them, and a ViewModel owns anything that touches the network or the clock. Keep those three responsibilities separate.
Read the reference for the task at hand:
  • references/validation-sync-and-async.md
    — sync
    validator
    returning localized
    String?
    ,
    AutovalidateMode
    choice, and the debounced-async-in-a-Notifier pattern (why async must NOT live in the sync validator).
  • references/focus-and-keyboard.md
    —
    FocusNode
    lifecycle, traversal order,
    autofocus
    ,
    FocusTraversalGroup
    ,
    TextInputAction
    ,
    onFieldSubmitted
    /
    onEditingComplete
    ,
    keyboardType
    ,
    autofillHints
    ,
    TextInputFormatter
    , keyboard-avoidance.
Run
scripts/check_forms.sh
before a PR.
文本输入是 Flutter app 中大多数资源释放泄漏、未本地化字符串和卡顿的源头。表单是一个小型状态机:字段保存文本,
FormState
负责验证字段,而 ViewModel 负责任何与网络或时钟交互的工作。保持这三种职责相互分离。
根据当前任务阅读参考资料:
  • references/validation-sync-and-async.md
    — 返回本地化
    String?
    的同步
    validator
    、
    AutovalidateMode
    选择,以及在 Notifier 中实现防抖异步的模式(说明为什么异步逻辑绝不能放在同步 validator 中)。
  • references/focus-and-keyboard.md
    —
    FocusNode
    生命周期、遍历顺序、
    autofocus
    、
    FocusTraversalGroup
    、
    TextInputAction
    、
    onFieldSubmitted
    /
    onEditingComplete
    、
    keyboardType
    、
    autofillHints
    、
    TextInputFormatter
    和键盘避让。
提交 PR 前运行
scripts/check_forms.sh
。

Non-negotiable rules

不可妥协的规则

  1. Every
    TextEditingController
    and
    FocusNode
    created in a
    State
    is disposed in
    dispose()
    .
    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 — see
    state-management-riverpod
    .
  2. Validator messages are localized, never hardcoded. A
    validator
    returns
    AppLocalizations.of(context).fieldRequired
    , not
    'Required'
    . Error text is user-facing UI copy and is owned by
    i18n-rtl-l10n
    . The
    check_forms.sh
    grep fails on string literals returned from a validator.
  3. The sync
    validator
    is pure and instant — no
    await
    , no network, no
    Future
    .
    FormFieldValidator<T>
    is
    String? Function(T?)
    ; it cannot be async and Flutter calls it synchronously during layout. Availability/uniqueness checks belong in a Notifier (rule 4).
  4. Async validation lives in a debounced Riverpod Notifier and surfaces through state. Debounce with a
    dart:async
    Timer
    (kept deterministic in tests via
    fakeAsync
    , not by the clock), run the check, and expose
    AsyncValue
    /a sealed status the field reads via
    InputDecoration.errorText
    . Any timestamp the check records comes from
    ref.read(clockProvider).now()
    , never
    DateTime.now()
    — the Clock seam is owned by
    service-boundary-and-native
    . Never block a keystroke on I/O. See
    async-safety
    for cancel-on-dispose.
  5. Submit-enabled is DERIVED from validity, never stored as a separate
    bool
    .
    A stored
    _isValid
    flag drifts out of sync with the fields. Compute it from
    FormState
    /Notifier state at build time. See
    flutter-performance
    (derive-don't-store).
  6. A keystroke rebuilds one field, not the whole form. Give each field its own controller/
    FormField
    ; do not lift raw text into a top-level
    setState
    /
    watch
    that rebuilds every sibling. Scope rebuilds with small widgets and
    ref.watch(....select(...))
    . See
    widget-composition
    and
    flutter-performance
    .
  7. Choose
    AutovalidateMode
    deliberately.
    Default to
    AutovalidateMode.onUserInteraction
    : silent until the user touches a field, then live. Never
    always
    (screams before the user types). Validate-on-submit only for short forms where per-field feedback is noise.
  8. Keyboard type, capitalization, and autofill are declared per field.
    keyboardType
    ,
    textCapitalization
    ,
    autofillHints
    , and
    TextInputFormatter
    s are structural input contracts, not decoration. A missing
    autofillHints
    breaks OS autofill and password managers.
  9. Errors are announced, not just colored.
    InputDecoration.labelText
    /
    errorText
    carry semantics that screen readers read on change; never signal an error with color alone. See
    accessibility-as-code
    .
  1. 在
    State
    中创建的每个
    TextEditingController
    和
    FocusNode
    都必须在
    dispose()
    中释放。
    它们持有原生资源和监听器;泄漏会跨越 widget 的生命周期,并继续针对已经销毁的组件树触发回调。如果某个值必须比 widget 存活更久,应将其保存在 Notifier 中,参见
    state-management-riverpod
    。
  2. Validator 消息必须本地化,绝不能硬编码。
    validator
    应返回
    AppLocalizations.of(context).fieldRequired
    ,而不是
    'Required'
    。错误文本是面向用户的 UI 文案,归
    i18n-rtl-l10n
    管理。如果 validator 返回字符串字面量,
    check_forms.sh
    的 grep 检查就会失败。
  3. 同步
    validator
    必须纯粹且即时完成——不能包含
    await
    、网络请求或
    Future
    。
    FormFieldValidator<T>
    是
    String? Function(T?)
    ;它不能是异步的,而且 Flutter 会在布局期间同步调用它。可用性或唯一性检查应放在 Notifier 中(规则 4)。
  4. 异步验证应位于防抖的 Riverpod Notifier 中,并通过 state 暴露结果。 使用
    dart:async
    的
    Timer
    进行防抖(测试中通过
    fakeAsync
    保持确定性,而不是依赖时钟),执行检查,并公开
    AsyncValue
    或 sealed 状态,让字段通过
    InputDecoration.errorText
    读取。检查记录的任何时间戳都应来自
    ref.read(clockProvider).now()
    ,绝不能使用
    DateTime.now()
    ——Clock 抽象边界由
    service-boundary-and-native
    管理。绝不能让一次按键被 I/O 阻塞。取消处理参见
    async-safety
    中的 dispose 规则。
  5. 提交按钮的启用状态应由有效性派生,绝不能作为独立的
    bool
    存储。
    单独存储的
    _isValid
    标记会与字段实际状态失去同步。应在 build 时根据
    FormState
    或 Notifier state 计算。参见
    flutter-performance
    的 derive-don't-store 原则。
  6. 一次按键只能重建一个字段,而不是整个表单。 为每个字段提供独立的 controller 和
    FormField
    ;不要在顶层
    setState
    或
    watch
    中保存原始文本并导致所有同级字段重建。使用小型 widget 和
    ref.watch(....select(...))
    限定重建范围。参见
    widget-composition
    和
    flutter-performance
    。
  7. 有意选择
    AutovalidateMode
    。
    默认使用
    AutovalidateMode.onUserInteraction
    :用户接触字段前保持静默,之后实时验证。不要使用
    always
    (会在用户开始输入前就过早展示错误)。仅当表单很短、逐字段反馈会造成干扰时,才使用提交时验证。
  8. 按字段声明键盘类型、大小写策略和自动填充。
    keyboardType
    、
    textCapitalization
    、
    autofillHints
    和
    TextInputFormatter
    属于结构性的输入约定,而不是装饰配置。缺少
    autofillHints
    会破坏操作系统的自动填充和密码管理器功能。
  9. 错误必须通过语义播报,而不只是用颜色表示。
    InputDecoration.labelText
    和
    errorText
    包含屏幕阅读器在变化时读取的语义信息;绝不能仅依靠颜色来表达错误。参见
    accessibility-as-code
    。

Form skeleton

表单骨架

Form
+ a
GlobalKey<FormState>
is the coordination point. The key lets the submit handler call
validate()
/
save()
across all fields at once.
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(),
          ),
        ],
      ),
    );
  }
}
Form
+
GlobalKey<FormState>
是协调中心。借助该 key,提交处理器可以一次性对所有字段调用
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
validator
is a total, synchronous function of the field value. Return
null
for valid, a localized message otherwise. Compose small checks; keep the closure short.
dart
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;
}
validator
是字段值的完整同步函数。验证通过时返回
null
,否则返回本地化消息。可以组合多个小型检查项,但应保持闭包简短。
dart
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
clockProvider
, and the field reads the result through
errorText
. The sync
validator
stays pure and only guards the shape of the input. Full pattern in
references/validation-sync-and-async.md
and
examples/async_field_notifier.dart
.
dart
// 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 中运行,并针对
clockProvider
进行防抖,字段通过
errorText
读取结果。同步
validator
保持纯粹,只负责校验输入形状。完整模式参见
references/validation-sync-and-async.md
和
examples/async_field_notifier.dart
。
dart
// 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
moves to the next field;
.done
submits. Advance focus in
onFieldSubmitted
with
FocusScope.of(context).nextFocus()
or by requesting a specific node. Group related fields with
FocusTraversalGroup
to control tab order. Details in
references/focus-and-keyboard.md
.
dart
TextFormField(
  focusNode: _titleFocus,
  textInputAction: TextInputAction.next,
  onFieldSubmitted: (_) => _dueDateFocus.requestFocus(),
),
TextInputAction.next
会移动到下一个字段,
.done
会提交。可以在
onFieldSubmitted
中调用
FocusScope.of(context).nextFocus()
,或请求某个特定 node 来推进焦点。使用
FocusTraversalGroup
对相关字段进行分组,以控制 Tab 顺序。详情参见
references/focus-and-keyboard.md
。
dart
TextFormField(
  focusNode: _titleFocus,
  textInputAction: TextInputAction.next,
  onFieldSubmitted: (_) => _dueDateFocus.requestFocus(),
),

Submit button derived from validity

根据有效性派生提交按钮状态

Do not store an
_isFormValid
bool. Derive enablement each build; disable while an async submit is in flight (from Notifier state).
dart
final submitting = ref.watch(taskFormNotifierProvider).isLoading;
FilledButton(
  onPressed: submitting ? null : _submit, // rule 5
  child: Text(l10n.saveAction),
);
不要存储
_isFormValid
bool。应在每次 build 时派生按钮启用状态;如果异步提交正在进行(状态来自 Notifier),则禁用按钮。
dart
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
SingleChildScrollView
inside the body lets
Scaffold
(with
resizeToAvoidBottomInset: true
, the default) push content up. For last-field submit, ensure the submit button is reachable — put it in the scroll view or a
bottomNavigationBar
.
使用滚动视图包裹长表单,让获得焦点的字段能够滚动到键盘上方:在 body 中放入
SingleChildScrollView
,这样
Scaffold
(默认的
resizeToAvoidBottomInset: true
)会将内容向上推移。对于最后一个字段的提交场景,应确保提交按钮可以访问——将其放入滚动视图或
bottomNavigationBar
中。

Anti-patterns

反模式

  • validator: (v) async => await repo.isTaken(v)
    — a validator cannot be async; the
    Future
    is truthy so it always "passes." Move to a Notifier (rule 4).
  • Returning
    'Required'
    /
    'Invalid email'
    from a validator — un-localized; breaks every non-English locale. Use
    AppLocalizations
    .
  • Creating a
    TextEditingController
    /
    FocusNode
    in
    build()
    — a fresh one every rebuild, losing text and cursor. Create in
    State
    , dispose in
    dispose()
    .
  • bool _isValid
    toggled in
    onChanged
    to enable submit — drifts from real validity. Derive it.
  • autovalidateMode: AutovalidateMode.always
    — errors shout before the user types a character.
  • One
    TextEditingController
    listener that calls
    setState
    on the whole form — every keystroke rebuilds every field. Scope the rebuild.
  • debounce
    timing rolled by hand with
    DateTime.now()
    diffs — use a
    dart:async
    Timer
    (deterministic under
    fakeAsync
    ); and any timestamp the check records comes from
    ref.read(clockProvider).now()
    , never
    DateTime.now()
    .
  • validator: (v) async => await repo.isTaken(v)
    ——validator 不能是异步的;
    Future
    始终为真,因此它总是会“通过”。应将检查移至 Notifier(规则 4)。
  • 从 validator 返回
    'Required'
    或
    'Invalid email'
    ——这些消息未本地化,会破坏所有非英语 locale。请使用
    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/
    FocusNode
    disposed (or state lives in a Notifier);
    check_forms.sh
    clean.
  • No string literal returned from any
    validator
    ; all messages via
    AppLocalizations
    .
  • No
    await
    /
    Future
    inside a sync
    validator
    ; async checks in a debounced Notifier surfaced through
    errorText
    .
  • AutovalidateMode.onUserInteraction
    (or an intentional submit-only choice).
  • Submit enablement derived, not stored; disabled during in-flight submit.
  • Each field declares
    keyboardType
    ,
    textInputAction
    , and
    autofillHints
    where applicable; focus advances correctly.
  • Errors announced via
    InputDecoration
    semantics, never color-only.
  • 每个 controller 和
    FocusNode
    都已释放(或其 state 保存在 Notifier 中);
    check_forms.sh
    检查通过。
  • 任何
    validator
    都不返回字符串字面量;所有消息均通过
    AppLocalizations
    获取。
  • 同步
    validator
    中不包含
    await
    或
    Future
    ;异步检查位于防抖 Notifier 中,并通过
    errorText
    暴露。
  • 使用
    AutovalidateMode.onUserInteraction
    ,或有意识地选择仅在提交时验证。
  • 提交按钮的启用状态通过派生计算,而不是单独存储;异步提交进行中时禁用。
  • 每个字段都声明了适用的
    keyboardType
    、
    textInputAction
    和
    autofillHints
    ;焦点能够正确推进。
  • 错误通过
    InputDecoration
    语义播报,而不是仅依赖颜色。

Related skills

相关 skills

  • state-management-riverpod
    — the Notifier that owns async validation and submit state.
  • async-safety
    — cancel debounce timers/subscriptions on dispose; mounted guards after await.
  • i18n-rtl-l10n
    — localized validator messages and labels; the non-null
    AppLocalizations.of(context)
    getter.
  • accessibility-as-code
    — field labels, error announcement, never-color-alone, target sizes.
  • flutter-performance
    — scoped rebuilds and derive-don't-store.
  • widget-composition
    — small const field widgets over
    _buildField
    methods.
  • navigation-and-routing
    —
    PopScope
    for unsaved-changes confirmation when leaving a dirty form.
  • service-boundary-and-native
    — the Clock seam (
    clockProvider
    ) any async check reads timestamps from.
  • state-management-riverpod
    — 负责异步验证和提交状态的 Notifier。
  • async-safety
    — 在 dispose 时取消防抖定时器和订阅;在 await 之后使用 mounted guard。
  • i18n-rtl-l10n
    — 本地化 validator 消息和标签;使用非空的
    AppLocalizations.of(context)
    getter。
  • accessibility-as-code
    — 字段标签、错误播报、禁止仅依靠颜色表达错误,以及目标尺寸。
  • flutter-performance
    — 限定重建范围,并遵循 derive-don't-store 原则。
  • widget-composition
    — 使用小型 const 字段 widget,而不是
    _buildField
    方法。
  • navigation-and-routing
    — 离开脏表单时,使用
    PopScope
    确认未保存的更改。
  • service-boundary-and-native
    — 任何异步检查读取时间戳时使用的 Clock 抽象边界(
    clockProvider
    )。

References

参考资料