mcpField
F
forms-and-input

forms-and-input

github.com/zakariaf开发63154 次安装5 周前更新
代码仓库

关于 forms-and-input

面向 Flutter 表单与文本输入的开发技能,将字段、校验和 ViewModel 职责分开。涵盖同步及防抖异步校验、焦点流转、键盘行为和局部重建。

主要功能

  1. 01管理 Controller 与 FocusNode 生命周期
  2. 02本地化同步校验
  3. 03在 Notifier 中防抖异步校验
  4. 04根据有效性推导提交状态
  5. 05为字段配置键盘与自动填充
  6. 06局部重建并提供错误语义

适用场景

  1. 01构建带本地化校验消息的表单
  2. 02检查远程可用性且不阻塞输入
  3. 03管理焦点移动和键盘导航
  4. 04减少输入时的界面重建

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/zakariaf/flutter-skills --skill forms-and-input

安装后在 Claude Code 中输入/forms-and-input确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

examples/
async_field_notifier.dart
task_form.dart
references/
focus-and-keyboard.md
validation-sync-and-async.md
scripts/
check_forms.sh

详细介绍

表单与输入

Flutter 应用中的大多数资源泄漏、未本地化字符串和卡顿都始于文本输入。表单是一个小型状态机:字段保存文本,FormState 验证字段,而 ViewModel 负责任何涉及网络或时钟的操作。请将这三项职责分开。

根据当前任务阅读参考资料:

  • references/validation-sync-and-async.md — 同步 validator 返回本地化的 String?、AutovalidateMode 的选择,以及在 Notifier 中实现防抖异步验证的模式(为什么异步逻辑绝不能放在同步验证器中)。
  • references/focus-and-keyboard.md — FocusNode 生命周期、焦点遍历顺序、autofocus、FocusTraversalGroup、TextInputAction、onFieldSubmitted/onEditingComplete、keyboardType、autofillHints、TextInputFormatter、键盘避让。

提交 PR 前运行 scripts/check_forms.sh。

不容妥协的规则

  1. 在 State 中创建的每个 TextEditingController 和 FocusNode 都必须在 dispose() 中释放。 它们持有原生资源和监听器;即使 Widget 已销毁,泄漏仍会存在,并对已销毁的 Widget 树触发回调。如果值必须比 Widget 存活得更久,请改为将其保存在 Notifier 中——参见 state-management-riverpod。
  2. 验证器消息必须本地化,绝不能硬编码。 validator 应返回 AppLocalizations.of(context).fieldRequired,而不是 'Required'。错误文本是面向用户的 UI 文案,由 i18n-rtl-l10n 负责管理。check_forms.sh 会通过 grep 检查验证器返回的字符串字面量,并在发现时失败。
  3. 同步 validator 必须是纯函数且立即返回——不能使用 await、网络或 Future。 FormFieldValidator<T> 的类型是 String? Function(T?);它不能是异步函数,而且 Flutter 会在布局期间同步调用它。可用性/唯一性检查应放在 Notifier 中(规则 4)。
  4. 异步验证应放在带防抖的 Riverpod Notifier 中,并通过状态传递结果。 使用 dart:async 的 Timer 实现防抖(在测试中使用 fakeAsync 保证确定性,而非依赖时钟),运行检查,并提供 AsyncValue/封装状态,供字段通过 InputDecoration.errorText 读取。检查记录的任何时间戳都必须来自 ref.read(clockProvider).now(),绝不能使用 DateTime.now()——Clock 接缝由 service-boundary-and-native 负责。切勿让按键输入等待 I/O。关于在销毁时取消操作,请参见 async-safety。
  5. 提交按钮是否启用必须根据有效性推导,绝不能单独存储为 bool。 存储的 _isValid 标记会与字段状态脱节。应在构建时根据 FormState/Notifier 状态计算。参见 flutter-performance(推导而非存储)。
  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 + GlobalKey<FormState> 是协调点。该 key 允许提交处理器一次性对所有字段调用 validate()/save()。

class TaskForm extends StatefulWidget {
  const TaskForm({super.key, required this.onSubmit});
  final void Function(String title) onSubmit;

  @override
  State<TaskForm> createState() => _TaskFormState();
}

class _TaskFormState extends State<TaskForm> {
  final _formKey = GlobalKey<FormState>();
  final _titleController = TextEditingController();
  final _titleFocus = FocusNode();

  @override
  void dispose() {
    _titleController.dispose(); // rule 1: always dispose
    _titleFocus.dispose();
    super.dispose();
  }

  void _submit() {
    if (_formKey.currentState?.validate() ?? false) {
      widget.onSubmit(_titleController.text.trim());
    }
  }

  @override
  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(),
          ),
        ],
      ),
    );
  }
}

同步验证

validator 是一个以字段值为输入的全定义同步函数。值有效时返回 null,否则返回本地化消息。组合简短的检查,并让闭包保持精简。

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

异步校验(移出校验器)

可用性检查(此账户名是否已被占用?)属于 I/O 操作。它在 Notifier 中运行,并根据 clockProvider 进行防抖;字段通过 errorText 读取结果。同步 validator 保持纯函数,只校验输入的格式。完整示例见 references/validation-sync-and-async.md 和 examples/async_field_notifier.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
    },
  ),
);

焦点与键盘流转

TextInputAction.next 会移至下一个字段;.done 会提交表单。在 onFieldSubmitted 中使用 FocusScope.of(context).nextFocus() 或请求特定节点来移动焦点。使用 FocusTraversalGroup 将相关字段分组,以控制 Tab 键顺序。详情见 references/focus-and-keyboard.md。

TextFormField(
  focusNode: _titleFocus,
  textInputAction: TextInputAction.next,
  onFieldSubmitted: (_) => _dueDateFocus.requestFocus(),
),

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

不要存储 _isFormValid 布尔值。每次构建时派生按钮是否启用;异步提交进行中时(根据 Notifier 状态)禁用按钮。

final submitting = ref.watch(taskFormNotifierProvider).isLoading;
FilledButton(
  onPressed: submitting ? null : _submit, // rule 5
  child: Text(l10n.saveAction),
);

避让键盘

为较长的表单添加包装,以便聚焦字段滚动到键盘上方:在主体中使用 SingleChildScrollView,让 Scaffold(默认 resizeToAvoidBottomInset: true)将内容向上推。对于最后一个字段提交的场景,确保提交按钮可见——将它放入滚动视图或 bottomNavigationBar 中。

反面模式

  • validator: (v) async => await repo.isTaken(v) — 校验器不能是异步的;Future 为真值,因此它总是“通过”。应移至 Notifier(规则 4)。
  • 从校验器返回 'Required' / 'Invalid email' — 未本地化,会导致所有非英语区域设置都出问题。请使用 AppLocalizations。
  • 在 build() 中创建 TextEditingController/FocusNode — 每次重建都会创建新的实例,导致文本和光标丢失。应在 State 中创建,并在 dispose() 中释放。
  • 在 onChanged 中切换 bool _isValid 来启用提交按钮 — 状态会与实际有效性不一致。应派生该值。
  • autovalidateMode: AutovalidateMode.always — 用户还没输入字符,错误就会显示。
  • 使用一个 TextEditingController 监听器调用整个表单的 setState — 每次按键都会重建所有字段。应限定重建范围。
  • 用 DateTime.now() 差值手动计算 debounce 时间 — 请使用 dart:async 的 Timer(在 fakeAsync 下具有确定性);检查所记录的任何时间戳都必须来自 ref.read(clockProvider).now(),不能使用 DateTime.now()。

完成标准

  • 每个控制器/FocusNode 都已释放(或状态存放在 Notifier 中);check_forms.sh 检查通过。
  • 任何 validator 都不返回字符串字面量;所有消息均通过 AppLocalizations 提供。
  • 同步 validator 中没有 await/Future;异步检查在经过防抖的 Notifier 中执行,并通过 errorText 呈现。
  • 使用 AutovalidateMode.onUserInteraction(或有意选择仅在提交时校验)。
  • 提交按钮是否启用由派生值决定,而非存储;提交进行中时禁用。
  • 每个字段都按需声明 keyboardType、textInputAction 和 autofillHints;焦点移动正确。
  • 错误通过 InputDecoration 语义播报,绝不能只依赖颜色。

相关技能

  • state-management-riverpod — 负责异步校验和提交状态的 Notifier。
  • async-safety — 在释放时取消防抖计时器/订阅;在 await 之后检查 mounted 状态。
  • i18n-rtl-l10n — 本地化校验器消息和标签;使用非空的 AppLocalizations.of(context) getter。
  • accessibility-as-code — 字段标签、错误播报、不能只依赖颜色、目标尺寸。
  • flutter-performance — 限定重建范围,并遵循“派生而非存储”原则。
  • widget-composition — 使用小型 const 字段组件,而不是 _buildField 方法。
  • navigation-and-routing — 离开有未保存更改的表单时,使用 PopScope 确认。
  • service-boundary-and-native — Clock 接口(clockProvider);任何异步检查都应从中读取时间戳。

References

评论

支持 Markdown