开篇:表单是用户体验的第一道防线
在任何移动应用中,表单都是用户与业务逻辑交互最频繁的载体——登录、注册、下单、填写资料。一个优秀的表单不仅要有清晰的布局,更要具备严谨的输入验证、流畅的焦点流转和明确的错误反馈。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 的组合,继承了两者的能力。以下是生产级表单中最常用的属性:
| 属性 | 类型 | 作用 |
|---|---|---|
decoration | InputDecoration | 标签、占位符、图标、错误样式 |
validator | FormFieldValidator<String> | 校验函数,返回 null 表示通过 |
keyboardType | TextInputType | 键盘类型(数字、邮箱、电话等) |
textInputAction | TextInputAction | 键盘右下角按钮(next/done) |
autofocus | bool | 页面加载后是否自动聚焦 |
enabled | bool | 是否可编辑 |
maxLength / maxLines | int | 输入长度 / 行数限制 |
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) | 支持小数点 |
| URL | TextInputType.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/ — 无障碍可访问性(表单字段的语义与焦点顺序)
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。