6.2 KiB
6.2 KiB
【Demo】Form 表单校验(7 个知识点)
源文件:
lib/pages/demo/form_demo.dart| 分类:基础布局 一句话:注册表单校验的完整范式 —— validator、时机、跨字段对比、协议勾选、两段式校验。
一、这个 Demo 解决什么问题
做注册/登录表单时新手最头疼的几件事,本页一次讲全: 必填/长度/正则/密码强度校验、确认密码对比、协议勾选校验、 validator 不能 await 时怎么做异步查重、防重复提交、重置表单。
和 Vue 的对照(关键差异)
| Vue | Flutter |
|---|---|
| v-model 双向绑定 + rules 规则对象 | TextEditingController + 每个字段自带 validator 函数 |
validate()(async-validator) |
formKey.currentState.validate() 统一触发 |
Flutter 的 validator 是同步函数,返回 String?:
- 返回
null→ 校验通过; - 返回字符串 → 错误文本(自动显示在输入框下方红字)。
没有"规则对象",每个字段的规则就写在它自己的 validator 里,就近维护。
二、怎么运行 / 演示内容
runApp(const MaterialApp(home: FormDemoPage()));
5 个输入框(用户名/手机号/邮箱/密码/确认密码)+ 协议勾选 + 提交/重置:
- 不填就提交 → 红字提示;填错即时提示(onUserInteraction);
- 提交通过 → 模拟 1 秒异步查重 → "注册成功";
- 重置 → 清空所有输入和校验状态。
三、核心知识点(新手向)
1. Form 的骨架
final GlobalKey<FormState> _formKey = GlobalKey<FormState>(); // Form 的唯一钥匙
Form(
key: _formKey,
autovalidateMode: AutovalidateMode.onUserInteraction, // 校验时机
child: Column(children: [ TextFormField(validator: ...), ... ]),
)
// 提交 / 重置都靠这把钥匙
_formKey.currentState!.validate();
_formKey.currentState?.reset();
2. 三种校验时机(AutovalidateMode)
| 模式 | 行为 | 建议 |
|---|---|---|
disabled(默认) |
只在点提交 validate() 时校验 | 简单表单 |
always |
build 时立刻校验 | 慎用:空表单满屏红字 |
onUserInteraction |
用户碰过的字段才即时校验 | 推荐 |
3. validator 写法范式
// 必填 + 长度
String? _validateName(String? v) {
final s = v?.trim() ?? '';
if (s.isEmpty) return '请输入用户名';
if (s.length < 2 || s.length > 8) return '用户名需 2-8 个字符';
return null;
}
// 正则全匹配:^...$ 必须写!不然 11 位之后还能续数字
String? _validatePhone(String? v) {
final s = v?.trim() ?? '';
if (s.isEmpty) return '请输入手机号';
if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(s)) return '手机号格式不正确';
return null;
}
// 跨字段对比:validator 里读另一个 controller
String? _validatePwd2(String? v) {
if (v == null || v.isEmpty) return '请再次输入密码';
if (v != _pwdC.text) return '两次密码不一致';
return null;
}
4. 两段式校验(重点!validator 不能 await)
// ① 同步校验:所有 validator 逐个执行
if (!_formKey.currentState!.validate()) {
_toast('有字段未通过校验,请检查红字提示');
return;
}
// ② 同步通过 → 再单独做异步业务校验(查重/验证码)
setState(() => _submitting = true);
await Future.delayed(const Duration(seconds: 1)); // 模拟查重请求
if (!mounted) return; // 异步后必须判 mounted 才能用 context
5. 协议勾选:FormField<bool> 校验"非输入型"字段
FormField<bool>(
initialValue: _agreed,
validator: (v) => (v ?? false) ? null : '请先勾选同意协议',
builder: (field) {
return Column(children: [
InkWell(
onTap: () {
field.didChange(!(field.value ?? false)); // 更新 FormField 自身
setState(() => _agreed = field.value ?? false); // 同步外部镜像
},
child: ...,
),
// 注意:自定义 FormField 的 errorText 要自己在 builder 里渲染!
if (field.hasError)
Text(field.errorText!, style: const TextStyle(color: Colors.red)),
]);
},
)
6. 防重复提交 + 重置
// 提交中按钮禁用(onPressed: null 自动置灰 + 换 loading 转圈)
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting ? CircularProgressIndicator() : const Text('提交'),
)
// 重置:reset() 只清校验错误和 FormField 状态,不碰 controller.text!
void _reset() {
_formKey.currentState?.reset();
_nameC.clear(); // 清空输入要手动 clear()
...
}
四、关键代码剖析
通用输入框抽取(本页把 label/hint/图标/校验规则全部参数化,避免 5 个输入框复制粘贴):
Widget _buildField({
required String label,
required TextEditingController controller,
required String? Function(String?) validator,
TextInputType? keyboardType,
}) {
return TextFormField(
controller: controller,
keyboardType: keyboardType,
validator: validator,
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
border: const OutlineInputBorder(),
),
);
}
五、踩坑记录
formState.reset()不碰controller.text—— 清空输入必须手动clear();- validator 是同步函数,不能 await —— 异步校验(查重/验证码)放 validate() 通过之后单独做;
- 正则要
^...$全匹配,否则RegExp(r'1[3-9]\d{9}')能匹配"123456789012345"; - 所有 controller 都要 dispose(本页 5 个);
- 加了
.w/.h/.sp的 widget 不能const; - 异步(await)之后用 context 必须先
if (!mounted) return。
六、扩展思考
- 校验规则多了以后,可以把 validator 抽成独立函数文件(如
lib/core/validators/),表单只负责组装; - 真实注册流程:本页"模拟查重"处换成
DioClient请求(见 dio_demo.md); - 密码强度可以升级为正则组合:
(?=.*[A-Za-z])(?=.*\d)就是"必须同时含字母和数字"的零宽断言写法; - 表单 + 状态管理:把 controller 和提交逻辑挪进 Riverpod
NotifierProvider,页面只剩纯 UI(见架构文档的 ExamPage 分层思想)。