原生 Flutter 表单开发繁琐至极 ------TextEditingController手动维护、验证逻辑分散、样式配置重复,稍不注意就出现状态混乱。本文优化后的FormKit以轻量设计整合「输入管理 + 多维度验证 + 状态同步 + 样式适配」核心能力,支持文本 / 密码 / 手机号 / 开关 / 选择器等高频场景,一行配置 + 几行代码即可搭建稳定表单,彻底解放重复编码!
一、核心优势(精准解决开发痛点)
✅ 零手动控制器:自动创建、管理并释放TextEditingController,无需关心生命周期✅ 多维度验证:内置必填、正则、自定义(同步 / 异步)校验,实时错误反馈,支持组合验证✅ 轻量无冗余:核心代码仅 300 + 行,无额外依赖,编译体积小✅ 样式自适应:默认适配主流 UI 风格,标签、输入框、错误提示视觉统一,支持按需自定义✅ 快速集成:配置化表单项,无需复杂布局嵌套,新手也能快速上手✅ 交互友好:输入实时验证、失去焦点自动校验、提交时关闭键盘,体验贴近原生
二、完整代码实现(可直接复制使用)
dart
import 'package:flutter/material.dart';
// 表单项类型枚举(覆盖高频场景,扩展灵活)
enum FormItemType {
text, // 普通文本输入框
password, // 密码输入框(隐藏输入)
phone, // 手机号输入框(数字键盘)
switchBtn, // 开关(布尔值)
selector // 选择器(日期/地区/自定义弹窗)
}
// 验证规则模型(精简核心参数,支持组合校验)
class FormRule {
final bool required; // 是否必填
final String? requiredMsg; // 必填错误提示(默认"此字段不能为空")
final RegExp? regex; // 正则校验规则
final String? regexMsg; // 正则错误提示(默认"格式不正确")
final String? Function(String?)? customValidate; // 自定义校验(支持异步)
const FormRule({
this.required = false,
this.requiredMsg = '此字段不能为空',
this.regex,
this.regexMsg = '格式不正确',
this.customValidate,
});
}
// 表单项模型(统一配置,减少冗余)
class FormItem {
final String key; // 表单项唯一标识(用于获取值/错误)
final String label; // 左侧标签文本
final FormItemType type; // 表单项类型
final String? hint; // 输入提示文本
final dynamic initialValue; // 初始值(输入类默认空字符串,开关默认false)
final FormRule rule; // 验证规则
final bool enabled; // 是否启用(默认true)
final Function(dynamic)? onChanged; // 值变化回调(实时反馈)
final Function()? onTap; // 点击回调(仅选择器类型生效)
final TextStyle? labelStyle; // 标签文本样式(自定义风格)
final TextStyle? inputStyle; // 输入文本样式(自定义风格)
const FormItem({
required this.key,
required this.label,
this.type = FormItemType.text,
this.hint,
this.initialValue,
this.rule = const FormRule(),
this.enabled = true,
this.onChanged,
this.onTap,
this.labelStyle,
this.inputStyle,
});
}
// 表单提交结果模型(统一返回格式,便于处理)
class FormResult {
final bool isValid; // 是否全部验证通过
final Map<String, dynamic> values; // 所有表单项的值(key对应FormItem.key)
final Map<String, String?> errors; // 验证错误信息(key对应FormItem.key)
FormResult({
required this.isValid,
required this.values,
required this.errors,
});
}
/// 轻量通用表单组件(核心实现)
class FormKit extends StatefulWidget {
final List<FormItem> items; // 表单项列表
final Widget submitBtn; // 提交按钮(支持完全自定义样式)
final Function(FormResult) onSubmit; // 提交回调(返回验证结果)
final double itemSpacing; // 表单项上下间距(默认12px)
final Color dividerColor; // 分割线颜色(默认浅灰色)
const FormKit({
super.key,
required this.items,
required this.submitBtn,
required this.onSubmit,
this.itemSpacing = 12.0,
this.dividerColor = const Color(0xFFF5F5F5),
});
@override
State<FormKit> createState() => _FormKitState();
}
class _FormKitState extends State<FormKit> {
final Map<String, dynamic> _formValues = {}; // 存储所有表单项值
final Map<String, String?> _formErrors = {}; // 存储所有表单项错误
final Map<String, TextEditingController> _inputControllers = {}; // 输入控制器缓存
@override
void initState() {
super.initState();
_initFormData(); // 初始化表单值与控制器
}
@override
void dispose() {
// 释放所有输入控制器,避免内存泄漏
_inputControllers.forEach((key, controller) => controller.dispose());
super.dispose();
}
/// 初始化表单:设置初始值+创建输入控制器
void _initFormData() {
for (final item in widget.items) {
// 初始化值:优先使用传入的initialValue,无则按类型赋默认值
_formValues[item.key] = item.initialValue ??
(item.type == FormItemType.switchBtn ? false : '');
// 输入类表单项(文本/密码/手机号)创建控制器并监听输入
if ([FormItemType.text, FormItemType.password, FormItemType.phone].contains(item.type)) {
final controller = TextEditingController(
text: item.initialValue?.toString() ?? '',
);
_inputControllers[item.key] = controller;
// 输入变化时更新值并实时验证
controller.addListener(() => _handleInputChange(item.key, controller.text));
}
}
}
/// 处理输入变化:更新值+实时验证
void _handleInputChange(String key, String value) {
setState(() {
_formValues[key] = value;
_validateSingleItem(key); // 实时验证当前项
});
// 触发外部变化回调
widget.items.firstWhere((item) => item.key == key).onChanged?.call(value);
}
/// 验证单个表单项(按规则顺序校验)
Future<void> _validateSingleItem(String key) async {
final targetItem = widget.items.firstWhere((item) => item.key == key);
final value = _formValues[key];
final rule = targetItem.rule;
String? errorMsg;
// 1. 必填校验(值为空时触发)
if (rule.required && (value == null || value.toString().trim().isEmpty)) {
errorMsg = rule.requiredMsg;
}
// 2. 正则校验(值非空且有正则规则时触发)
else if (value != null && value.toString().isNotEmpty && rule.regex != null) {
if (!rule.regex!.hasMatch(value.toString())) {
errorMsg = rule.regexMsg;
}
}
// 3. 自定义校验(支持异步,如校验用户名是否已存在)
else if (rule.customValidate != null) {
errorMsg = await rule.customValidate?.call(value?.toString());
}
setState(() {
_formErrors[key] = errorMsg;
});
}
/// 全量验证(提交时调用,返回整体结果)
Future<FormResult> _validateAllItems() async {
final Map<String, String?> allErrors = {};
bool isAllValid = true;
// 遍历所有表单项,执行校验
for (final item in widget.items) {
await _validateSingleItem(item.key);
if (_formErrors[item.key] != null) {
allErrors[item.key] = _formErrors[item.key];
isAllValid = false;
}
}
return FormResult(
isValid: isAllValid,
values: Map.unmodifiable(_formValues), // 不可修改,避免外部篡改
errors: Map.unmodifiable(allErrors),
);
}
/// 提交表单:关闭键盘+全量验证+触发回调
void _submitForm() async {
FocusScope.of(context).unfocus(); // 关闭软键盘,提升体验
final result = await _validateAllItems();
widget.onSubmit(result); // 将结果返回给外部处理
}
/// 构建单个表单项(按类型适配UI)
Widget _buildFormItem(FormItem item) {
final hasError = _formErrors[item.key] != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 表单项主体(标签+输入内容)
Padding(
padding: EdgeInsets.symmetric(vertical: widget.itemSpacing / 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 左侧标签
SizedBox(
width: 80,
child: Text(
item.label,
style: item.labelStyle ??
const TextStyle(
fontSize: 16,
color: Colors.black87,
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(width: 8),
// 右侧输入/选择内容(占满剩余宽度)
Expanded(child: _buildItemContent(item)),
],
),
),
// 错误提示(校验失败时显示)
if (hasError)
Padding(
padding: const EdgeInsets.only(left: 88, top: 4),
child: Text(
_formErrors[item.key]!,
style: const TextStyle(
fontSize: 12,
color: Colors.redAccent,
height: 1.2,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
// 分割线(视觉分隔表单项)
Divider(
height: 1,
color: widget.dividerColor,
indent: 88, // 与标签对齐,视觉更统一
),
],
);
}
/// 构建表单项内容(按类型差异化实现)
Widget _buildItemContent(FormItem item) {
switch (item.type) {
case FormItemType.text:
case FormItemType.phone:
return TextField(
controller: _inputControllers[item.key],
enabled: item.enabled,
keyboardType: item.type == FormItemType.phone
? TextInputType.phone
: TextInputType.text,
style: item.inputStyle ??
const TextStyle(fontSize: 16, color: Colors.black87),
decoration: InputDecoration(
hintText: item.hint,
hintStyle: const TextStyle(fontSize: 16, color: Color(0xFF999999)),
border: InputBorder.none, // 隐藏默认边框,适配自定义风格
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
),
// 失去焦点时触发校验,补充实时验证的遗漏场景
onEditingComplete: () => _validateSingleItem(item.key),
);
case FormItemType.password:
return TextField(
controller: _inputControllers[item.key],
enabled: item.enabled,
obscureText: true, // 隐藏输入内容
style: item.inputStyle ??
const TextStyle(fontSize: 16, color: Colors.black87),
decoration: InputDecoration(
hintText: item.hint,
hintStyle: const TextStyle(fontSize: 16, color: Color(0xFF999999)),
border: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: 8),
),
onEditingComplete: () => _validateSingleItem(item.key),
);
case FormItemType.switchBtn:
return Switch(
value: _formValues[item.key] as bool,
onChanged: item.enabled
? (newValue) => setState(() {
_formValues[item.key] = newValue;
_validateSingleItem(item.key);
})
: null,
activeColor: Colors.blueAccent,
inactiveTrackColor: const Color(0xFFE8E8E8),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, // 缩小点击区域,适配布局
);
case FormItemType.selector:
return GestureDetector(
onTap: item.enabled ? item.onTap : null,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formValues[item.key].toString().isEmpty
? (item.hint ?? '请选择')
: _formValues[item.key].toString(),
style: TextStyle(
fontSize: 16,
color: _formValues[item.key].toString().isEmpty
? const Color(0xFF999999)
: Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const Icon(Icons.arrow_forward_ios, size: 16, color: Color(0xFF999999)),
],
),
);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 表单项列表
Column(
children: widget.items.map((item) => _buildFormItem(item)).toList(),
),
const SizedBox(height: 24),
// 提交按钮(包装点击事件)
GestureDetector(
onTap: _submitForm,
behavior: HitTestBehavior.opaque,
child: widget.submitBtn,
),
],
),
);
}
}
三、实战使用示例(覆盖 3 大高频场景)
场景 1:登录表单(文本 + 密码 + 开关,基础核心场景)
dart
FormKit(
items: [
FormItem(
key: 'phone',
label: '手机号',
type: FormItemType.phone,
hint: '请输入11位手机号',
rule: FormRule(
required: true,
regex: RegExp(r'^1[3-9]\d{9}$'),
regexMsg: '手机号格式错误',
),
inputStyle: const TextStyle(fontSize: 15),
),
FormItem(
key: 'password',
label: '密码',
type: FormItemType.password,
hint: '请输入6-18位密码',
rule: FormRule(
required: true,
regex: RegExp(r'^.{6,18}$'),
regexMsg: '密码长度6-18位',
),
inputStyle: const TextStyle(fontSize: 15),
),
FormItem(
key: 'remember',
label: '记住我',
type: FormItemType.switchBtn,
initialValue: true,
),
],
submitBtn: Container(
width: double.infinity,
height: 50,
decoration: BoxDecoration(
color: Colors.blueAccent,
borderRadius: BorderRadius.circular(25),
boxShadow: [
BoxShadow(
color: Colors.blueAccent.withOpacity(0.3),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: const Text(
'登录',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w500),
),
),
onSubmit: (result) {
if (result.isValid) {
// 验证通过,执行登录逻辑
final phone = result.values['phone'];
final password = result.values['password'];
debugPrint('登录请求:手机号=$phone,密码=$password');
// 实际场景:调用登录接口...
} else {
// 验证失败,处理错误(如弹窗提示)
debugPrint('表单错误:${result.errors}');
}
},
dividerColor: Colors.grey[200]!,
)
场景 2:注册表单(带联动验证,复杂场景)
dart
FormKit(
items: [
FormItem(
key: 'username',
label: '用户名',
hint: '请输入4-16位用户名',
rule: FormRule(
required: true,
regex: RegExp(r'^[a-zA-Z0-9_]{4,16}$'),
regexMsg: '仅支持字母、数字、下划线',
),
),
FormItem(
key: 'password',
label: '密码',
type: FormItemType.password,
hint: '请输入6-18位密码',
rule: FormRule(
required: true,
regex: RegExp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{6,18}$'),
regexMsg: '需包含大小写字母和数字',
),
),
FormItem(
key: 'confirmPwd',
label: '确认密码',
type: FormItemType.password,
hint: '请再次输入密码',
rule: FormRule(
required: true,
customValidate: (value) {
// 联动密码字段,校验一致性(核心联动逻辑)
final password = _formValues['password'];
if (value != password) {
return '两次密码输入不一致';
}
return null;
},
),
),
],
submitBtn: Container(
width: double.infinity,
height: 50,
decoration: BoxDecoration(
color: Colors.greenAccent,
borderRadius: BorderRadius.circular(25),
),
alignment: Alignment.center,
child: const Text(
'注册',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w500),
),
),
onSubmit: (result) {
if (result.isValid) {
debugPrint('注册成功:${result.values}');
}
},
)
场景 3:信息设置表单(文本 + 选择器,配置类场景)
dart
class ProfileSettingPage extends StatefulWidget {
const ProfileSettingPage({super.key});
@override
State<ProfileSettingPage> createState() => _ProfileSettingPageState();
}
class _ProfileSettingPageState extends State<ProfileSettingPage> {
String _selectedCity = '';
final GlobalKey<_FormKitState> _formKey = GlobalKey();
// 选择城市弹窗(模拟选择器逻辑)
void _showCitySelector() async {
final selected = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('选择所在城市'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: ['北京', '上海', '广州', '深圳', '杭州']
.map((city) => ListTile(
title: Text(city),
onTap: () => Navigator.pop(context, city),
))
.toList(),
),
),
);
if (selected != null) {
setState(() => _selectedCity = selected);
// 通过GlobalKey更新表单值
_formKey.currentState?._formValues['city'] = selected;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('个人信息设置')),
body: FormKit(
key: _formKey,
items: [
FormItem(
key: 'nickname',
label: '昵称',
hint: '请输入昵称',
rule: FormRule(required: true, requiredMsg: '昵称不能为空'),
inputStyle: const TextStyle(fontSize: 16),
),
FormItem(
key: 'city',
label: '城市',
type: FormItemType.selector,
hint: '请选择城市',
initialValue: _selectedCity,
onTap: _showCitySelector,
rule: FormRule(required: true, requiredMsg: '请选择所在城市'),
),
FormItem(
key: 'pushNotify',
label: '推送通知',
type: FormItemType.switchBtn,
initialValue: true,
),
],
submitBtn: Container(
width: double.infinity,
height: 48,
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(24),
),
alignment: Alignment.center,
child: const Text(
'保存设置',
style: TextStyle(color: Colors.white, fontSize: 17),
),
),
onSubmit: (result) {
if (result.isValid) {
debugPrint('保存成功:${result.values}');
// 显示保存成功提示
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('设置保存成功!')),
);
}
},
itemSpacing: 15,
dividerColor: Colors.grey[100]!,
),
);
}
}
四、核心封装技巧(快速掌握设计思路)
- 自动控制器管理 :内部缓存
TextEditingController,初始化时创建、销毁时释放,开发者无需手动处理,彻底避免 "忘记 dispose" 导致的内存泄漏。 - 验证逻辑解耦 :通过
FormRule统一封装校验规则,支持 "必填 + 正则 + 自定义" 组合校验,新增校验场景时只需扩展FormRule,无需修改组件核心逻辑。 - 状态实时同步 :输入变化、开关切换时自动更新
_formValues,并触发实时验证,错误提示即时反馈,提升用户体验。 - UI 细节优化:分割线与标签对齐、输入框隐藏默认边框、开关缩小点击区域,默认样式贴合主流设计,减少自定义成本。
- 灵活扩展设计 :支持自定义标签 / 输入文本样式、分割线颜色、表单项间距,同时保留原生表单的扩展性(如
onChanged回调)。
五、避坑指南(实际开发必看)
- 表单项 key 唯一性 :每个
FormItem的key必须唯一,否则会导致值 / 错误信息存储冲突,引发状态混乱。 - 初始值类型匹配 :开关类型(
switchBtn)初始值必须为bool,输入类(文本 / 密码 / 手机号)初始值为字符串(未设置则默认空字符串),避免类型转换错误。 - 选择器值更新 :选择器类型(
selector)需通过GlobalKey手动更新_formValues,否则表单提交时无法获取最新选择值(如场景 3 示例)。 - 异步校验处理:自定义校验支持异步(如校验用户名是否已被注册),组件内部自动等待校验结果,无需额外处理线程。
- 禁用状态适配 :设置
enabled: false后,输入框、开关会自动变为禁用状态,同时拦截点击 / 输入事件,无需手动处理。
总结
优化后的FormKit以 "轻量、高效、易用" 为核心,彻底解决了原生 Flutter 表单开发的繁琐问题。无论是简单的登录表单,还是复杂的注册、配置表单,都能通过几行配置快速实现,同时保证样式统一、交互流畅。无需关注控制器管理、验证逻辑等底层细节,让开发者聚焦业务核心,大幅提升开发效率。