表单、输入校验与焦点管理

表单是业务 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()) 实现点空白收起键盘;给 FocusNodeaddListener 监听聚焦失焦;提交后用 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失焦或提交时提示
后端字段错误或服务端异常字段 errorTextSnackBar提交后定位到第一个错误字段
// 把后端返回的字段级错误映射到对应输入框
final serverErrors = <String, String>{'phone': '该手机号已注册'};
TextFormField(decoration: InputDecoration(errorText: serverErrors['phone'])) // 后端错误优先展示

一个务实建议:前端只校验"格式与必填",业务规则(手机号是否被占用、额度是否足够)交给后端。两边都写全套规则,改动时必然不一致。

flutter analyze
flutter test      # tester.enterText + tester.tap 可验证各条校验分支

小结:表单用 Form + GlobalKey<FormState> 统一校验、TextFormFieldvalidator;键盘按字段类型选 keyboardType、按位置选 textInputAction;焦点用 FocusNodeFocusScope.of(context).nextFocus() 管理,onChanged 记得防抖;提交先 validate() 再防重复,后端错误按字段映射回 errorText

笔记加载中…