表單處理
內容: 對應 react-hook-form / Formik,Flutter 表單兩種方式 + 完整登錄頁示例,3 個 Tab 演示。
Tab 1:Form + TextFormField(官方表單,推薦)
- 等價 react-hook-form 的
useForm + register + handleSubmit GlobalKey<FormState>控制校驗/重置/保存autovalidateMode三種觸發時機(disabled / onUserInteraction / always)validator返回null= 通過,返回字符串 = 錯誤信息- 密碼可顯示/隱藏的
_PasswordField組件
Tab 2:受控組件(TextEditingController)
- 等價 React 受控組件(
value + onChange) - 程式碼設置值、清空、光標定位
inputFormatters限制輸入(只允許數字/小數)- 多行文本(
maxLines)、字數限制(maxLength)
Tab 3:完整登錄表單
- 郵箱 + 密碼校驗
- Loading 狀態按鈕禁用
- 服務端錯誤提示(模擬
setError('root', {...})) mounted防止組件卸載後 setState
运行
在线效果可以复制到:dartpad.dev/ 查看,更推荐本地flutter build web调试学习
效果图
学习代码
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
// ============================================================
// Flutter 表单处理
// 等价于 react-hook-form / Formik
//
// Flutter 表单两种方式:
// Form + TextFormField(官方,等价于 react-hook-form)
// TextEditingController(手动控制,等价于受控组件)
// ============================================================
void main() => runApp(const MaterialApp(home: _FormDemo()));
class _FormDemo extends StatelessWidget {
const _FormDemo();
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
appBar: AppBar(
title: const Text('表单处理'),
bottom: const TabBar(tabs: [
Tab(text: 'Form 校验'),
Tab(text: '受控组件'),
Tab(text: '完整登录'),
]),
),
body: const TabBarView(children: [
_FormValidationDemo(),
_ControlledInputDemo(),
_LoginFormDemo(),
]),
),
);
}
}
// ============================================================
// 一、Form + TextFormField(官方表单,推荐)
// 等价于 react-hook-form 的 useForm + register + handleSubmit
// ============================================================
class _FormValidationDemo extends StatefulWidget {
const _FormValidationDemo();
@override
State<_FormValidationDemo> createState() => _FormValidationDemoState();
}
class _FormValidationDemoState extends State<_FormValidationDemo> {
// 等价于 react-hook-form 的 useForm()
// GlobalKey 用于访问 FormState(validate / reset / save)
final _formKey = GlobalKey<FormState>();
// 存储表单值
String _name = '';
String _email = '';
String _password = '';
void _submit() {
// 等价于 handleSubmit(onSubmit)
// validate() 会触发所有字段的 validator,返回 true = 全部通过
if (_formKey.currentState!.validate()) {
// 调用 save() 触发所有字段的 onSaved 回调
_formKey.currentState!.save();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('提交成功:$_name / $_email')),
);
}
}
void _reset() {
// 等价于 reset()
_formKey.currentState!.reset();
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
// autovalidateMode: 校验触发时机
// AutovalidateMode.disabled → 只在 validate() 时校验(默认)
// AutovalidateMode.onUserInteraction → 用户输入后实时校验
// AutovalidateMode.always → 始终校验
autovalidateMode: AutovalidateMode.onUserInteraction,
child: Column(
children: [
// ── 姓名字段 ────────────────────────────────────
// 等价于:
// <input {...register('name', { required: '请输入姓名', minLength: 2 })} />
TextFormField(
decoration: const InputDecoration(
labelText: '姓名',
hintText: '请输入姓名',
prefixIcon: Icon(Icons.person),
),
// validator:等价于 react-hook-form 的 validate 规则
validator: (value) {
if (value == null || value.isEmpty) return '请输入姓名';
if (value.length < 2) return '姓名至少 2 个字符';
return null; // null = 校验通过
},
// onSaved:调用 formKey.currentState!.save() 时触发
onSaved: (value) => _name = value ?? '',
),
const SizedBox(height: 16),
// ── 邮箱字段 ────────────────────────────────────
TextFormField(
decoration: const InputDecoration(
labelText: '邮箱',
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress, // 弹出邮箱键盘
validator: (value) {
if (value == null || value.isEmpty) return '请输入邮箱';
// 正则校验
final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');
if (!emailRegex.hasMatch(value)) return '邮箱格式不正确';
return null;
},
onSaved: (value) => _email = value ?? '',
),
const SizedBox(height: 16),
// ── 密码字段 ────────────────────────────────────
_PasswordField(
label: '密码',
validator: (value) {
if (value == null || value.isEmpty) return '请输入密码';
if (value.length < 6) return '密码至少 6 位';
return null;
},
onSaved: (value) => _password = value ?? '',
),
const SizedBox(height: 24),
// ── 提交 / 重置 ─────────────────────────────────
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: _submit,
child: const Text('提交'),
),
),
const SizedBox(width: 16),
Expanded(
child: OutlinedButton(
onPressed: _reset,
child: const Text('重置'),
),
),
],
),
],
),
),
);
}
}
// 密码输入框(可切换显示/隐藏)
class _PasswordField extends StatefulWidget {
final String label;
final FormFieldValidator<String>? validator;
final FormFieldSetter<String>? onSaved;
const _PasswordField({required this.label, this.validator, this.onSaved});
@override
State<_PasswordField> createState() => _PasswordFieldState();
}
class _PasswordFieldState extends State<_PasswordField> {
bool _obscure = true;
@override
Widget build(BuildContext context) {
return TextFormField(
obscureText: _obscure, // 密码遮罩
decoration: InputDecoration(
labelText: widget.label,
prefixIcon: const Icon(Icons.lock),
// 右侧眼睛图标切换显隐(等价于 React 受控 type="password" 切换)
suffixIcon: IconButton(
icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility),
onPressed: () => setState(() => _obscure = !_obscure),
),
),
validator: widget.validator,
onSaved: widget.onSaved,
);
}
}
// ============================================================
// 二、受控组件(TextEditingController)
// 等价于 React 受控组件:value + onChange
// ============================================================
class _ControlledInputDemo extends StatefulWidget {
const _ControlledInputDemo();
@override
State<_ControlledInputDemo> createState() => _ControlledInputDemoState();
}
class _ControlledInputDemoState extends State<_ControlledInputDemo> {
// 等价于:const [value, setValue] = useState('')
final TextEditingController _controller = TextEditingController();
final TextEditingController _priceController = TextEditingController();
@override
void dispose() {
// 必须释放,等价于 React useEffect cleanup
_controller.dispose();
_priceController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 基础受控输入
TextField(
controller: _controller,
decoration: const InputDecoration(labelText: '受控输入'),
// 等价于 onChange:实时监听输入
onChanged: (value) {
setState(() {}); // 触发重建显示实时预览
},
),
Text('实时值:${_controller.text}'),
const SizedBox(height: 16),
// 程序化设置值(等价于 setValue('xxx'))
Row(
children: [
TextButton(
onPressed: () {
_controller.text = '程序设置的值';
// 光标移到末尾
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
},
child: const Text('程序设置值'),
),
TextButton(
onPressed: () => _controller.clear(), // 等价于 setValue('')
child: const Text('清空'),
),
],
),
const SizedBox(height: 16),
// 只允许输入数字(等价于 <input type="number">)
TextField(
controller: _priceController,
decoration: const InputDecoration(
labelText: '价格(只能输入数字)',
prefixText: '¥ ',
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
// 过滤非数字字符,等价于 onChange 里的正则过滤
FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}')),
],
),
const SizedBox(height: 16),
// 多行文本(等价于 <textarea>)
const TextField(
maxLines: 4, // null = 无限行
minLines: 2,
decoration: InputDecoration(
labelText: '多行文本',
alignLabelWithHint: true,
),
),
const SizedBox(height: 16),
// 字符数限制(等价于 maxLength 属性)
const TextField(
maxLength: 100, // 自动显示字符计数
decoration: InputDecoration(labelText: '限制 100 字'),
),
],
),
);
}
}
// ============================================================
// 三、完整登录表单(综合示例)
// 等价于 react-hook-form 完整登录页
// ============================================================
class _LoginFormDemo extends StatefulWidget {
const _LoginFormDemo();
@override
State<_LoginFormDemo> createState() => _LoginFormDemoState();
}
class _LoginFormDemoState extends State<_LoginFormDemo> {
final _formKey = GlobalKey<FormState>();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _isLoading = false;
String? _serverError; // 服务端返回的错误(等价于 setError('root', {...}))
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future<void> _login() async {
// 清除服务端错误
setState(() => _serverError = null);
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
// 模拟请求
await Future.delayed(const Duration(seconds: 1));
// 模拟服务端错误(等价于 setError('root', { message: '...' }))
if (_emailController.text != 'test@test.com') {
setState(() => _serverError = '邮箱或密码错误');
return;
}
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('登录成功!')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 32),
Text(
'登录',
style: Theme.of(context).textTheme.headlineMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
// 邮箱
TextFormField(
controller: _emailController,
decoration: const InputDecoration(
labelText: '邮箱',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email_outlined),
),
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next, // 键盘下一步按钮
validator: (v) {
if (v == null || v.isEmpty) return '请输入邮箱';
if (!v.contains('@')) return '邮箱格式不正确';
return null;
},
),
const SizedBox(height: 16),
// 密码
_PasswordField(
label: '密码',
validator: (v) {
if (v == null || v.isEmpty) return '请输入密码';
if (v.length < 6) return '密码至少 6 位';
return null;
},
),
const SizedBox(height: 8),
// 服务端错误提示(等价于 errors.root?.message)
if (_serverError != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
_serverError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
// 忘记密码
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () {},
child: const Text('忘记密码?'),
),
),
const SizedBox(height: 16),
// 提交按钮(loading 状态禁用)
ElevatedButton(
onPressed: _isLoading ? null : _login,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: _isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('登录', style: TextStyle(fontSize: 16)),
),
const SizedBox(height: 16),
// 提示文字
const Text(
'测试账号:test@test.com / 任意密码',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
],
),
),
);
}
}
// ============================================================
// 附:react-hook-form vs Flutter Form 对照
//
// react-hook-form Flutter Form
// ─────────────────────────────────────────────────────────
// useForm() GlobalKey<FormState>
// register('field', rules) TextFormField(validator: ...)
// handleSubmit(onSubmit) formKey.currentState!.validate()
// reset() formKey.currentState!.reset()
// watch('field') TextEditingController.text
// setValue('field', value) controller.text = value
// setError('field', msg) setState(() => _error = msg)
// errors.field?.message validator 返回的错误字符串
// trigger('field') formKey.currentState!.validate()
// <input type="number"> keyboardType: TextInputType.number
// <textarea rows={4}> TextField(maxLines: 4)
// maxLength={100} TextField(maxLength: 100)
// <input type="password"> TextField(obscureText: true)
// onChange onChanged
// onBlur onEditingComplete / focusNode.addListener
// ============================================================