开场:输入框是一切的开始
写 Flutter 应用绕不开一个场景:让用户输入点什么。不管是登录页的账号密码、注册页的昵称邮箱,还是搜索框、评论框、设置页的修改项,底层都是同一个组件——TextField。很多零基础的同学学到这儿会有点懵,感觉 TextField 属性一大堆,什么 controller、decoration、onChanged、onSubmitted,看着就头大。再加上和 Form 表单联动做校验时,又冒出来 TextFormField、GlobalKey、validator 这些新概念,直接劝退。
这篇文章我尽量用大白话把 TextField 拆开讲透。不管你是刚看完 Dart 语法、准备写第一个带输入功能页面的新手,还是已经写过几个页面、想系统理清表单这块儿的初级开发者,都适合往下看。我会从最简单的单行输入框开始,一步步做到带校验、带提交逻辑的完整表单,最后再聊几个实际开发里最容易踩的坑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
1. 从零开始写一个输入框
1.1 最朴素的用法:先跑起来再说
直接用 TextField 就能在页面上放一个基础输入框,没有任何多余的配置:
dart复制TextField(
decoration: InputDecoration(
hintText: '请输入手机号',
),
)
这段代码放到 Scaffold 的 body 里就能看到效果——一个底部带下划线的输入区域,里面是灰色的提示文字。hintText 是输入框没内容时显示的占位提示,用户一开始输入它就消失,这个机制和 HTML 里 <input> 的 placeholder 是一样的逻辑。
这里有个新手最容易忽略的点:TextField 自己是不会保存你输入的内容的。它只负责“显示”和“接收”输入,如果你不主动去读取,输入完字迹一关页面,数据就没了。要取到用户输入的内容,有两条路:
- 用
onChanged回调实时监听每一次输入; - 用
TextEditingController绑定控制器,随时主动取值。
日常开发里 99% 的场景都用第二种,因为 controller 不仅能在需要的时候取到完整内容,还能用它去设置初始值、清空输入框、定位光标,比 onChanged 灵活得多。
1.2 核心属性逐个拆解
我把平时写得最多的几个属性拉出来讲,每一张参数表都对应一个真实需求场景:
| 属性 | 作用 | 典型场景 |
|---|---|---|
controller |
绑定输入控制器,用于取值/赋值 | 几乎所有场景 |
keyboardType |
调起哪种键盘 | 数字键盘、邮箱键盘、普通键盘 |
obscureText |
是否密文显示 | 密码输入框 |
maxLength |
最大输入长度 | 手机号、验证码 |
maxLines |
最大行数,null 表示不限 | 评论框、多行文本 |
readOnly |
只读不可编辑 | 展示数据 + 点击复制 |
enabled |
是否可用 | 表单提交中暂时禁用 |
inputFormatters |
限制输入格式 | 只允许数字、金额格式 |
拿登录页面来举例,账号输入框和密码输入框的差异就在于 keyboardType 和 obscureText:
dart复制// 账号
TextField(
controller: _accountController,
keyboardType: TextInputType.number, // 手机号场景,直接弹数字键盘
maxLength: 11,
decoration: InputDecoration(
hintText: '请输入手机号',
counterText: '', // 去掉右下角的字数统计,很多人第一步就坑在这
),
)
// 密码
TextField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(
hintText: '请输入密码',
),
)
这里我想多说一句 maxLength。它的默认行为不只是限制输入长度,还会在输入框右下角显示“5/11”这种当前字数统计标签。如果 UI 稿上没这个计数器,你加了 maxLength 又没写 counterText: '',视觉上会多出一行小字,对不齐设计稿,评测系统里也容易被视觉项目扣分。
1.3 让输入框更好看的 decoration 技巧
decoration 接收一个 InputDecoration 对象,它负责输入框的一切视觉表现——边框、填充色、图标、标签文字都在这里配。新手容易把 hintText 和 labelText 搞混,看效果就能分清:
hintText:输入框为空时显示的灰色提示,一输入就消失;labelText:永远在输入框上方或边框上显示的标签,输入后有动画上移。
实际开发里,如果设计稿有占位提示文字,用 hintText 就够了,labelText 更适合材料风格的表单。再说一个很常用的组合:给输入框加左侧图标和圆角边框。
dart复制TextField(
decoration: InputDecoration(
hintText: '输入用户名',
prefixIcon: Icon(Icons.person),
filled: true, // 有底色
fillColor: Colors.grey[200],
border: OutlineInputBorder( // 圆角边框
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none, // 去掉默认边框线
),
),
)
filled: true 配合 fillColor 是做灰色圆角输入区域的标配,borderSide: BorderSide.none 保证默认情况下不会出现黑线,这个写法在很多后台管理界面和移动端 App 里都非常常见。
2. 用 Form 做表单,把多个输入框管起来
2.1 为什么不用多个 TextField 自己存?
很多新手写登录页的时候,会写两个 TextField,一个绑一个 controller,然后提交按钮的 onPressed 里手动读两个 controller 的值,再自己判断非空、格式对不对。这当然也能跑,但一旦表单字段多了,代码就会变成一个巨型函数,而且校验逻辑散落在各个地方,维护起来很痛苦。
Flutter 官方给的方案是 Form 组件,它专门解决“多个输入框的表单”问题。Form 本身不渲染任何可见界面,它是一个逻辑容器,管三件事:
- 统一管理
TextFormField的校验; - 统一触发表单验证(
validate()); - 统一保存表单数据(
save())。
这样你提交按钮的逻辑就变成:调 validate() 检查所有字段,全部通过再存值、提交,校验失败自动聚焦到第一个错误字段并显示错误提示,完全不用自己写那套“逐字段判断 + 手动弹 Toast”的逻辑。
2.2 TextFormField 和 TextField 区别在哪
TextFormField 是 TextField 的表单专用版本,它在内部直接包了一层 TextField,额外多出了 validator 和 FormField 相关的能力。结构上你可以理解为:
TextField是“普通输入框”TextFormField是“会校验的输入框”,必须写在Form里面才能发挥完整作用
换成 TextFormField 后,基础写法和 TextField 几乎一模一样,只是多了一个 validator 参数。所以对新手来说,只要你的输入框是放在表单页里的,直接默认用 TextFormField 就好,免得到后面再加校验还得把组件换一遍。
2.3 一个完整的登录表单示例
下面这个例子是实际开发里非常典型的登录/注册表单结构,我加了注释方便你照着抄:
dart复制class _LoginPageState extends State<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _accountController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
// 控制器的释放很重要,不然会有内存泄漏警告
_accountController.dispose();
_passwordController.dispose();
super.dispose();
}
void _login() {
// 先收起键盘,体验细节
FocusScope.of(context).unfocus();
if (_formKey.currentState!.validate()) {
// 校验通过,取数据做登录请求
String account = _accountController.text;
String password = _passwordController.text;
// 这里执行真实的登录请求
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _accountController,
keyboardType: TextInputType.number,
maxLength: 11,
decoration: InputDecoration(
hintText: '请输入手机号',
counterText: '',
),
validator: (value) {
if (value == null || value.isEmpty) {
return '手机号不能为空';
}
if (value.length != 11) {
return '手机号长度应为11位';
}
return null;
},
),
TextFormField(
controller: _passwordController,
obscureText: true,
decoration: InputDecoration(hintText: '请输入密码'),
validator: (value) {
if (value == null || value.isEmpty) {
return '密码不能为空';
}
if (value.length < 6) {
return '密码长度不能少于6位';
}
return null;
},
),
SizedBox(height: 24),
ElevatedButton(
onPressed: _login,
child: Text('登录'),
),
],
),
);
}
}
这串代码里最关键的是 GlobalKey<FormState>。你可以把它理解成给这张表单一个“全局唯一标识号”,当点击登录按钮时,通过这个 key 获取 FormState,然后调用它的 validate()。只有 Form 包着 TextFormField 时这个机制才成立,这也是为什么表单一定要用 Form 组件管理。
dart复制if (_formKey.currentState!.validate()) {
// 校验通过
}
执行完 validate() 后,Flutter 会遍历表单内所有 TextFormField,依次执行它们各自的 validator 回调。任何一条返回非空字符串,校验就失败,页面显示返回的错误提示,并且不会进入 if 代码块内部。所有校验通过才返回 true,进入提交逻辑。
3. TextEditingController 的几个必会操作
3.1 给输入框赋初始值
场景很常见:编辑资料页,打开以后输入框里要回显当前保存的昵称。做法是在 initState 里给 controller 赋初值:
dart复制late TextEditingController _nameController;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: currentUser.name);
}
注意 TextEditingController 的构造函数里可以传 text,这个初始值会在输入框首次 build 时显示出来。不要在 build 方法里写 controller.text = xxx,那会在每次 build 时都触发输入框重建,引起光标跳动甚至输入异常。
3.2 清空输入框
搜索框清空按钮、评论删除重写,本质都是这一行:
dart复制_controller.clear();
clear() 会同时清空内容和光标位置,不需要自己再把 value 设为空字符串。如果你用的是 Form 里的 TextFormField,清空之后它的内部状态也会同步变化,错误提示也会消失,目前这个逻辑我实测下来是流畅的。
3.3 controller 和数据联动
如果你想实现“输入框 A 填了内容后,自动同步到输入框 B”这类联动效果,还是要靠 controller 加监听:
dart复制_controllerA.addListener(() {
_controllerB.text = _controllerA.text;
});
这个写法比较简单粗暴,但要注意循环触发的问题——如果 B 也加了 addListener 再反向设置 A,就会陷入死循环。正常业务里这种需求不多,如果需要做类似联动,建议少用监听器、多直接扔到 onChanged 里去处理。
3.4 生命周期:dispose 最好别偷懒
TextEditingController 会一直持有内部文本的监听服务,如果每写一个页面都不 dispose(),页面频繁切换时会有内存泄漏风险,控制台还会打黄色警告。
dart复制@override
void dispose() {
_controller.dispose();
super.dispose();
}
从写第一个输入框开始,就养成“创建 controller 必须跟着 dispose”的习惯,这是 Flutter 学习者避免日后内存问题的好起点。
4. 输入格式限制与高级校验
4.1 用 inputFormatters 限制内容格式
很多场景比“不能为空”更精细:手机号只能输入数字且长度 11 位、验证码只能 4 位数字、提现金额最多小数点后两位。这些光靠 validator 不够,因为用户输入时就能往框里塞进一大堆非法字符,等提交时才提示就太晚了。
inputFormatters 接收一组 TextInputFormatter,用来在输入过程中直接过滤或格式化内容。最常用的两个是 FilteringTextInputFormatter 和 LengthLimitingTextInputFormatter。
只允许数字:
dart复制import 'package:flutter/services.dart';
TextFormField(
inputFormatters: [
FilteringTextInputFormatter.digitsOnly, // 只允许数字
LengthLimitingTextInputFormatter(11), // 最多11位
],
)
digitsOnly 是系统内置的组合过滤器,等于把所有非数字字符在输入那一刻过滤掉。对用户来说,就是在输入字母或符号时直接“没反应”,体验最温和。
对于金额输入,可以用正则:
dart复制FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))
这个正则允许输入整数部分任意位数、小数点最多保留两位,基本能覆盖常见的金额输入场景。同类实战中如果你需要更依赖纯正则的思路,也可以在 onChanged 回调里去写替换逻辑。
4.2 手机号、邮箱、密码强度校验
现在把校验规则写全,方便你直接抄。在 validator 回调里做正则匹配是最正统的做法:
dart复制validator: (value) {
if (value == null || value.isEmpty) {
return '手机号不能为空';
}
// 简单版手机号正则:1开头的11位数字
if (!RegExp(r'^1\d{10}$').hasMatch(value)) {
return '手机号格式不正确';
}
return null;
},
邮箱校验:
dart复制validator: (value) {
if (value == null || value.isEmpty) {
return '邮箱不能为空';
}
if (!RegExp(r'^[\w\.-]+@[\w-]+(\.[\w-]+)+$').hasMatch(value)) {
return '邮箱格式不正确';
}
return null;
},
密码强度校验可以按产品需求来写,比如“必须同时包含字母和数字”:
dart复制validator: (value) {
if (value == null || value.isEmpty) {
return '密码不能为空';
}
if (!RegExp(r'^(?=.*[A-Za-z])(?=.*\d).+$').hasMatch(value)) {
return '密码需包含字母和数字';
}
if (value.length < 8) {
return '密码长度至少8位';
}
return null;
},
正则里面对 (?=.*[A-Za-z]) 这类“预查”写法,含义是字符串中必须存在一个字母,同时还要存在一个数字,这种写法在做多条件校验时很管用。
4.3 实时校验 onChanged 与 setState
如果想让输入过程中错误提示边输入边消失,可以在 onChanged 回调里再次调用 _formKey.currentState!.validate(),不需要手动 setState,因为 validate() 本身会触发表单的重建。
dart复制TextFormField(
onChanged: (_) {
_formKey.currentState?.validate();
},
)
这么写之后,错误提示会在用户开始修正的一瞬间实时刷新。对登录注册这类高频表单体验提升非常明显。
5. 焦点与键盘:容易被忽略的体验细节
5.1 FocusNode 基本用法
FocusNode 管理输入框的焦点状态,也就是“当前激活的是哪个输入框”。最常见的两个操作是:
- 页面加载后让某个输入框自动获取焦点(弹出键盘);
- 点按钮让焦点落到指定输入框或收起键盘。
自动聚焦通常写在 initState 里,等输入框挂载后再请求:
dart复制FocusNode _node = FocusNode();
@override
void initState() {
super.initState();
// 帧回调里请求焦点,确保输入框已经build完成
WidgetsBinding.instance.addPostFrameCallback((_) {
_node.requestFocus();
});
}
@override
void dispose() {
_node.dispose();
super.dispose();
}
登录成功或点击空白区域收起键盘,最直接的方式:
dart复制FocusScope.of(context).unfocus();
5.2 键盘弹出与遮挡问题的处理
移动端表单页面最容易犯的一个错误:键盘弹起来把提交按钮盖住了。Flutter 默认行为里 Scaffold 会自动 adjust resize,但如果你用了某些固定高度的底部导航栏或者自定义布局,还是可能被遮挡。
最简单的全局解决方案是包一层 SingleChildScrollView,这样键盘弹出时整个表单可以往上滚动:
dart复制Scaffold(
body: SingleChildScrollView(
child: Form(
child: Column(...),
),
),
)
需要更精准控制的时候,也可以用 Scaffold 的 resizeToAvoidBottomInset 属性配合 bottomNavigationBar 做底部固定按钮的适配,但新手期 SingleChildScrollView 已经能覆盖大部分场景了。
5.3 回车键的处理逻辑
textInputAction 控制键盘右下角按钮的行为。搜索框可以设置为 TextInputAction.search,登录页密码框可以设置为 TextInputAction.done。配合 onSubmitted 可以在点击该按钮时执行对应的提交逻辑:
dart复制TextFormField(
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) {
_login();
},
)
这样用户在密码输入完,直接点键盘右下角的“完成/前往”就能触发登录,不需要再伸手去点屏幕上的按钮,对表单流畅度提升非常显著。
6. 常见问题与排查技巧实录
6.1 输入中文时 onChanged 不触发或重复触发
有一类非常典型的坑:输入中文时,拼音组合阶段的 onChanged 回调会执行多次,且取到的 value 经常是拼音或临时候选词,导致实时搜索/实时校验的结果不对。
这个问题和 Flutter 的文本输入“组合输入”机制有关。解决方案是按需处理,最简单实用的办法是:实时搜索场景用 onChanged 但加防抖(300ms);精确提交场景直接读 controller.text,不要在拼音阶段做逻辑。
实现防抖需要 Timer,下面是一个适用于搜索框的最小可运行写法:
dart复制Timer? _debounce;
onChanged: (value) {
_debounce?.cancel();
_debounce = Timer(Duration(milliseconds: 300), () {
// 这里才算最终输入完成
doSearch(value);
});
}
6.2 controller 被复用到多个输入框引发报错
新手最容易把同一个 TextEditingController 传给两个 TextField,结果运行时报错:
code复制A TextEditingController was used after being disposed.
这是因为每个 TextEditingController 只能同时被一个输入框持有,你不小心复用了它,前一个输入框销毁时 controller 就被释放了。解决办法是每个输入框创建独立的 TextEditingController,不要共享。
6.3 密码框透明背景把默认的下划线去掉了
有时候设计稿的密码输入框是“无边框、透明底”风格,但开启 obscureText: true 后发现输入区域透明、边框也消失了,看起来很怪。原因是 decoration 里你没设置个性化样式,Flutter 默认的暗色下划线消失,视觉上就完全看不到输入区域的范围。
解决方法是显式设置 filled: true、fillColor 和 border,让输入框有一个清晰的容器边界。比如:
dart复制TextField(
obscureText: true,
decoration: InputDecoration(
hintText: '请输入密码',
filled: true,
fillColor: Color(0xFFF5F5F5),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
)
6.4 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 输入框不能输入文字 | readOnly 误设置为 true,或 enabled 为 false |
检查属性设置,改成 false/去掉 |
| 输入长度限制不生效 | 忘了写 inputFormatters,只写了 maxLength 但没配合限制 |
确保 maxLength 或 LengthLimitingTextInputFormatter 存在 |
| 校验不触发 | TextFormField 没有放在 Form 里面 |
确保最外层有 Form 和 GlobalKey<FormState> |
| 提交后数据获取为空 | 没有给 TextFormField 绑 controller,或者值在 dispose 后读取 |
绑定 controller,在 validate() 通过后取 .text |
| 编译报错 controller 被释放 | 控制器在输入框卸载时被其他输入框引用 | 每个输入框独立 controller,使用完 dispose |
| 键盘把按钮挡住 | 整个页面未支持滚动 | 用 SingleChildScrollView 包住表单列 |
7. 一些提升开发效率的小建议
7.1 关于输入类型的常见组合
做业务开发一段时间后会发现,各种输入框其实就是一堆属性的固定搭配。我整理了三组高频组合,直接参考:
| 场景 | keyboardType | inputFormatters | 其他设置 |
|---|---|---|---|
| 手机号 | TextInputType.number |
digitsOnly + 长度11 | maxLength 11 |
| 密码 | .visiblePassword |
无 | obscureText true |
| 验证码 | .number |
digitsOnly + 长度6 | maxLength 6,居中样式 |
| 金额 | .numberWithOptions(decimal: true) |
正则保留两位小数 | keyboardType 注意同时设置 |
| 多行评论 | .multiline |
无 | maxLines 4,textInputAction 不设置 |
7.2 封装通用输入组件
如果项目里多个页面都有这类“图标 + 输入框 + 边框”的样式,建议抽成一个小组件,减少重复代码:
dart复制Widget buildInputField({
String? hint,
IconData? icon,
TextEditingController? controller,
bool obscure = false,
FormFieldValidator<String>? validator,
}) {
return TextFormField(
controller: controller,
obscureText: obscure,
validator: validator,
decoration: InputDecoration(
hintText: hint,
prefixIcon: icon != null ? Icon(icon) : null,
filled: true,
fillColor: Colors.grey[100],
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
),
);
}
这种方法在需要快速搭建表单的后台类页面或用户资料页面里,可以明显少写大量嵌套代码。需要注意封装时需要透传的 validator 类型是 FormFieldValidator<String>,这是 validator 回调的官方签名。
7.3 围绕 60fps 的渲染小注意点
虽然 TextField 本身不至于让页面卡成 PPT,但输入框和键盘的问题容易和渲染性能绑定在一起,常见的情况是:每次输入时页面都大面积 setState 重建,导致低端机上键盘弹落不流畅。这里有一个非常实用的小经验:输入框内容变化时,不要用 setState 包住整个页面。
例子:搜索页面如果每次输入都调用 setState(() {}),整个页面包括列表、图标都会重建,输入越频繁越卡。更好的做法是:输入的过程只更新“需要跟随变化”的区域(比如清空按钮显示/隐藏),搜索结果拉回来后再整体刷新列表。把输入和显示的依赖拆开,对保持滚动列表流畅也很有效。
最后再说几句
我当初第一次写注册页面时,就是被 TextEditingController 的释放、FormState 的校验、InputDecoration 的样式这些概念来回折腾了不少时间。后来慢慢形成一套自己的固定写法,遇到新的表单页面直接按模板上手,就再没出过什么大问题。这里分享最想强调的三点:
TextFormField比TextField更适合做表单输入,Form加GlobalKey加validator这个组合你越早熟悉越省心;- 页面上每个
TextEditingController都必须记得dispose(),量小的项目无所谓,量大了内存问题会特别烦; - 输入格式限制靠
inputFormatters提前做拦截,校验靠validator兜底,两者配合才能做出靠谱的表单体验。
我在多个项目里走下来,这个体系的稳定性是场上最能打的,尤其适合零基础起步。希望这篇把 TextField 的基本功讲清楚后,你的表单页面从今天起不再是一件头疼事。
