Flutter 表单与输入验证:Form、Validator 与自定义控件

Flutter 表单体系深度实践:Form 与 TextFormField 的字段管理与验证机制、Validator 与自动验证触发时机、输入格式化(FilteringTextInputFormatter 与掩码)、焦点与键盘处理、复杂表单状态管理,以及登录/注册等实用表单完整案例。

开篇:表单是用户体验的第一道防线

在任何移动应用中,表单都是用户与业务逻辑交互最频繁的载体——登录、注册、下单、填写资料。一个优秀的表单不仅要有清晰的布局,更要具备严谨的输入验证、流畅的焦点流转和明确的错误反馈。Flutter 作为声明式 UI 框架,提供了一套完整的表单体系:Form 负责整体状态,TextFormField 负责单个字段,Validator 负责校验逻辑,配合输入格式化、焦点管理和键盘事件,可以构建出生产级的表单体验。

本章将从 Form 的架构原理出发,系统讲解 Flutter 表单与输入验证的完整知识体系,并给出可直接复用的实战案例。


一、表单体系:Form 与 FormField 架构

1.1 Form 的职责

Form 是一个 InheritedWidget,它通过 FormState 统一管理其下所有 FormField(包括 TextFormField)的状态。核心 API 包括:

API作用典型场景
FormState.validate()校验所有字段,返回是否全部通过点击"提交"按钮时
FormState.save()触发所有字段的 onSaved 回调校验通过后收集数据
FormState.reset()重置所有字段为初始值清空表单
FormState.autovalidateMode自动验证触发时机实时反馈输入是否合法
class _LoginFormState extends State<LoginForm> {
  final _formKey = GlobalKey<FormState>();

  void _submit() {
    // validate() 返回 true 表示所有字段通过校验
    if (_formKey.currentState!.validate()) {
      _formKey.currentState!.save(); // 触发 onSaved 收集数据
      // 提交业务逻辑...
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(decoration: const InputDecoration(labelText: '用户名')),
          TextFormField(
            decoration: const InputDecoration(labelText: '密码'),
            obscureText: true,
          ),
          ElevatedButton(
            onPressed: _submit,
            child: const Text('登录'),
          ),
        ],
      ),
    );
  }
}

1.2 FormField 的状态生命周期

每个 FormField 都持有自己的 FormFieldState,生命周期包含四个关键事件:

FormFieldState 生命周期:
初始值 → didChange(用户输入) → 校验/保存/重置 → 销毁(dispose)

一句话:Form 是"表单的 Redux"——它集中了 validate/save/reset 三个全局操作,而字段细节由各自的 FormFieldState 自治。


二、TextFormField 与文本输入

2.1 常用属性一览

TextFormField 是 FormField<String> 与 TextField 的组合,继承了两者的能力。以下是生产级表单中最常用的属性:

属性类型作用
decorationInputDecoration标签、占位符、图标、错误样式
validatorFormFieldValidator<String>校验函数,返回 null 表示通过
keyboardTypeTextInputType键盘类型(数字、邮箱、电话等)
textInputActionTextInputAction键盘右下角按钮(next/done)
autofocusbool页面加载后是否自动聚焦
enabledbool是否可编辑
maxLength / maxLinesint输入长度 / 行数限制
TextFormField(
  decoration: const InputDecoration(
    labelText: '手机号',
    hintText: '请输入 11 位手机号',
    prefixIcon: Icon(Icons.phone_android),
  ),
  keyboardType: TextInputType.phone,
  textInputAction: TextInputAction.next,
  maxLength: 11,
  validator: (value) {
    if (value == null || value.isEmpty) return '手机号不能为空';
    if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(value)) return '手机号格式不正确';
    return null;
  },
)

2.2 控制器与数据同步

当需要在表单之外读取或修改文本值时(如"清空"按钮、自动填充),需要使用 TextEditingController:

class _ProfileFormState extends State<ProfileForm> {
  final _nameController = TextEditingController();
  final _emailController = TextEditingController();

  @override
  void dispose() {
    _nameController.dispose();
    _emailController.dispose();
    super.dispose();
  }

  void _autoFillDemo() {
    _nameController.text = '张三';
    _emailController.text = 'zhangsan@example.com';
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextFormField(
          controller: _nameController,
          decoration: const InputDecoration(labelText: '姓名'),
        ),
        TextFormField(
          controller: _emailController,
          decoration: const InputDecoration(labelText: '邮箱'),
          keyboardType: TextInputType.emailAddress,
        ),
        TextButton(onPressed: _autoFillDemo, child: const Text('一键填充')),
      ],
    );
  }
}

一句话:能用 onChanged 就不要建 Controller,但涉及外部读写、文本监听时必须用 TextEditingController,且记得在 dispose 中释放。


三、Validator 校验与自动验证

3.1 Validator 的返回约定

Flutter 的 validator 采用"返回 null 表示通过,返回字符串表示错误信息"的约定。多字段联动校验可以这样组织:

String? _validatePassword(String? value) {
  if (value == null || value.isEmpty) return '密码不能为空';
  if (value.length < 8) return '密码至少 8 位';
  if (!RegExp(r'[A-Z]').hasMatch(value)) return '密码需包含大写字母';
  if (!RegExp(r'[0-9]').hasMatch(value)) return '密码需包含数字';
  return null;
}

String? _validateConfirmPassword(String? value) {
  if (value == null || value.isEmpty) return '请再次输入密码';
  if (value != _passwordController.text) return '两次输入的密码不一致';
  return null;
}

3.2 AutovalidateMode 触发时机

自动验证有三种模式,直接决定错误提示何时出现:

模式行为适用场景
AutovalidateMode.disabled仅显式调用 validate() 时校验默认,提交时才反馈
AutovalidateMode.always每次输入变化都校验需要实时反馈的严格表单
AutovalidateMode.onUserInteraction首次交互(失焦/提交)后实时校验兼顾体验与性能的推荐方案
Form(
  key: _formKey,
  autovalidateMode: AutovalidateMode.onUserInteraction, // 用户交互后实时校验
  child: Column(
    children: [
      TextFormField(validator: _validateEmail),
      TextFormField(validator: _validatePassword),
    ],
  ),
)

3.3 异步校验(如检查用户名是否已存在)

同步 Validator 无法处理网络请求。此时可以配合 FutureBuilder 或单独的异步校验状态:

class _AsyncValidationState extends State<AsyncValidationPage> {
  String? _usernameError;
  bool _checking = false;

  Future<void> _checkUsername(String username) async {
    if (username.isEmpty) {
      setState(() => _usernameError = null);
      return;
    }
    setState(() {
      _checking = true;
      _usernameError = null;
    });
    // 模拟网络延迟
    await Future.delayed(const Duration(milliseconds: 500));
    final exists = username == 'admin';
    if (!mounted) return;
    setState(() {
      _checking = false;
      _usernameError = exists ? '该用户名已被占用' : null;
    });
  }

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      decoration: InputDecoration(
        labelText: '用户名',
        errorText: _usernameError, // 手动注入错误信息
        suffixIcon: _checking
            ? const SizedBox(
                width: 20,
                height: 20,
                child: CircularProgressIndicator(strokeWidth: 2),
              )
            : null,
      ),
      onChanged: _checkUsername,
    );
  }
}

一句话:同步校验交给 Validator,异步校验用独立状态 + errorText 手动注入,不要把异步逻辑塞进同步 Validator。


四、输入格式化:FilteringTextInputFormatter 与掩码

4.1 输入格式化器

TextInputFormatter 可以在用户输入时对文本进行过滤和格式化。FilteringTextInputFormatter 是最常用的内置实现:

import 'package:flutter/services.dart';

TextFormField(
  decoration: const InputDecoration(labelText: '金额'),
  keyboardType: TextInputType.number,
  inputFormatters: [
    FilteringTextInputFormatter.allow(RegExp(r'^\d{0,6}(\.\d{0,2})?')), // 金额限制
  ],
)

TextFormField(
  decoration: const InputDecoration(labelText: '仅字母'),
  inputFormatters: [
    FilteringTextInputFormatter.allow(RegExp(r'[a-zA-Z]')), // 白名单
  ],
)

TextFormField(
  decoration: const InputDecoration(labelText: '去除空白'),
  inputFormatters: [
    FilteringTextInputFormatter.deny(RegExp(r'\s')), // 黑名单
  ],
)

4.2 自定义掩码 Formatter(手机号/银行卡)

内置 Formatter 只做过滤,要实现"输入过程中自动补全分隔符"需要自定义 TextInputFormatter:

class PhoneNumberFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    // 只保留数字
    final digits = newValue.text.replaceAll(RegExp(r'\D'), '');
    final buffer = StringBuffer();
    for (var i = 0; i < digits.length; i++) {
      if (i == 3 || i == 7) buffer.write(' '); // 3-4-4 分段
      buffer.write(digits[i]);
    }
    final formatted = buffer.toString();
    return newValue.copyWith(
      text: formatted,
      selection: TextSelection.collapsed(offset: formatted.length),
    );
  }
}

TextFormField(
  decoration: const InputDecoration(labelText: '手机号'),
  keyboardType: TextInputType.phone,
  inputFormatters: [PhoneNumberFormatter()],
  validator: (v) {
    if (v == null || v.isEmpty) return '请输入手机号';
    final digits = v.replaceAll(RegExp(r'\D'), '');
    if (digits.length != 11) return '手机号格式不正确';
    return null;
  },
)

4.3 格式化的取舍

方案优点缺点适用场景
仅过滤(Filtering)简单、无状态无法自动插入分隔符密码、字母、金额
自定义掩码体验好、所见即所得逻辑复杂、光标处理麻烦手机号、银行卡、日期
提交时格式化逻辑集中输入过程无提示校验后再统一处理

一句话:格式化应"轻过滤、重提交"——输入时只拦截非法字符,复杂的分段展示尽量在提交或回显时统一处理。


五、焦点管理与键盘处理

5.1 FocusNode 与焦点流转

多字段表单中,点击键盘"下一项"自动跳到下一个输入框是必备体验。这需要 FocusNode 配合 textInputAction: TextInputAction.next:

class _LoginFocusState extends State<LoginPage> {
  final _usernameFocus = FocusNode();
  final _passwordFocus = FocusNode();

  @override
  void dispose() {
    _usernameFocus.dispose();
    _passwordFocus.dispose();
    super.dispose();
  }

  void _submit() {
    _passwordFocus.unfocus(); // 收起键盘
    if (_formKey.currentState!.validate()) {
      // 提交逻辑
    }
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            focusNode: _usernameFocus,
            textInputAction: TextInputAction.next,
            onFieldSubmitted: (_) => _passwordFocus.requestFocus(), // 跳到密码
          ),
          TextFormField(
            focusNode: _passwordFocus,
            obscureText: true,
            textInputAction: TextInputAction.done,
            onFieldSubmitted: (_) => _submit(),
          ),
        ],
      ),
    );
  }
}

5.2 键盘处理与遮挡问题

当键盘弹出遮挡输入框时,需要配合 Scaffold 的 resizeToAvoidBottomInset 和滚动容器:

Scaffold(
  appBar: AppBar(title: const Text('注册')),
  body: SingleChildScrollView(          // 键盘弹出时可滚动
    padding: const EdgeInsets.all(16),
    child: Form(child: _buildFields()),
  ),
)

// 监听键盘高度做精细调整
// ignore: deprecated_member_use
final insets = MediaQuery.of(context).viewInsets;
final bottomPadding = insets.bottom > 0 ? 120.0 : 0.0;

5.3 键盘类型与输入法优化

场景keyboardType说明
邮箱TextInputType.emailAddress键盘自带 @ 和 .
电话TextInputType.phone数字键盘
金额/编号TextInputType.numberWithOptions(decimal: true)支持小数点
URLTextInputType.url键盘自带 .com
搜索TextInputType.text + TextInputAction.search右下角"搜索"按钮

一句话:焦点管理是表单体验的"隐形工程师"——next/done 流转 + 键盘收起时机,决定了多字段表单是否顺手。


六、错误提示与交互体验

6.1 InputDecoration 的错误样式

InputDecoration 提供了完善的错误状态样式,包括错误文本、图标、边框颜色:

TextFormField(
  decoration: InputDecoration(
    labelText: '邮箱',
    errorText: _emailError,
    errorMaxLines: 2,
    errorStyle: const TextStyle(fontSize: 12, color: Colors.red),
    prefixIcon: const Icon(Icons.email),
    border: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: const BorderSide(color: Colors.grey),
    ),
    focusedBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: const BorderSide(color: Colors.blue, width: 2),
    ),
    errorBorder: OutlineInputBorder(
      borderRadius: BorderRadius.circular(12),
      borderSide: const BorderSide(color: Colors.red),
    ),
  ),
)

6.2 全局错误提示(SnackBar / 顶部横幅)

字段级错误由 Validator 负责,表单级错误(如"用户名或密码错误")需要全局反馈:

void _handleLoginFailure(String message) {
  ScaffoldMessenger.of(context).showSnackBar(
    SnackBar(
      content: Text(message),
      backgroundColor: Theme.of(context).colorScheme.error,
      behavior: SnackBarBehavior.floating,
    ),
  );
}

6.3 防抖校验:避免每敲一个字就校验

在 AutovalidateMode.always 下,复杂校验(如正则)会随每次输入执行。用 debounce 优化异步校验频率:

import 'dart:async';

class _DebouncedValidatorState extends State<DebouncedPage> {
  Timer? _debounce;
  String? _error;

  void _onChanged(String value) {
    _debounce?.cancel();
    _debounce = Timer(const Duration(milliseconds: 400), () {
      setState(() {
        _error = value.length < 6 ? '至少 6 个字符' : null;
      });
    });
  }

  @override
  void dispose() {
    _debounce?.cancel();
    super.dispose();
  }
}

一句话:字段级错误用 InputDecoration 内联展示,表单级错误用 SnackBar/Dialog 提示,异步校验记得防抖。


七、复杂表单状态管理

7.1 动态表单字段(增删行)

当表单字段数量动态变化(如填写多份经历)时,用 List<TextEditingController> 管理:

class _DynamicFormState extends State<DynamicFormPage> {
  final List<TextEditingController> _items = [];

  @override
  void dispose() {
    for (final c in _items) {
      c.dispose();
    }
    super.dispose();
  }

  void _addItem() {
    setState(() => _items.add(TextEditingController()));
  }

  void _removeItem(int index) {
    _items[index].dispose();
    setState(() => _items.removeAt(index));
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        for (var i = 0; i < _items.length; i++)
          Row(
            children: [
              Expanded(
                child: TextFormField(
                  controller: _items[i],
                  decoration: InputDecoration(labelText: '项目 ${i + 1}'),
                ),
              ),
              IconButton(
                icon: const Icon(Icons.delete),
                onPressed: () => _removeItem(i),
              ),
            ],
          ),
        TextButton(onPressed: _addItem, child: const Text('+ 添加项目')),
      ],
    );
  }
}

7.2 表单数据模型与序列化

生产项目中,表单应绑定一个数据模型,提交时通过 onSaved 汇总:

class SignUpData {
  final String email;
  final String password;
  final String phone;

  const SignUpData({required this.email, required this.password, required this.phone});

  Map<String, dynamic> toJson() => {
        'email': email,
        'password': password,
        'phone': phone,
      };
}

class _SignUpFormState extends State<SignUpForm> {
  final _formKey = GlobalKey<FormState>();
  SignUpData? _data;

  void _submit() {
    if (!_formKey.currentState!.validate()) return;
    _formKey.currentState!.save(); // 触发 onSaved 填充数据
    if (_data != null) {
      // 提交 _data!.toJson()
    }
  }
}

7.3 表单状态与状态管理库结合

当表单状态需要跨页面共享或持久化时,可将其纳入 Riverpod / BLoC。例如用 Riverpod 的 StateNotifier 保存表单中间态:

// 仅作示意——表单草稿的持久化思路
class FormDraftNotifier extends StateNotifier<SignUpData?> {
  FormDraftNotifier() : super(null);

  void saveDraft(SignUpData data) => state = data;
  void clearDraft() => state = null;
}

一句话:动态字段、数据模型、草稿持久化是复杂表单的三座大山——Controller 生命周期要管好,提交数据要模型化。


八、实用表单案例

8.1 完整注册表单(含校验、格式化、焦点)

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

class RegisterForm extends StatefulWidget {
  const RegisterForm({super.key});
  @override
  State<RegisterForm> createState() => _RegisterFormState();
}

class _RegisterFormState extends State<RegisterForm> {
  final _formKey = GlobalKey<FormState>();
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();
  final _confirmController = TextEditingController();
  final _phoneController = TextEditingController();
  final _passwordFocus = FocusNode();
  final _confirmFocus = FocusNode();

  @override
  void dispose() {
    _emailController.dispose();
    _passwordController.dispose();
    _confirmController.dispose();
    _phoneController.dispose();
    _passwordFocus.dispose();
    _confirmFocus.dispose();
    super.dispose();
  }

  String? _validateEmail(String? v) {
    if (v == null || v.isEmpty) return '请输入邮箱';
    if (!RegExp(r'^[\w.+-]+@[\w-]+\.[\w.]+$').hasMatch(v)) return '邮箱格式不正确';
    return null;
  }

  String? _validatePassword(String? v) {
    if (v == null || v.isEmpty) return '请输入密码';
    if (v.length < 8) return '密码至少 8 位';
    return null;
  }

  void _register() {
    if (!_formKey.currentState!.validate()) return;
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('注册成功')),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('注册')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(24),
        child: Form(
          key: _formKey,
          autovalidateMode: AutovalidateMode.onUserInteraction,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              TextFormField(
                controller: _emailController,
                decoration: const InputDecoration(
                  labelText: '邮箱',
                  prefixIcon: Icon(Icons.email_outlined),
                ),
                keyboardType: TextInputType.emailAddress,
                textInputAction: TextInputAction.next,
                validator: _validateEmail,
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _passwordController,
                focusNode: _passwordFocus,
                decoration: const InputDecoration(
                  labelText: '密码',
                  prefixIcon: Icon(Icons.lock_outline),
                ),
                obscureText: true,
                textInputAction: TextInputAction.next,
                validator: _validatePassword,
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _confirmController,
                focusNode: _confirmFocus,
                decoration: const InputDecoration(
                  labelText: '确认密码',
                  prefixIcon: Icon(Icons.lock_outline),
                ),
                obscureText: true,
                textInputAction: TextInputAction.done,
                validator: (v) {
                  if (v == null || v.isEmpty) return '请再次输入密码';
                  if (v != _passwordController.text) return '两次密码不一致';
                  return null;
                },
              ),
              const SizedBox(height: 16),
              TextFormField(
                controller: _phoneController,
                decoration: const InputDecoration(
                  labelText: '手机号',
                  prefixIcon: Icon(Icons.phone_android),
                ),
                keyboardType: TextInputType.phone,
                inputFormatters: [
                  FilteringTextInputFormatter.digitsOnly,
                  LengthLimitingTextInputFormatter(11),
                ],
              ),
              const SizedBox(height: 24),
              FilledButton(
                onPressed: _register,
                child: const Text('立即注册'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

8.2 输入方案决策速查

需求推荐方案
提交时统一校验FormState.validate() + AutovalidateMode.disabled
用户失焦后反馈AutovalidateMode.onUserInteraction
实时过滤非法字符FilteringTextInputFormatter
自动补充分隔符自定义 TextInputFormatter
异步唯一性检查独立状态 + errorText
跨字段一致性(确认密码)读取兄弟 Controller 的 text

九、总结

Flutter 表单与输入验证不是简单的"套一个 Form"就能完成,它是一套贯穿架构与交互的完整工程:

维度关键手段常见误区
结构Form + FormField + Controller所有状态堆在一个 State
校验Validator 返回 null/字符串把网络请求写进 Validator
格式化过滤 + 掩码 + 提交时处理掩码逻辑塞满 build
焦点FocusNode 流转 + 键盘收起忘记 dispose FocusNode
反馈内联 errorText + 全局 SnackBar只有提交后才提示错误
状态数据模型 + 状态管理库直接操作控制器散乱状态

一句话:好的表单 = 严格的字段校验 + 顺滑的焦点流转 + 即时的错误反馈 + 清晰的数据模型。把这四件事做成模板,就能在项目中快速复制出高质量表单。


相关阅读

  • https://plumephp.com/flutter-state-management/ — 状态管理全解析(表单状态与 Provider/Riverpod 结合)
  • https://plumephp.com/flutter-performance-optimization/ — 性能优化(表单重建与 Controller 管理)
  • https://plumephp.com/flutter-accessibility/ — 无障碍可访问性(表单字段的语义与焦点顺序)

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Flutter」更多文章

  1. Flutter 渲染引擎与框架内部:Widget 树、Element 树与渲染管线
  2. Flutter 无障碍可访问性:语义、屏幕阅读器与导航
  3. Flutter 国际化与本地化:intl、ARB 与多语言应用