Flutter 通用输入框封装实战:带校验 / 清除 / 密码切换的 InputWidget

导语

输入框是 Flutter 表单开发的核心组件,但原生 TextField 存在样式不统一、校验逻辑重复、功能单一等问题 ------ 每次开发都要配置边框、校验规则、清除按钮,效率低下且易出错。本文基于原封装思路,优化通用输入框 InputWidget,强化校验灵活性、样式适配性和交互体验,集成输入校验、一键清除、密码显隐切换、焦点管理等核心功能,实现 "一次封装,全域复用",适配手机号、邮箱、密码等 90% 以上的输入场景!

一、核心需求升级拆解

在原需求基础上优化扩展,覆盖更多实际业务场景:✅ 支持手机号 / 邮箱 / 密码 / 普通文本等多类型校验,支持自定义校验规则✅ 输入内容一键清除,密码框显隐切换,交互反馈更清晰✅ 自定义提示文本、图标、颜色、圆角,适配不同设计风格✅ 支持焦点监听、最大长度限制、必填校验,满足表单规范✅ 统一输入框样式,适配暗黑模式,避免重复配置✅ 错误提示灵活控制,支持外部传入和内部校验双重模式

二、完整代码实现(带优化注释)

dart

复制代码
import 'package:flutter/material.dart';

/// 输入框类型枚举(语义化区分输入场景)
enum InputType {
  text, // 普通文本
  password, // 密码(自动隐藏+显隐切换)
  phone, // 手机号(内置正则校验)
  email, // 邮箱(内置正则校验)
  custom, // 自定义校验(外部传入校验规则)
}

/// 通用输入框组件(增强版:支持多类型校验、自定义样式、灵活交互)
class InputWidget extends StatefulWidget {
  // 必选参数
  final String hintText; // 输入提示文本
  final InputType inputType; // 输入框类型
  final ValueChanged<String> onChanged; // 输入变化回调

  // 可选参数(带合理默认值,降低使用成本)
  final Icon? prefixIcon; // 左侧图标
  final String? initialValue; // 初始值
  final bool isRequired; // 是否必填(默认false)
  final String? errorText; // 外部传入错误提示(优先级高于内部校验)
  final double borderRadius; // 圆角(默认8px,符合设计规范)
  final int? maxLength; // 最大输入长度
  final FocusNode? focusNode; // 焦点节点(用于表单焦点管理)
  final ValueChanged<bool>? onFocusChanged; // 焦点变化回调
  final Color? borderColor; // 边框颜色(默认灰色)
  final Color? focusBorderColor; // 聚焦时边框颜色(默认蓝色)
  final Color? textColor; // 输入文本颜色(默认黑色)
  final double fontSize; // 字体大小(默认16px)
  final bool enableClear; // 是否启用清除按钮(默认true)
  final String? Function(String value)? customValidator; // 自定义校验规则(InputType.custom时生效)

  const InputWidget({
    super.key,
    required this.hintText,
    required this.inputType,
    required this.onChanged,
    this.prefixIcon,
    this.initialValue,
    this.isRequired = false,
    this.errorText,
    this.borderRadius = 8.0,
    this.maxLength,
    this.focusNode,
    this.onFocusChanged,
    this.borderColor = Colors.grey,
    this.focusBorderColor = Colors.blue,
    this.textColor = Colors.black87,
    this.fontSize = 16.0,
    this.enableClear = true,
    this.customValidator,
  });

  @override
  State<InputWidget> createState() => _InputWidgetState();
}

class _InputWidgetState extends State<InputWidget> {
  late TextEditingController _controller; // 输入控制器
  bool _obscureText = true; // 密码是否隐藏
  bool _showClear = false; // 是否显示清除按钮
  bool _hasFocus = false; // 是否获取焦点

  @override
  void initState() {
    super.initState();
    // 初始化控制器,设置初始值
    _controller = TextEditingController(text: widget.initialValue);
    // 监听输入变化,控制清除按钮显示
    _controller.addListener(_onInputChanged);
    // 监听焦点变化,触发回调
    widget.focusNode?.addListener(_onFocusChanged);
    // 初始状态判断:有初始值则显示清除按钮
    _showClear = _controller.text.isNotEmpty;
  }

  @override
  void dispose() {
    // 释放资源,避免内存泄漏
    _controller.removeListener(_onInputChanged);
    widget.focusNode?.removeListener(_onFocusChanged);
    _controller.dispose();
    super.dispose();
  }

  /// 输入内容变化监听
  void _onInputChanged() {
    setState(() {
      _showClear = widget.enableClear && _controller.text.isNotEmpty;
    });
    // 触发外部输入回调
    widget.onChanged(_controller.text);
  }

  /// 焦点变化监听
  void _onFocusChanged() {
    setState(() {
      _hasFocus = widget.focusNode?.hasFocus ?? false;
    });
    // 触发外部焦点回调
    widget.onFocusChanged?.call(_hasFocus);
  }

  /// 内置校验规则(手机号/邮箱)
  String? _internalValidate(String value) {
    // 必填校验(优先级最高)
    if (widget.isRequired && value.isEmpty) {
      return '此项不能为空';
    }
    // 手机号校验
    if (widget.inputType == InputType.phone && value.isNotEmpty) {
      if (!RegExp(r'^1[3-9]\d{9}$').hasMatch(value)) {
        return '请输入正确的手机号';
      }
    }
    // 邮箱校验
    if (widget.inputType == InputType.email && value.isNotEmpty) {
      if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value)) {
        return '请输入正确的邮箱';
      }
    }
    // 自定义校验(InputType.custom时生效)
    if (widget.inputType == InputType.custom && widget.customValidator != null) {
      return widget.customValidator!(value);
    }
    return null;
  }

  /// 统一校验入口(外部错误提示优先级高于内部校验)
  String? _validateInput() {
    // 外部传入errorText则直接使用,否则执行内部校验
    return widget.errorText ?? _internalValidate(_controller.text);
  }

  /// 构建右侧图标(清除按钮/密码显隐切换)
  Widget? _buildSuffixIcon() {
    // 密码框:显隐切换图标
    if (widget.inputType == InputType.password) {
      return IconButton(
        icon: Icon(
          _obscureText ? Icons.visibility_off : Icons.visibility,
          color: Colors.grey,
          size: 20,
        ),
        onPressed: () {
          setState(() {
            _obscureText = !_obscureText;
          });
        },
        padding: EdgeInsets.zero,
        constraints: const BoxConstraints(minWidth: 40),
      );
    }
    // 普通输入框:清除按钮(需启用且有输入内容)
    if (widget.enableClear && _showClear) {
      return IconButton(
        icon: const Icon(Icons.clear, color: Colors.grey, size: 20),
        onPressed: () {
          _controller.clear();
          _onInputChanged(); // 触发输入变化回调
        },
        padding: EdgeInsets.zero,
        constraints: const BoxConstraints(minWidth: 40),
      );
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
    // 输入框样式配置(统一风格,支持自定义颜色)
    InputDecoration inputDecoration = InputDecoration(
      hintText: widget.hintText,
      hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: widget.fontSize),
      prefixIcon: widget.prefixIcon,
      suffixIcon: _buildSuffixIcon(),
      // 正常状态边框
      border: OutlineInputBorder(
        borderRadius: BorderRadius.circular(widget.borderRadius),
        borderSide: BorderSide(color: widget.borderColor!, width: 1.0),
      ),
      // 未聚焦状态边框
      enabledBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(widget.borderRadius),
        borderSide: BorderSide(color: widget.borderColor!, width: 1.0),
      ),
      // 聚焦状态边框
      focusedBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(widget.borderRadius),
        borderSide: BorderSide(color: widget.focusBorderColor!, width: 1.5),
      ),
      // 错误状态边框
      errorBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(widget.borderRadius),
        borderSide: const BorderSide(color: Colors.red, width: 1.0),
      ),
      // 聚焦错误状态边框
      focusedErrorBorder: OutlineInputBorder(
        borderRadius: BorderRadius.circular(widget.borderRadius),
        borderSide: const BorderSide(color: Colors.red, width: 1.5),
      ),
      errorText: _validateInput(),
      errorStyle: const TextStyle(fontSize: 12, height: 1.2),
      counterText: '', // 隐藏默认长度提示
      contentPadding: const EdgeInsets.symmetric(horizontal: 15, vertical: 14),
      filled: true,
      fillColor: Colors.transparent,
    );

    return TextField(
      controller: _controller,
      focusNode: widget.focusNode,
      obscureText: widget.inputType == InputType.password && _obscureText,
      // 根据输入类型配置键盘
      keyboardType: widget.inputType == InputType.phone
          ? TextInputType.phone
          : widget.inputType == InputType.email
              ? TextInputType.emailAddress
              : TextInputType.text,
      maxLength: widget.maxLength,
      style: TextStyle(
        fontSize: widget.fontSize,
        color: widget.textColor,
        height: 1.4,
      ),
      decoration: inputDecoration,
      // 禁止输入时的样式
      enabled: true,
      cursorColor: widget.focusBorderColor,
      cursorWidth: 2.0,
      cursorRadius: Radius.circular(1.0),
    );
  }
}

三、丰富使用示例(覆盖全场景)

dart

复制代码
class InputWidgetDemo extends StatelessWidget {
  const InputWidgetDemo({super.key});

  @override
  Widget build(BuildContext context) {
    // 焦点管理(用于表单提交时自动聚焦错误输入框)
    final phoneFocus = FocusNode();
    final passwordFocus = FocusNode();
    final customFocus = FocusNode();

    return Scaffold(
      appBar: AppBar(title: const Text('通用输入框示例')),
      body: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 15),
        child: Column(
          children: [
            // 1. 手机号输入框(必填+内置校验)
            InputWidget(
              hintText: '请输入手机号',
              inputType: InputType.phone,
              prefixIcon: const Icon(Icons.phone, color: Colors.blue),
              isRequired: true,
              focusNode: phoneFocus,
              onFocusChanged: (hasFocus) {
                debugPrint('手机号输入框焦点:$hasFocus');
              },
              onChanged: (value) => debugPrint('手机号:$value'),
            ),
            const SizedBox(height: 12),

            // 2. 密码输入框(最大长度+显隐切换)
            InputWidget(
              hintText: '请输入密码(6-16位)',
              inputType: InputType.password,
              prefixIcon: const Icon(Icons.lock, color: Colors.grey),
              isRequired: true,
              maxLength: 16,
              focusNode: passwordFocus,
              borderColor: Colors.grey.shade300,
              focusBorderColor: Colors.green,
              onChanged: (value) => debugPrint('密码:$value'),
            ),
            const SizedBox(height: 12),

            // 3. 邮箱输入框(外部错误提示)
            InputWidget(
              hintText: '请输入邮箱',
              inputType: InputType.email,
              prefixIcon: const Icon(Icons.email, color: Colors.orange),
              errorText: '邮箱格式错误', // 外部传入错误提示(例如接口返回的错误)
              onChanged: (value) => debugPrint('邮箱:$value'),
            ),
            const SizedBox(height: 12),

            // 4. 普通文本输入框(禁用清除按钮)
            InputWidget(
              hintText: '请输入昵称',
              inputType: InputType.text,
              prefixIcon: const Icon(Icons.person, color: Colors.purple),
              enableClear: false,
              textColor: Colors.purple.shade700,
              onChanged: (value) => debugPrint('昵称:$value'),
            ),
            const SizedBox(height: 12),

            // 5. 自定义校验输入框(例如验证码)
            InputWidget(
              hintText: '请输入6位验证码',
              inputType: InputType.custom,
              prefixIcon: const Icon(Icons.verified_user, color: Colors.red),
              isRequired: true,
              maxLength: 6,
              focusNode: customFocus,
              customValidator: (value) {
                if (value.length != 6) {
                  return '请输入6位验证码';
                }
                if (!RegExp(r'^\d{6}$').hasMatch(value)) {
                  return '验证码仅支持数字';
                }
                return null;
              },
              onChanged: (value) => debugPrint('验证码:$value'),
            ),
          ],
        ),
      ),
    );
  }
}

四、优化亮点与核心技巧

1. 功能增强(解决原封装痛点)

  • 自定义校验支持 :新增InputType.custom类型,通过customValidator传入自定义校验规则,适配验证码、身份证等特殊场景。
  • 焦点管理优化 :支持焦点监听回调onFocusChanged,便于表单提交时自动聚焦错误输入框,提升用户体验。
  • 样式全自定义:可配置边框颜色、聚焦颜色、文本颜色等,适配不同设计风格,无需修改组件源码。
  • 错误提示灵活 :支持外部传入errorText(如接口返回错误)和内部校验双重模式,优先级清晰。

2. 交互体验升级

  • 清除按钮优化 :添加enableClear开关,支持禁用清除功能;图标尺寸和间距优化,视觉更协调。
  • 焦点样式强化:聚焦时边框加粗(1.5px),错误状态显示红色边框,视觉反馈更清晰。
  • 输入细节优化:配置光标颜色、宽度和圆角,统一输入文本行高,提升视觉一致性。

3. 封装核心方法论

  • 状态内聚:密码显隐、清除按钮显示、焦点状态等均封装在组件内部,外部无需关心实现细节,仅需通过参数配置。
  • 校验分层:内置常用校验规则(手机号 / 邮箱),同时支持自定义校验,兼顾通用性和灵活性。
  • 样式统一:固定内边距、圆角默认值,提供颜色配置接口,既保证样式一致性,又支持个性化调整。
  • 生命周期安全 :及时释放TextEditingController和焦点监听,避免内存泄漏;初始化时处理初始值,状态更稳定。

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

常见问题 表现形式 解决方案
清除按钮不显示 输入内容后仍无清除图标 检查enableClear是否为true;确认输入框类型不是密码框(密码框优先显示显隐切换图标)
自定义校验不生效 输入内容后未触发自定义校验 确保输入框类型设为InputType.customcustomValidator参数已正确传入校验函数
焦点回调无响应 聚焦 / 失焦时未触发onFocusChanged 需传入自定义FocusNode;确保未在外部手动移除焦点监听
错误提示重叠 错误文本与输入内容距离过近 无需手动调整,组件已设置errorStyleheight属性,保证间距合理
密码框显隐切换无效 点击图标后密码仍未显示 / 隐藏 确认输入框类型为InputType.password;未手动设置obscureText属性覆盖组件内部状态

总结

优化后的 InputWidget 不仅保留了原封装的核心功能,还通过自定义校验、焦点管理、样式适配等增强,覆盖了更多实际业务场景。其核心优势在于 "通用性强、配置灵活、体验优秀"------ 既减少了重复代码,又保证了样式和交互的一致性,是 Flutter 表单开发的必备组件。

相关推荐
2501_915909061 小时前
Fiddler抓包与接口调试实战,HTTPHTTPS配置、代理设置与移动端抓包详解
前端·测试工具·ios·小程序·fiddler·uni-app·webview
我命由我123452 小时前
微信小程序开发 - 为 tap 事件的处理函数传递数据
开发语言·前端·javascript·微信小程序·小程序·前端框架·js
百万蹄蹄向前冲5 小时前
Trae Genimi3跟着官网学实时通信 Socket.io框架
前端·后端·websocket
狂炫冰美式6 小时前
TRAE SOLO 驱动:重构AI模拟面试产品的复盘
前端·后端·面试
1024肥宅8 小时前
JavaScript 拷贝全解析:从浅拷贝到深拷贝的完整指南
前端·javascript·ecmascript 6
欧阳天风8 小时前
js实现鼠标横向滚动
开发语言·前端·javascript
局i9 小时前
Vue 指令详解:v-for、v-if、v-show 与 {{}} 的妙用
前端·javascript·vue.js
码界奇点9 小时前
Java Web学习 第15篇jQuery从入门到精通的万字深度解析
java·前端·学习·jquery
小鑫同学10 小时前
Alias Assistant:新一代 macOS Shell 别名管理解决方案
前端·前端工程化