import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; /// ============================================================ /// 【FormDemoPage】表单校验 Demo(Form + TextFormField + validator) /// /// 一、和 Vue 表单校验的对照(关键差异): /// Vue: v-model 双向绑定 + rules 规则对象 + validate()(async-validator) /// Flutter: TextEditingController 持有值(无 v-model)+ /// 每个 TextFormField 自带 validator 函数 + /// formKey.currentState.validate() 统一触发 /// validator 是【同步函数】,返回 String?: /// null → 校验通过;非 null → 错误文本(自动显示在输入框下方红字) /// —— 所以 Flutter 没有"规则对象",每个字段的规则就写在它自己的 /// validator 里,就近维护,一眼能看到这个字段的所有规则。 /// /// 二、三种校验时机(AutovalidateMode): /// - disabled(默认) → 只在点提交 validate() 时校验 /// - always → build 时立刻校验(空表单会满屏报错,慎用) /// - onUserInteraction → 用户碰过该字段后才校验(推荐: /// 未输入不报错,输错即时提示,提交时再兜底校验一次) /// /// 三、本页演示的 7 个知识点: /// 1. 必填/长度校验(用户名 2-8 位) /// 2. 正则校验(手机号 / 邮箱) /// 3. 密码强度(必须同时含字母和数字) /// 4. 跨字段对比(确认密码 vs 密码,validator 里读另一个 controller) /// 5. FormField 校验非输入型字段(协议勾选,errorText 手动渲染) /// 6. 两段式校验:validate() 同步通过后,再模拟异步查重 /// (validator 不能 await,异步业务校验必须放提交后) /// 7. 防重复提交(_submitting 置灰按钮)+ 重置表单 /// /// 四、高频坑: /// 1. formState.reset() 只清【校验错误】和 FormField 状态, /// 不碰 controller.text —— 清空输入要手动 clear(); /// 2. 确认密码的 validator 依赖 _pwdC.text,两个 controller 都必须 /// 在 dispose 里释放,否则内存泄漏; /// 3. 手机号正则要用 ^...$ 全匹配,不然 11 位之后还能续数字; /// 4. 加了 .w/.h/.sp 的 widget 不能再用 const(运行时才算尺寸); /// 5. 异步(await)之后用 context 必须先用 mounted 判空。 /// ============================================================ class FormDemoPage extends StatefulWidget { const FormDemoPage({super.key}); @override State createState() => _FormDemoPageState(); } class _FormDemoPageState extends State { /// Form 的唯一钥匙:提交(validate)/ 重置(reset)都靠它 final GlobalKey _formKey = GlobalKey(); /// 每个输入框一个 controller(dispose 里必须释放) final TextEditingController _nameC = TextEditingController(); final TextEditingController _phoneC = TextEditingController(); final TextEditingController _emailC = TextEditingController(); final TextEditingController _pwdC = TextEditingController(); final TextEditingController _pwd2C = TextEditingController(); bool _showPwd = false; // 密码明文/密文 bool _agreed = false; // 协议是否勾选(FormField 外部镜像,重置用) bool _submitting = false; // 提交中(防重复提交) @override void dispose() { _nameC.dispose(); _phoneC.dispose(); _emailC.dispose(); _pwdC.dispose(); _pwd2C.dispose(); super.dispose(); } // ==================== 校验规则(每个字段一个 validator) ==================== /// 用户名:必填 + 2-8 位 String? _validateName(String? v) { final s = v?.trim() ?? ''; if (s.isEmpty) return '请输入用户名'; if (s.length < 2 || s.length > 8) return '用户名需 2-8 个字符'; return null; } /// 手机号:必填 + 正则全匹配(^...$) 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; } /// 邮箱:选填,填了才校验格式 String? _validateEmail(String? v) { final s = v?.trim() ?? ''; if (s.isEmpty) return null; if (!RegExp(r'^[\w.+-]+@[\w-]+\.[\w.-]+$').hasMatch(s)) return '邮箱格式不正确'; return null; } /// 密码:必填 + 6-20 位 + 同时含字母和数字 String? _validatePwd(String? v) { final s = v ?? ''; if (s.isEmpty) return '请输入密码'; if (s.length < 6 || s.length > 20) return '密码需 6-20 位'; if (!RegExp(r'(?=.*[A-Za-z])(?=.*\d)').hasMatch(s)) return '密码需同时包含字母和数字'; return null; } /// 确认密码:必填 + 跨字段对比(读 _pwdC.text) String? _validatePwd2(String? v) { final s = v ?? ''; if (s.isEmpty) return '请再次输入密码'; if (s != _pwdC.text) return '两次密码不一致'; return null; } // ==================== 提交 / 重置 ==================== /// 两段式校验:先同步 validate(),通过后模拟异步查重 Future _submit() async { if (_submitting) return; // 防连点 FocusScope.of(context).unfocus(); // 收起键盘 // ① 同步校验:所有子字段的 validator 逐个执行,只要有一个返回 // 非 null → validate() 为 false,错误红字已自动显示。 if (!_formKey.currentState!.validate()) { _toast('有字段未通过校验,请检查红字提示'); return; } // ② 同步通过 → 异步业务校验(查重/验证码)。validator 是同步函数 // 不能 await,异步校验必须在 validate() 之后单独做。 setState(() => _submitting = true); await Future.delayed(const Duration(seconds: 1)); // 模拟查重请求 if (!mounted) return; // 异步后必须判 mounted 才能用 context setState(() => _submitting = false); _toast('校验全部通过,注册成功!'); } /// 重置:formState.reset() 只清校验错误,输入内容要手动 clear() void _reset() { _formKey.currentState?.reset(); _nameC.clear(); _phoneC.clear(); _emailC.clear(); _pwdC.clear(); _pwd2C.clear(); setState(() => _agreed = false); } void _toast(String msg) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar(content: Text(msg), duration: const Duration(seconds: 2)), ); } // ==================== UI ==================== @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('表单校验 Demo')), body: SingleChildScrollView( padding: EdgeInsets.all(20.w), child: Form( key: _formKey, // onUserInteraction:用户碰过的字段才即时校验,未碰不报错 autovalidateMode: AutovalidateMode.onUserInteraction, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _buildTipCard(), SizedBox(height: 16.h), _buildField( label: '用户名', hint: '2-8 个字符', icon: Icons.person_outline, controller: _nameC, validator: _validateName, ), SizedBox(height: 16.h), _buildField( label: '手机号', hint: '11 位大陆手机号', icon: Icons.phone_iphone, controller: _phoneC, validator: _validatePhone, keyboardType: TextInputType.phone, ), SizedBox(height: 16.h), _buildField( label: '邮箱(选填)', hint: '例:name@example.com', icon: Icons.mail_outline, controller: _emailC, validator: _validateEmail, keyboardType: TextInputType.emailAddress, ), SizedBox(height: 16.h), // 密码:带"眼睛"显隐切换 TextFormField( controller: _pwdC, obscureText: !_showPwd, validator: _validatePwd, decoration: InputDecoration( labelText: '密码', hintText: '6-20 位,含字母和数字', prefixIcon: const Icon(Icons.lock_outline), suffixIcon: IconButton( icon: Icon( _showPwd ? Icons.visibility_outlined : Icons.visibility_off_outlined, ), onPressed: () => setState(() => _showPwd = !_showPwd), ), border: const OutlineInputBorder(), ), ), SizedBox(height: 16.h), _buildField( label: '确认密码', hint: '再次输入密码', icon: Icons.lock_outline, controller: _pwd2C, validator: _validatePwd2, ), SizedBox(height: 16.h), _buildAgreement(), SizedBox(height: 24.h), _buildButtons(), ], ), ), ), ); } /// 顶部说明卡片 Widget _buildTipCard() { return Container( padding: EdgeInsets.all(16.w), decoration: BoxDecoration( color: const Color(0xFFE8F0FE), borderRadius: BorderRadius.circular(12.r), ), child: const Text( '校验机制:Form 收集所有子字段的 validator,' '点提交统一 validate()。validator 返回 null=通过,' '返回字符串=错误红字(自动显示)。autovalidateMode 控制即时校验。', style: TextStyle(fontSize: 13, color: Color(0xFF3B82F6)), ), ); } /// 通用输入框(label/hint/图标/校验规则全部参数化) Widget _buildField({ required String label, required String hint, required IconData icon, required TextEditingController controller, required String? Function(String?) validator, TextInputType? keyboardType, }) { return TextFormField( controller: controller, keyboardType: keyboardType, validator: validator, decoration: InputDecoration( labelText: label, hintText: hint, prefixIcon: Icon(icon), border: const OutlineInputBorder(), ), ); } /// 协议勾选:FormField 校验"非输入型"字段。 /// 注意:自定义 FormField 的 errorText 要自己在 builder 里渲染。 Widget _buildAgreement() { return FormField( initialValue: _agreed, validator: (v) => (v ?? false) ? null : '请先勾选同意协议', builder: (field) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ InkWell( onTap: () { field.didChange(!(field.value ?? false)); // 更新 FormField 自身 setState(() => _agreed = field.value ?? false); // 同步外部镜像 }, child: Row( children: [ Icon( _agreed ? Icons.check_circle_rounded : Icons.radio_button_unchecked, size: 24, color: _agreed ? const Color(0xFF3B82F6) : const Color(0xFFC0C4CC), ), SizedBox(width: 8.w), const Text('我已阅读并同意《用户协议》和《隐私政策》'), ], ), ), if (field.hasError) Padding( padding: EdgeInsets.only(top: 4.h), child: Text( field.errorText!, style: const TextStyle(color: Colors.red, fontSize: 12), ), ), ], ); }, ); } /// 提交 + 重置按钮 Widget _buildButtons() { return Row( children: [ Expanded( child: SizedBox( height: 48.h, child: FilledButton( // 提交中禁用:防重复提交 onPressed: _submitting ? null : _submit, child: _submitting ? const SizedBox( width: 22, height: 22, child: CircularProgressIndicator( strokeWidth: 2.5, color: Colors.white, ), ) : const Text('提交'), ), ), ), SizedBox(width: 12.w), Expanded( child: SizedBox( height: 48.h, child: OutlinedButton( onPressed: _submitting ? null : _reset, child: const Text('重置'), ), ), ), ], ); } }