关于 forms-and-input
面向 Flutter 表单与文本输入的开发技能,将字段、校验和 ViewModel 职责分开。涵盖同步及防抖异步校验、焦点流转、键盘行为和局部重建。
主要功能
- 01管理 Controller 与 FocusNode 生命周期
- 02本地化同步校验
- 03在 Notifier 中防抖异步校验
- 04根据有效性推导提交状态
- 05为字段配置键盘与自动填充
- 06局部重建并提供错误语义
适用场景
- 01构建带本地化校验消息的表单
- 02检查远程可用性且不阻塞输入
- 03管理焦点移动和键盘导航
- 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.darttask_form.dartreferences/focus-and-keyboard.mdvalidation-sync-and-async.mdscripts/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。
不容妥协的规则
- 在
State中创建的每个TextEditingController和FocusNode都必须在dispose()中释放。 它们持有原生资源和监听器;即使 Widget 已销毁,泄漏仍会存在,并对已销毁的 Widget 树触发回调。如果值必须比 Widget 存活得更久,请改为将其保存在 Notifier 中——参见state-management-riverpod。 - 验证器消息必须本地化,绝不能硬编码。
validator应返回AppLocalizations.of(context).fieldRequired,而不是'Required'。错误文本是面向用户的 UI 文案,由i18n-rtl-l10n负责管理。check_forms.sh会通过 grep 检查验证器返回的字符串字面量,并在发现时失败。 - 同步
validator必须是纯函数且立即返回——不能使用await、网络或Future。FormFieldValidator<T>的类型是String? Function(T?);它不能是异步函数,而且 Flutter 会在布局期间同步调用它。可用性/唯一性检查应放在 Notifier 中(规则 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。 - 提交按钮是否启用必须根据有效性推导,绝不能单独存储为
bool。 存储的_isValid标记会与字段状态脱节。应在构建时根据FormState/Notifier 状态计算。参见flutter-performance(推导而非存储)。 - 一次按键输入只应触发一个字段重建,而非整个表单。 为每个字段单独设置 controller/
FormField;不要将原始文本提升到顶层setState/watch中,导致所有相邻字段都重建。使用小型 Widget 和ref.watch(....select(...))限定重建范围。参见widget-composition和flutter-performance。 - 谨慎选择
AutovalidateMode。 默认使用AutovalidateMode.onUserInteraction:用户触碰字段前保持静默,之后实时验证。绝不要使用always(用户输入前就开始报错)。只有在较短表单中,逐字段反馈会造成干扰时,才仅在提交时验证。 - 为每个字段声明键盘类型、大小写和自动填充。
keyboardType、textCapitalization、autofillHints和TextInputFormatter是输入结构约定,而非装饰属性。缺少autofillHints会导致系统自动填充和密码管理器失效。 - 错误必须通过语义信息播报,不能只改变颜色。
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
- 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