news 2026/4/18 3:33:05

Flutter 通用表单输入组件 CustomInputWidget:校验 + 样式 + 交互一键适配

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Flutter 通用表单输入组件 CustomInputWidget:校验 + 样式 + 交互一键适配

在 Flutter 开发中,表单输入(登录、注册、设置页)是高频场景。原生TextField存在样式配置繁琐、校验逻辑分散、交互反馈单一等问题。本文封装的CustomInputWidget整合 “统一样式 + 实时校验 + 输入格式化 + 交互反馈” 四大核心能力,支持手机号、密码、验证码等 10+ 场景,一行代码调用,覆盖 90%+ 表单需求。

一、核心优势

  1. 样式统一:支持边框 / 下划线两种风格,颜色、圆角可统一配置,无需重复写样式
  2. 实时校验:内置手机号、邮箱、密码等校验规则,支持自定义校验,实时反馈错误
  3. 输入格式化:手机号(3-4-4 分隔)、金额(保留两位小数)等自动处理,提升体验
  4. 交互优化:密码可见切换、清除输入、图标点击,聚焦 / 错误状态高亮
  5. 高扩展:左侧图标、右侧自定义组件(如验证码倒计时)可灵活嵌入

二、核心配置速览

配置分类核心参数核心作用
必选配置controllerhintText输入控制器、占位提示文本
样式配置borderTypefocusColorinputStyle边框风格、聚焦颜色、文本样式
校验配置inputTypevalidatorerrorText输入类型、自定义校验、错误提示
交互配置isPasswordshowClearBtnonIconTap密码类型、清除按钮、图标点击
扩展配置prefixIconsuffixWidgetformatter左侧图标、右侧组件、输入格式化

三、完整代码(可直接复制)

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("确认充值"), ), ), ], ), ), ); } }

五、核心封装技巧

  1. 分层校验:预设规则 + 自定义校验,自定义优先级更高,兼顾通用与灵活
  2. 输入格式化:通过TextInputFormatter自动处理手机号、金额格式
  3. 状态联动:内部监听输入与聚焦状态,自动切换组件显示
  4. 插槽设计:右侧支持自定义组件,适配倒计时等复杂场景
  5. 深色适配:所有颜色通过_adaptDarkMode统一适配,无需额外配置

六、避坑指南

  1. 控制器管理:外部传入控制器并手动dispose,避免内存泄漏
  2. 校验触发:autoValidate: false时,需手动校验后提交
  3. 密码状态:isPassword: true后,内部自动管理可见性,无需外部维护
  4. 格式化冲突:外部formatter与预设合并,避免重复限制
  5. 错误提示:errorText外部传入优先级高于内部校验,适合接口错误提示

https://openharmonycrossplatform.csdn.net/content

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/4/18 5:20:48

DETR技术2025商业落地全景:从工业质检到智能零售的范式革新

DETR技术2025商业落地全景&#xff1a;从工业质检到智能零售的范式革新 【免费下载链接】detr-resnet-50 项目地址: https://ai.gitcode.com/hf_mirrors/facebook/detr-resnet-50 导语 DETR&#xff08;Detection Transformer&#xff09;目标检测技术正通过边缘计算优…

作者头像 李华
网站建设 2026/4/17 21:19:01

Nord调色板全球化色彩管理:构建跨文化用户体验的技术方案

Nord调色板全球化色彩管理&#xff1a;构建跨文化用户体验的技术方案 【免费下载链接】nord An arctic, north-bluish color palette. 项目地址: https://gitcode.com/gh_mirrors/no/nord 在当今数字化产品的全球化部署中&#xff0c;Nord调色板作为北极风蓝绿色配色方案…

作者头像 李华
网站建设 2026/4/17 10:23:04

CosyVoice语音生成模型部署实战:从零构建高可用语音服务

想象一下&#xff0c;你正在为一个智能客服项目部署语音合成系统&#xff0c;却在ONNX模型加载环节频频碰壁。这种困扰是否似曾相识&#xff1f;&#x1f914; 本文将带你走进CosyVoice语音生成模型的世界&#xff0c;通过真实场景剖析&#xff0c;掌握从环境搭建到生产部署的全…

作者头像 李华
网站建设 2026/4/18 6:09:09

解密DolphinScheduler:如何用分布式调度系统彻底解决你的数据处理瓶颈

解密DolphinScheduler&#xff1a;如何用分布式调度系统彻底解决你的数据处理瓶颈 【免费下载链接】dolphinscheduler Dolphinscheduler是一个分布式调度系统&#xff0c;主要用于任务调度和流程编排。它的特点是易用性高、可扩展性强、性能稳定等。适用于任务调度和流程自动化…

作者头像 李华
网站建设 2026/4/18 7:01:09

39、编程与开发相关知识及环境搭建与许可协议解读

编程与开发相关知识及环境搭建与许可协议解读 编程术语解释 在编程领域,有许多重要的术语和概念,下面为大家详细介绍一些常见的术语。 - SVG :一种基于 XML 文档类型的开放标准矢量图形格式,常用于创建可缩放的图形。 - 同步(synchronous) :使用同步 I/O 操作的程…

作者头像 李华