Flutter 通用表单组件封装:FormKit 一站式解决表单验证、状态管理与交互优化

原生 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]!,
      ),
    );
  }
}

四、核心封装技巧(快速掌握设计思路)

  1. 自动控制器管理 :内部缓存TextEditingController,初始化时创建、销毁时释放,开发者无需手动处理,彻底避免 "忘记 dispose" 导致的内存泄漏。
  2. 验证逻辑解耦 :通过FormRule统一封装校验规则,支持 "必填 + 正则 + 自定义" 组合校验,新增校验场景时只需扩展FormRule,无需修改组件核心逻辑。
  3. 状态实时同步 :输入变化、开关切换时自动更新_formValues,并触发实时验证,错误提示即时反馈,提升用户体验。
  4. UI 细节优化:分割线与标签对齐、输入框隐藏默认边框、开关缩小点击区域,默认样式贴合主流设计,减少自定义成本。
  5. 灵活扩展设计 :支持自定义标签 / 输入文本样式、分割线颜色、表单项间距,同时保留原生表单的扩展性(如onChanged回调)。

五、避坑指南(实际开发必看)

  1. 表单项 key 唯一性 :每个FormItemkey必须唯一,否则会导致值 / 错误信息存储冲突,引发状态混乱。
  2. 初始值类型匹配 :开关类型(switchBtn)初始值必须为bool,输入类(文本 / 密码 / 手机号)初始值为字符串(未设置则默认空字符串),避免类型转换错误。
  3. 选择器值更新 :选择器类型(selector)需通过GlobalKey手动更新_formValues,否则表单提交时无法获取最新选择值(如场景 3 示例)。
  4. 异步校验处理:自定义校验支持异步(如校验用户名是否已被注册),组件内部自动等待校验结果,无需额外处理线程。
  5. 禁用状态适配 :设置enabled: false后,输入框、开关会自动变为禁用状态,同时拦截点击 / 输入事件,无需手动处理。

总结

优化后的FormKit以 "轻量、高效、易用" 为核心,彻底解决了原生 Flutter 表单开发的繁琐问题。无论是简单的登录表单,还是复杂的注册、配置表单,都能通过几行配置快速实现,同时保证样式统一、交互流畅。无需关注控制器管理、验证逻辑等底层细节,让开发者聚焦业务核心,大幅提升开发效率。

https://openharmonycrossplatform.csdn.net/content

相关推荐
ujainu3 小时前
Flutter性能优化实战:从卡顿排查到极致流畅
flutter·性能优化
阿巴~阿巴~3 小时前
HTTP服务器实现请求解析与响应构建:从基础架构到动态交互
服务器·网络·网络协议·http·交互·请求解析·响应构建
克喵的水银蛇3 小时前
Flutter 通用下拉刷新上拉加载列表:PullRefreshList
flutter·下拉刷新·组件封装·上拉加载
帅气马战的账号3 小时前
开源鸿蒙+Flutter:分布式协同驱动的全场景跨端开发新范式
flutter
帅气马战的账号3 小时前
开源鸿蒙+Flutter进阶实战:跨端融合与原生能力无缝调用新方案
flutter
晚霞的不甘3 小时前
Flutter + OpenHarmony 自动化测试全攻略:从单元测试到多设备真机云测
flutter·单元测试
ujainu3 小时前
Flutter性能优化实战:从卡顿到丝滑的全方案
flutter·性能优化
克喵的水银蛇3 小时前
Flutter 通用网络图片加载组件:ImageLoaderWidget 解决加载痛点
flutter
寒季6663 小时前
Flutter 智慧零售门店服务平台:跨端协同打造全渠道消费体验
flutter