表单、输入校验与焦点管理
表单是业务 App 的标配,也是细节最密集的地方:键盘类型对不对、回车能不能跳下一个、密码能不能看一眼、提交时错误提示在哪、后端返回的字段错误怎么显示到对应输入框。Flutter 的 Form + TextFormField 已经覆盖大部分需求,本章把校验、键盘、焦点、提交反馈串起来讲。
Form + GlobalKey + TextFormField
Form 负责收集与触发校验,GlobalKey<FormState> 是外部调用 validate()/reset() 的把手,TextFormField 是带校验能力的输入框。
class _LoginFormState extends State<LoginForm> {
final _formKey = GlobalKey<FormState>(); // 统一校验入口
final _phoneController = TextEditingController();
final _pwdFocus = FocusNode();
bool _obscure = true;
bool _submitting = false;
@override
void dispose() {
_phoneController.dispose();
_pwdFocus.dispose(); // Controller 与 FocusNode 都必须释放
super.dispose();
}
Future<void> _submit() async {
if (!(_formKey.currentState?.validate() ?? false)) return; // 所有 validator 都会跑
if (_submitting) return; // 防重复提交
setState(() => _submitting = true);
await Future<void>.delayed(const Duration(seconds: 1)); // 换成真实登录请求
if (!mounted) return; // 页面已销毁就不再提示
setState(() => _submitting = false);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('登录成功')));
}
@override
Widget build(BuildContext context) => Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction, // 交互后才自动校验
child: ListView(
padding: const EdgeInsets.all(16),
children: [
TextFormField(
controller: _phoneController,
keyboardType: TextInputType.phone, // 拉起数字键盘
textInputAction: TextInputAction.next, // 回车键显示"下一个"
decoration: const InputDecoration(labelText: '手机号', border: OutlineInputBorder()),
validator: (value) {
final v = value?.trim() ?? '';
if (v.isEmpty) return '请输入手机号';
return RegExp(r'^1[3-9]\d{9}$').hasMatch(v) ? null : '手机号格式不正确';
},
onFieldSubmitted: (_) => _pwdFocus.requestFocus(), // 回车跳到密码框
),
const SizedBox(height: 16),
TextFormField(
focusNode: _pwdFocus,
obscureText: _obscure,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: '密码',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility),
onPressed: () => setState(() => _obscure = !_obscure), // 密码可见切换
),
),
validator: (v) => (v == null || v.length < 6) ? '密码至少 6 位' : null,
onFieldSubmitted: (_) => _submit(),
),
const SizedBox(height: 24),
FilledButton(
onPressed: _submitting ? null : _submit, // null 即禁用,防连点
child: Text(_submitting ? '提交中…' : '登录'),
),
],
),
);
}
输入类型与键盘
keyboardType | 键盘形态 | 适用 |
|---|---|---|
text | 全键盘 | 昵称、备注 |
number | 数字键盘 | 数量、验证码 |
phone | 电话键盘 | 手机号 |
textInputAction | 回车键文案 | 适用 |
|---|---|---|
next | 下一个 | 中间字段 |
done | 完成 | 最后一个字段 |
字段类型决定 keyboardType,字段位置决定 textInputAction:
TextField(
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
onSubmitted: (_) => FocusScope.of(context).nextFocus(), // 无需自己维护 FocusNode
)
焦点管理与防抖
常用的四件事:autofocus: true 自动聚焦首个输入框;外层包 GestureDetector(onTap: () => FocusScope.of(context).unfocus()) 实现点空白收起键盘;给 FocusNode 加 addListener 监听聚焦失焦;提交后用 FocusManager.instance.primaryFocus?.unfocus() 收起键盘。
import 'dart:async'; // Timer 来自 dart:async
Timer? _debounce; // 搜索框:输入停止 400ms 后才发请求(防抖)
void _onChanged(String value) {
_debounce?.cancel(); // 取消上一次未触发的定时器
_debounce = Timer(const Duration(milliseconds: 400), () => _search(value));
}
// dispose 里别忘了 _debounce?.cancel();
onChanged 用于每次内容变化(实时计数、防抖搜索),onFieldSubmitted 用于回车(聚焦下一项、提交),onSaved 配合 _formKey.currentState.save() 收集值。
提交校验与后端错误提示
| 错误来源 | 展示位置 | 交互要求 |
|---|---|---|
| 本地格式校验与必填 | 输入框下方 errorText | 失焦或提交时提示 |
| 后端字段错误或服务端异常 | 字段 errorText 或 SnackBar | 提交后定位到第一个错误字段 |
// 把后端返回的字段级错误映射到对应输入框
final serverErrors = <String, String>{'phone': '该手机号已注册'};
TextFormField(decoration: InputDecoration(errorText: serverErrors['phone'])) // 后端错误优先展示
一个务实建议:前端只校验"格式与必填",业务规则(手机号是否被占用、额度是否足够)交给后端。两边都写全套规则,改动时必然不一致。
flutter analyze
flutter test # tester.enterText + tester.tap 可验证各条校验分支
小结:表单用 Form + GlobalKey<FormState> 统一校验、TextFormField 写 validator;键盘按字段类型选 keyboardType、按位置选 textInputAction;焦点用 FocusNode 或 FocusScope.of(context).nextFocus() 管理,onChanged 记得防抖;提交先 validate() 再防重复,后端错误按字段映射回 errorText。