在 Flutter 开发中,表单输入(登录、注册、设置页)是高频场景。原生TextField存在样式配置繁琐、校验逻辑分散、交互反馈单一等问题。本文封装的CustomInputWidget整合 “统一样式 + 实时校验 + 输入格式化 + 交互反馈” 四大核心能力,支持手机号、密码、验证码等 10+ 场景,一行代码调用,覆盖 90%+ 表单需求。
一、核心优势
- 样式统一:支持边框 / 下划线两种风格,颜色、圆角可统一配置,无需重复写样式
- 实时校验:内置手机号、邮箱、密码等校验规则,支持自定义校验,实时反馈错误
- 输入格式化:手机号(3-4-4 分隔)、金额(保留两位小数)等自动处理,提升体验
- 交互优化:密码可见切换、清除输入、图标点击,聚焦 / 错误状态高亮
- 高扩展:左侧图标、右侧自定义组件(如验证码倒计时)可灵活嵌入
二、核心配置速览
| 配置分类 | 核心参数 | 核心作用 |
|---|---|---|
| 必选配置 | controller、hintText | 输入控制器、占位提示文本 |
| 样式配置 | borderType、focusColor、inputStyle | 边框风格、聚焦颜色、文本样式 |
| 校验配置 | inputType、validator、errorText | 输入类型、自定义校验、错误提示 |
| 交互配置 | isPassword、showClearBtn、onIconTap | 密码类型、清除按钮、图标点击 |
| 扩展配置 | prefixIcon、suffixWidget、formatter | 左侧图标、右侧组件、输入格式化 |
三、完整代码(可直接复制)
dart
import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; /// 输入框边框类型枚举 enum InputBorderType { outline, underline, none } /// 预设输入类型枚举 enum InputType { normal, phone, email, password, code, idCard, number } /// 通用表单输入组件 class CustomInputWidget extends StatefulWidget { // 必选参数 final TextEditingController controller; final String hintText; // 样式配置 final InputBorderType borderType; final TextStyle inputStyle; final TextStyle hintStyle; final Color focusColor; final Color normalColor; final Color errorColor; final double borderWidth; final double borderRadius; final EdgeInsetsGeometry padding; // 校验配置 final InputType inputType; final String? errorText; final String? Function(String?)? validator; final bool autoValidate; // 交互配置 final bool isPassword; final bool showClearBtn; final bool enabled; final bool readOnly; final int maxLength; final int maxLines; // 扩展配置 final Widget? prefixIcon; final VoidCallback? onIconTap; final Widget? suffixWidget; final List<TextInputFormatter>? formatter; final ValueChanged<String>? onChanged; final VoidCallback? onEditingComplete; // 适配配置 final bool adaptDarkMode; const CustomInputWidget({ super.key, required this.controller, required this.hintText, this.borderType = InputBorderType.outline, this.inputStyle = const TextStyle(fontSize: 16, color: Colors.black87), this.hintStyle = const TextStyle(fontSize: 16, color: Colors.grey), this.focusColor = Colors.blue, this.normalColor = Colors.grey, this.errorColor = Colors.redAccent, this.borderWidth = 1.0, this.borderRadius = 8.0, this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 14), this.inputType = InputType.normal, this.errorText, this.validator, this.autoValidate = true, this.isPassword = false, this.showClearBtn = true, this.enabled = true, this.readOnly = false, this.maxLength = 100, this.maxLines = 1, this.prefixIcon, this.onIconTap, this.suffixWidget, this.formatter, this.onChanged, this.onEditingComplete, this.adaptDarkMode = true, }); @override State<CustomInputWidget> createState() => _CustomInputWidgetState(); } class _CustomInputWidgetState extends State<CustomInputWidget> { late FocusNode _focusNode; bool _isFocused = false; bool _showPassword = false; bool _showClearBtn = false; String? _currentErrorText; // 预设输入格式化器 List<TextInputFormatter> get _defaultFormatters { switch (widget.inputType) { case InputType.phone: return [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(13), _PhoneInputFormatter()]; case InputType.code: return [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)]; case InputType.idCard: return [FilteringTextInputFormatter.allow(RegExp(r'[0-9Xx]')), LengthLimitingTextInputFormatter(18)]; case InputType.number: return [_NumberInputFormatter()]; default: return [LengthLimitingTextInputFormatter(widget.maxLength)]; } } // 预设校验规则 String? _defaultValidator(String? value) { if (value == null || value.trim().isEmpty) return "请输入${_getInputTypeName()}"; switch (widget.inputType) { case InputType.phone: final purePhone = value.replaceAll(RegExp(r'\D'), ''); return purePhone.length != 11 ? "请输入11位有效手机号" : null; case InputType.email: final emailReg = RegExp(r'^[\w-]+(\.[\w-]+)*@[\w-]+(\.[\w-]+)+$'); return emailReg.hasMatch(value) ? null : "请输入有效邮箱地址"; case InputType.password: if (value.length < 6 || value.length > 20) return "密码长度为6-20位"; final hasLetter = RegExp(r'[a-zA-Z]').hasMatch(value); final hasNumber = RegExp(r'[0-9]').hasMatch(value); return (hasLetter && hasNumber) ? null : "密码需包含字母和数字"; case InputType.code: return (value.length >=4 && value.length <=6) ? null : "请输入4-6位验证码"; case InputType.idCard: final idReg = RegExp(r'^\d{17}[\dXx]$'); return idReg.hasMatch(value) ? null : "请输入18位有效身份证号"; default: return null; } } String _getInputTypeName() { switch (widget.inputType) { case InputType.phone: return "手机号"; case InputType.email: return "邮箱"; case InputType.password: return "密码"; case InputType.code: return "验证码"; case InputType.idCard: return "身份证号"; case InputType.number: return "数字"; default: return "内容"; } } void _validateInput(String value) { if (!widget.autoValidate) return; String? error = widget.validator?.call(value) ?? _defaultValidator(value); setState(() => _currentErrorText = error); } @override void initState() { super.initState(); _focusNode = FocusNode()..addListener(() => setState(() => _isFocused = _focusNode.hasFocus)); widget.controller.addListener(() { final value = widget.controller.text; setState(() => _showClearBtn = widget.showClearBtn && value.isNotEmpty && !widget.isPassword); _validateInput(value); widget.onChanged?.call(value); }); _validateInput(widget.controller.text); } @override void didUpdateWidget(covariant CustomInputWidget oldWidget) { super.didUpdateWidget(oldWidget); if (widget.errorText != oldWidget.errorText) setState(() => _currentErrorText = widget.errorText); } @override void dispose() { _focusNode.dispose(); super.dispose(); } Color _adaptDarkMode(Color lightColor, Color darkColor) { if (!widget.adaptDarkMode) return lightColor; return MediaQuery.platformBrightnessOf(context) == Brightness.dark ? darkColor : lightColor; } InputBorder _buildBorder() { final currentColor = _currentErrorText != null ? _adaptDarkMode(widget.errorColor, Colors.red[400]!) : (_isFocused ? _adaptDarkMode(widget.focusColor, Colors.blueAccent) : _adaptDarkMode(widget.normalColor, Colors.grey[600]!)); switch (widget.borderType) { case InputBorderType.outline: return OutlineInputBorder(borderSide: BorderSide(color: currentColor, width: widget.borderWidth), borderRadius: BorderRadius.circular(widget.borderRadius)); case InputBorderType.underline: return UnderlineInputBorder(borderSide: BorderSide(color: currentColor, width: widget.borderWidth)); case InputBorderType.none: return InputBorder.none; } } Widget? _buildSuffixWidget() { if (widget.suffixWidget != null) return widget.suffixWidget; if (widget.isPassword) { return IconButton( icon: Icon(_showPassword ? Icons.visibility : Icons.visibility_off, size: 20, color: _adaptDarkMode(widget.normalColor, Colors.grey[400]!)), onPressed: () => setState(() => _showPassword = !_showPassword), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 40), ); } if (_showClearBtn) { return IconButton( icon: Icon(Icons.clear, size: 20, color: _adaptDarkMode(widget.normalColor, Colors.grey[400]!)), onPressed: () => widget.controller.clear(), padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 40), ); } return null; } @override Widget build(BuildContext context) { final formatters = [..._defaultFormatters, if (widget.formatter != null) ...widget.formatter!]; final adaptedInputStyle = widget.inputStyle.copyWith(color: _adaptDarkMode(widget.inputStyle.color!, Colors.white70)); final adaptedHintStyle = widget.hintStyle.copyWith(color: _adaptDarkMode(widget.hintStyle.color!, Colors.grey[400]!)); return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: widget.controller, focusNode: _focusNode, style: adaptedInputStyle, hintText: widget.hintText, hintStyle: adaptedHintStyle, obscureText: widget.isPassword && !_showPassword, enabled: widget.enabled, readOnly: widget.readOnly, maxLines: widget.maxLines, inputFormatters: formatters, keyboardType: _getKeyboardType(), decoration: InputDecoration( prefixIcon: widget.prefixIcon != null ? GestureDetector(onTap: widget.onIconTap, child: Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: widget.prefixIcon)) : null, suffixIcon: _buildSuffixWidget(), border: _buildBorder(), focusedBorder: _buildBorder(), enabledBorder: _buildBorder(), disabledBorder: _buildBorder(), errorBorder: _buildBorder(), focusedErrorBorder: _buildBorder(), contentPadding: widget.padding, isDense: true, errorText: _currentErrorText, errorStyle: TextStyle(fontSize: 12, color: _adaptDarkMode(widget.errorColor, Colors.red[400]!)), errorMaxLines: 2, ), onEditingComplete: widget.onEditingComplete, ), ], ); } TextInputType _getKeyboardType() { switch (widget.inputType) { case InputType.phone: case InputType.code: return TextInputType.phone; case InputType.email: return TextInputType.emailAddress; case InputType.number: return TextInputType.numberWithOptions(decimal: true); default: return TextInputType.text; } } } /// 手机号格式化器(3-4-4分隔) class _PhoneInputFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { final text = newValue.text.replaceAll(RegExp(r'\D'), ''); final buffer = StringBuffer(); for (int i = 0; i < text.length; i++) { buffer.write(text[i]); if (i == 2 || i == 6) if (i != text.length - 1) buffer.write('-'); } final value = buffer.toString(); return newValue.copyWith(text: value, selection: TextSelection.collapsed(offset: value.length)); } } /// 数字格式化器(保留两位小数) class _NumberInputFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) { String value = newValue.text; if (!RegExp(r'^[\d.]*$').hasMatch(value)) return oldValue; if (value.contains('.') && value.indexOf('.') != value.lastIndexOf('.')) return oldValue; if (value.contains('.')) { final parts = value.split('.'); if (parts.length > 1 && parts[1].length > 2) value = '${parts[0]}.${parts[1].substring(0, 2)}'; } if (value.startsWith('.')) value = '0$value'; return newValue.copyWith(text: value, selection: TextSelection.collapsed(offset: value.length)); } }四、三大高频场景示例
场景 1:登录页(手机号 + 密码)
dart
class LoginPage extends StatefulWidget { @override State<LoginPage> createState() => _LoginPageState(); } class _LoginPageState extends State<LoginPage> { final TextEditingController _phoneController = TextEditingController(); final TextEditingController _pwdController = TextEditingController(); bool _isLoginEnabled = false; @override void initState() { super.initState(); _phoneController.addListener(_checkLoginEnable); _pwdController.addListener(_checkLoginEnable); } void _checkLoginEnable() { final phoneValid = _phoneController.text.replaceAll(RegExp(r'\D'), '').length == 11; final pwdValid = _pwdController.text.length >=6 && _pwdController.text.length <=20; setState(() => _isLoginEnabled = phoneValid && pwdValid); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("登录")), body: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Column( children: [ CustomInputWidget( controller: _phoneController, hintText: "请输入手机号", inputType: InputType.phone, prefixIcon: const Icon(Icons.phone, color: Colors.blue), borderType: InputBorderType.underline, ), const SizedBox(height: 20), CustomInputWidget( controller: _pwdController, hintText: "请输入密码", inputType: InputType.password, isPassword: true, prefixIcon: const Icon(Icons.lock, color: Colors.blue), borderType: InputBorderType.underline, ), const SizedBox(height: 40), SizedBox( width: double.infinity, child: ElevatedButton( onPressed: _isLoginEnabled ? () => debugPrint("执行登录") : null, child: const Text("登录"), ), ), ], ), ), ); } @override void dispose() { _phoneController.dispose(); _pwdController.dispose(); super.dispose(); } }场景 2:验证码输入(带倒计时)
dart
class CodeInputPage extends StatefulWidget { @override State<CodeInputPage> createState() => _CodeInputPageState(); } class _CodeInputPageState extends State<CodeInputPage> { final TextEditingController _codeController = TextEditingController(); bool _isCounting = false; int _countDown = 60; void _getCode() { setState(() => _isCounting = true); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("验证码已发送"))); Timer.periodic(const Duration(seconds: 1), (timer) { setState(() { _countDown--; if (_countDown <= 0) { _isCounting = false; _countDown = 60; timer.cancel(); } }); }); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("验证手机号")), body: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Column( children: [ CustomInputWidget( controller: _codeController, hintText: "请输入4位验证码", inputType: InputType.code, prefixIcon: const Icon(Icons.sms, color: Colors.orangeAccent), suffixWidget: Padding( padding: const EdgeInsets.only(right: 8), child: TextButton( onPressed: _isCounting ? null : _getCode, child: Text(_isCounting ? "$_countDown秒后重发" : "获取验证码"), ), ), ), const SizedBox(height: 30), ElevatedButton( onPressed: _codeController.text.length ==4 ? () => Navigator.pop(context) : null, child: const Text("验证"), ), ], ), ), ); } }场景 3:金额输入(带格式化)
dart
class AmountInputPage extends StatefulWidget { @override State<AmountInputPage> createState() => _AmountInputPageState(); } class _AmountInputPageState extends State<AmountInputPage> { final TextEditingController _amountController = TextEditingController(); final double _maxAmount = 10000.0; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text("充值")), body: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text("充值金额", style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500)), const SizedBox(height: 12), CustomInputWidget( controller: _amountController, hintText: "最多10000元", inputType: InputType.number, prefixIcon: const Icon(Icons.money, color: Colors.green), validator: (value) { if (value == null || value.isEmpty) return "请输入金额"; final amount = double.tryParse(value) ?? 0; if (amount < 0.01) return "最小0.01元"; if (amount > _maxAmount) return "最大10000元"; return null; }, ), const SizedBox(height: 20), Wrap( spacing: 12, children: [100, 500, 1000, 2000, 5000, 10000] .map((amount) => GestureDetector( onTap: () => _amountController.text = amount.toString(), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(border: Border.all(color: Colors.grey[300]!), borderRadius: BorderRadius.circular(8)), child: Text("¥$amount"), ), )) .toList(), ), const Spacer(), SizedBox( width: double.infinity, child: ElevatedButton( onPressed: _amountController.text.isNotEmpty && double.parse(_amountController.text) >=0.01 ? () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("充值¥${_amountController.text}"))) : null, child: const Text("确认充值"), ), ), ], ), ), ); } }五、核心封装技巧
- 分层校验:预设规则 + 自定义校验,自定义优先级更高,兼顾通用与灵活
- 输入格式化:通过
TextInputFormatter自动处理手机号、金额格式 - 状态联动:内部监听输入与聚焦状态,自动切换组件显示
- 插槽设计:右侧支持自定义组件,适配倒计时等复杂场景
- 深色适配:所有颜色通过
_adaptDarkMode统一适配,无需额外配置
六、避坑指南
- 控制器管理:外部传入控制器并手动
dispose,避免内存泄漏 - 校验触发:
autoValidate: false时,需手动校验后提交 - 密码状态:
isPassword: true后,内部自动管理可见性,无需外部维护 - 格式化冲突:外部
formatter与预设合并,避免重复限制 - 错误提示:
errorText外部传入优先级高于内部校验,适合接口错误提示
https://openharmonycrossplatform.csdn.net/content