358 lines
13 KiB
Dart
358 lines
13 KiB
Dart
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<bool> 校验非输入型字段(协议勾选,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<FormDemoPage> createState() => _FormDemoPageState();
|
||
}
|
||
|
||
class _FormDemoPageState extends State<FormDemoPage> {
|
||
/// Form 的唯一钥匙:提交(validate)/ 重置(reset)都靠它
|
||
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
|
||
|
||
/// 每个输入框一个 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<void> _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<bool> 校验"非输入型"字段。
|
||
/// 注意:自定义 FormField 的 errorText 要自己在 builder 里渲染。
|
||
Widget _buildAgreement() {
|
||
return FormField<bool>(
|
||
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('重置'),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|