Flutter 基础体系 · 第 8/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 表单与输入:Controller、Focus、校验、键盘和无障碍
表单输入不是“把几个 TextField 放进 Column”这么简单。一个可维护的表单至少同时处理五类状态:
- 文本状态:用户当前输入的字符、光标和选区。
- 焦点状态:哪个控件正在接收键盘事件。
- 校验状态:字段是否为空、格式是否正确、服务端是否接受。
- 键盘与窗口状态:软键盘是否弹出、可视区域是否被遮挡、下一步动作是什么。
- 语义与可访问性状态:读屏软件如何理解标签、错误、必填性和焦点移动。
Flutter 将这些职责拆成了不同的 API:TextEditingController 管理编辑值,FocusNode 管理焦点,Form 和 FormField 组织同步校验,MediaQuery、viewInsets 和滚动组件处理键盘遮挡,Semantics 与输入控件自身的语义树支持无障碍访问。
一、先建立输入模型:文本、选区、组合文本和提交
1. TextEditingController 保存的不只是字符串
TextEditingController 是 ValueNotifier<TextEditingValue>。它保存一个 TextEditingValue,其中至少包含:
class TextEditingValue {
final String text;
final TextSelection selection;
final TextRange composing;
}
因此,输入框的实际状态可以表示为:
其中:
- :当前文本;
- :光标或选区;
- :输入法正在组合的文本范围,例如用户正在拼写中文、日文或韩文时尚未提交的部分。
只读取 controller.text 只能得到 ,不能表达光标位置和输入法组合状态。多数业务只需要文本,但需要格式化、光标定位或处理复杂输入时,应使用 controller.value。
final controller = TextEditingController();
controller.addListener(() {
final value = controller.value;
debugPrint('文本=${value.text}');
debugPrint('选区=${value.selection}');
debugPrint('组合区间=${value.composing}');
});
TextField 和 TextFormField 会把用户编辑产生的新 TextEditingValue 写回 controller;业务代码也可以通过 controller 修改输入框。因此它是一个双向数据通道:
用户/输入法
│
▼
EditableText
│ 更新
▼
TextEditingController.value
│ 监听或读取
▼
业务状态、校验、提交
2. Controller 的生命周期
如果在 State 中创建 controller,必须在 dispose 中释放:
class _EmailFieldState extends State<EmailField> {
late final TextEditingController _controller;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialEmail);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(controller: _controller);
}
}
不要在 build 中这样写:
@override
Widget build(BuildContext context) {
return TextField(
controller: TextEditingController(text: '初始值'),
);
}
build 可能被频繁调用。这样会反复创建 controller,导致用户输入、光标位置或监听器丢失,也会造成未释放对象。
如果只需要设置初始值,且不需要读取或控制文本,可以使用:
TextFormField(
initialValue: '初始值',
)
但 TextFormField 不能同时设置 initialValue 和 controller。一旦传入 controller,初始文本应通过 controller 设置。
3. 修改文本时要尊重光标和组合区间
简单替换文本可以写:
controller.text = controller.text.trim();
但这个赋值会影响当前选区和 composing 状态。需要保留或重新计算光标时,使用 value.copyWith:
void removeSpaces(TextEditingController controller) {
final oldValue = controller.value;
final newText = oldValue.text.replaceAll(' ', '');
final newOffset = oldValue.selection.baseOffset
.clamp(0, newText.length);
controller.value = oldValue.copyWith(
text: newText,
selection: TextSelection.collapsed(offset: newOffset),
composing: TextRange.empty,
);
}
这里的风险是:如果业务代码在 controller 监听器中再次修改 controller,可能形成递归更新。尤其不要在监听器里无条件写回同一个值:
controller.addListener(() {
controller.text = controller.text.toUpperCase(); // 可能重复触发监听
});
需要格式限制时,优先使用 inputFormatters,因为它们参与编辑值变换,并且通常能更好地处理选区:
TextField(
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
)
这只能限制本地输入形式,不能代替业务校验。用户仍可能通过粘贴、自动填充、平台输入法或外部状态写入数据。
二、Focus:谁接收输入,以及焦点如何移动
1. FocusNode 与 TextEditingController 是两个独立概念
TextEditingController 回答“输入框里是什么”;FocusNode 回答“哪个控件当前接收键盘事件”。
late final FocusNode _emailFocus;
late final FocusNode _passwordFocus;
@override
void initState() {
super.initState();
_emailFocus = FocusNode();
_passwordFocus = FocusNode();
}
@override
void dispose() {
_emailFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
使用:
TextFormField(
focusNode: _emailFocus,
controller: _emailController,
)
FocusNode 也不能在 build 中临时创建,否则焦点树每次重建都可能丢失。
2. 焦点树不是 Widget 树的简单副本
Flutter 使用焦点树管理键盘事件传播。一个节点获得焦点后,键盘事件通常先经过当前焦点节点,再沿焦点层级传播。FocusScope 划分焦点范围;FocusTraversalGroup 和 FocusTraversalPolicy 决定 Tab、方向键等如何移动。
常见的字段间移动方式是:
TextFormField(
focusNode: _emailFocus,
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) {
_passwordFocus.requestFocus();
},
),
TextFormField(
focusNode: _passwordFocus,
textInputAction: TextInputAction.done,
onFieldSubmitted: (_) {
_submit();
},
)
textInputAction 是发给输入法的动作提示,不保证每个平台都完全按相同方式显示。onFieldSubmitted 也不是所有输入方式的唯一提交路径:桌面键盘、硬件键盘、读屏操作和某些输入法可能走不同事件路径,因此提交按钮仍应保留。
3. 失焦不等于校验成功
常见业务规则是“用户离开字段后显示错误”。可以监听焦点:
late final FocusNode _emailFocus;
@override
void initState() {
super.initState();
_emailFocus = FocusNode()..addListener(_onEmailFocusChanged);
}
void _onEmailFocusChanged() {
if (!_emailFocus.hasFocus) {
_emailFormFieldKey.currentState?.validate();
}
}
@override
void dispose() {
_emailFocus
..removeListener(_onEmailFocusChanged)
..dispose();
super.dispose();
}
不过 FormFieldState.validate() 需要稳定的 GlobalKey<FormFieldState<String>>,不能每次构建时重新创建。更简单的方式是使用:
autovalidateMode: AutovalidateMode.onUserInteraction
它表示用户与字段交互后自动校验。提交时仍应调用整个表单的 validate()。
三、Form 与校验:显示错误、阻止提交和服务端错误是三件事
1. validator 的契约
TextFormField.validator 的返回值有明确含义:
- 返回
null:当前值通过该字段的同步校验; - 返回非空字符串:当前值无效,该字符串作为错误信息显示。
String? validateEmail(String? value) {
final text = value?.trim() ?? '';
if (text.isEmpty) {
return '请输入邮箱地址';
}
final emailPattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if (!emailPattern.hasMatch(text)) {
return '邮箱格式不正确';
}
return null;
}
validator 应该是同步、快速、无副作用的函数。不要在其中发网络请求、修改 controller、弹 Snackbar 或调用 setState。因为校验可能由 build 间接触发,副作用会造成重复请求、重建循环或不可预测的错误。
2. FormState.validate() 的实际流程
典型的提交流程是:
final formKey = GlobalKey<FormState>();
bool validateForm() {
final isValid = formKey.currentState?.validate() ?? false;
return isValid;
}
调用 validate() 时,表单会遍历其后代 FormField:
- 执行每个字段的
validator; - 保存每个字段的错误文本;
- 触发相关字段重建;
- 返回所有字段是否通过校验。
形式化地说,若有字段集合 ,每个字段校验结果为:
则:
也就是说,只要一个字段返回错误字符串,整个表单就不能进入本地提交阶段。
3. save() 不会自动发生
FormState.save() 会调用每个 FormField 的 onSaved,但不会替代 validate():
final isValid = formKey.currentState?.validate() ?? false;
if (!isValid) {
return;
}
formKey.currentState?.save();
如果数据本来就通过 controller 读取,onSaved 不是必须的:
final email = _emailController.text.trim();
onSaved 更适合表单字段较多、希望统一把值写入一个模型的场景。
4. 一个完整的本地校验示例
下面的页面可以直接作为 Flutter 应用的 home 使用。它包含 controller、focus、同步校验、键盘动作、滚动、防止重复提交和异步提交错误处理。
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() {
runApp(const MaterialApp(
debugShowCheckedModeBanner: false,
home: SignInPage(),
));
}
class SignInPage extends StatefulWidget {
const SignInPage({super.key});
@override
State<SignInPage> createState() => _SignInPageState();
}
class _SignInPageState extends State<SignInPage> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _emailController;
late final TextEditingController _passwordController;
late final FocusNode _emailFocus;
late final FocusNode _passwordFocus;
bool _submitting = false;
bool _obscurePassword = true;
String? _serverError;
@override
void initState() {
super.initState();
_emailController = TextEditingController();
_passwordController = TextEditingController();
_emailFocus = FocusNode();
_passwordFocus = FocusNode();
}
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
_emailFocus.dispose();
_passwordFocus.dispose();
super.dispose();
}
String? _validateEmail(String? value) {
final email = value?.trim() ?? '';
if (email.isEmpty) return '请输入邮箱地址';
final pattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if (!pattern.hasMatch(email)) {
return '请输入有效的邮箱地址';
}
return null;
}
String? _validatePassword(String? value) {
final password = value ?? '';
if (password.isEmpty) return '请输入密码';
if (password.length < 8) return '密码至少需要 8 个字符';
return null;
}
Future<void> _submit() async {
FocusManager.instance.primaryFocus?.unfocus();
final valid = _formKey.currentState?.validate() ?? false;
if (!valid || _submitting) return;
setState(() {
_submitting = true;
_serverError = null;
});
try {
await Future<void>.delayed(const Duration(milliseconds: 500));
// 此处替换为真实 API 调用。
// 真实实现应区分网络失败、身份验证失败和服务端字段错误。
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('登录请求已提交')),
);
} catch (error) {
if (!mounted) return;
setState(() {
_serverError = '网络异常,请稍后重试';
});
} finally {
if (mounted) {
setState(() {
_submitting = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('登录')),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Form(
key: _formKey,
child: AutofillGroup(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextFormField(
controller: _emailController,
focusNode: _emailFocus,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username],
textCapitalization: TextCapitalization.none,
autocorrect: false,
decoration: const InputDecoration(
labelText: '邮箱',
hintText: 'name@example.com',
),
validator: _validateEmail,
autovalidateMode:
AutovalidateMode.onUserInteraction,
onFieldSubmitted: (_) {
_passwordFocus.requestFocus();
},
),
const SizedBox(height: 16),
TextFormField(
controller: _passwordController,
focusNode: _passwordFocus,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
decoration: InputDecoration(
labelText: '密码',
suffixIcon: IconButton(
tooltip: _obscurePassword ? '显示密码' : '隐藏密码',
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
icon: Icon(
_obscurePassword
? Icons.visibility
: Icons.visibility_off,
),
),
),
validator: _validatePassword,
autovalidateMode:
AutovalidateMode.onUserInteraction,
onFieldSubmitted: (_) => _submit(),
),
if (_serverError != null) ...[
const SizedBox(height: 12),
Text(
_serverError!,
semanticsLabel: '提交失败:$_serverError',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _submitting ? null : _submit,
child: _submitting
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('登录'),
),
],
),
),
),
),
),
);
}
}
这个示例中有几个重要因果关系:
SingleChildScrollView允许内容在键盘出现后滚动,而不是让固定高度的Column被裁剪;SafeArea处理刘海、状态栏和底部系统区域,但它不等于键盘避让;validate()只处理本地同步规则;_submitting防止用户连续点击产生并发提交;mounted检查防止异步任务完成后对已销毁页面调用setState;TextInputAction.next与requestFocus配合,形成明确的字段流转;- 密码显示按钮有
tooltip,不仅依赖图标形状传达含义。
四、同步校验、异步校验和服务端字段错误
1. 异步校验不能直接塞进 validator
错误做法:
validator: (value) async {
final available = await checkUsername(value);
return available ? null : '用户名已存在';
}
FormField.validator 的契约是同步返回 String?,不是 Future<String?>。异步校验应单独管理状态。
例如检查用户名是否可用时,需要考虑输入变化期间的竞态:
用户输入 abc ──请求 A──►
用户输入 abcd ─请求 B──►
B 先返回
A 后返回
如果无条件接受返回结果,旧请求 A 会覆盖新请求 B 的状态。可以使用递增版本号:
int _validationVersion = 0;
String? _usernameError;
bool _checkingUsername = false;
Future<void> _checkUsername(String username) async {
final version = ++_validationVersion;
setState(() {
_checkingUsername = true;
_usernameError = null;
});
try {
final available = await fakeCheckUsername(username);
if (!mounted || version != _validationVersion) return;
setState(() {
_usernameError = available ? null : '用户名已被占用';
_checkingUsername = false;
});
} catch (_) {
if (!mounted || version != _validationVersion) return;
setState(() {
_usernameError = '无法检查用户名,请稍后重试';
_checkingUsername = false;
});
}
}
Future<bool> fakeCheckUsername(String username) async {
await Future<void>.delayed(const Duration(milliseconds: 300));
return username != 'admin';
}
这里的版本号不是取消网络请求,而是使旧请求的结果失效。若 HTTP 客户端支持取消,也可以同时取消旧请求,但仍建议保留结果版本判断,因为取消并不一定能阻止服务端响应已经进入客户端的路径。
2. 服务端错误与字段 validator 的边界
本地 validator 适合:
- 必填;
- 长度;
- 基本格式;
- 两次密码是否一致;
- 本地可推导的约束。
服务端错误适合:
- 邮箱已注册;
- 验证码过期;
- 账号被锁定;
- 权限或业务状态不允许。
服务端返回字段错误后,可以把它放到独立状态中显示,而不是修改本地 validator 的职责。因为服务端错误可能在下一次本地校验后仍然有效,也可能需要在用户修改字段时清除。
一个常见策略是:
onChanged: (_) {
if (_serverError != null) {
setState(() => _serverError = null);
}
}
如果要显示在具体字段下方,可以使用 errorText,但应明确控制优先级:
TextFormField(
controller: _emailController,
decoration: InputDecoration(
labelText: '邮箱',
errorText: _serverEmailError,
),
validator: _validateEmail,
)
同时使用 validator 和 decoration.errorText 时,必须测试两者同时存在时的显示行为,避免用户看到重复或相互矛盾的错误。生产代码通常会把错误状态归一化为一个字段级错误源。
五、键盘、窗口内边距和滚动
1. 软键盘会改变可视区域
在移动端,软键盘通常通过窗口内边距反映其遮挡区域:
final bottomInset = MediaQuery.viewInsetsOf(context).bottom;
键盘打开时,bottomInset 通常大于零。它表示被系统视图遮挡的区域,不是设备底部安全区。两者区别是:
viewPadding:系统区域的原始安全边距;padding:考虑系统遮挡后的安全边距;viewInsets:完全被系统 UI 覆盖的区域,键盘是常见来源。
SafeArea 主要处理安全边距;Scaffold 的 resizeToAvoidBottomInset 控制 body 是否随键盘改变可用空间。默认布局经常已经足够,但复杂底部面板、全屏背景或自定义 Scaffold 需要明确处理这些值。
2. 为什么单纯增加底部 Padding 不总是够
下面的布局可能仍然无法让当前输入框可见:
Column(
children: [
// 很多内容
TextField(),
],
)
原因是 Column 本身不负责滚动,也不会保证获得焦点的子节点位于可视区域。通常需要:
SingleChildScrollView(
padding: EdgeInsets.only(
left: 24,
right: 24,
bottom: MediaQuery.viewInsetsOf(context).bottom + 24,
),
child: Form(...),
)
对于长表单,ListView 往往比 SingleChildScrollView + Column 更适合,因为它可以按需构建大量字段:
ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.only(
left: 24,
right: 24,
top: 24,
bottom: MediaQuery.viewInsetsOf(context).bottom + 24,
),
children: const [
// 表单字段
],
)
但 ListView 的子节点必须满足可滚动布局约束。将无限高度的滚动组件放入另一个同方向滚动组件,容易出现“Vertical viewport was given unbounded height”等布局错误。这个问题与 Flutter 的约束传播有关:父滚动视口通常在滚动方向上提供无界约束,子滚动视口无法确定自己的高度。
3. 主动滚动到获得焦点的字段
对于嵌套滚动、底部表单或自定义布局,可以在字段获得焦点后请求可见:
void ensureFieldVisible(BuildContext fieldContext) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!fieldContext.mounted) return;
Scrollable.ensureVisible(
fieldContext,
duration: const Duration(milliseconds: 250),
curve: Curves.easeOut,
alignment: 0.2,
);
});
}
必须等待一帧,是因为焦点变化、键盘动画和布局重算可能尚未完成。立即滚动时,目标 RenderObject 的最终位置可能还不存在,表现为滚动不到位或被键盘再次遮挡。
六、输入法、格式限制与平台差异
1. keyboardType 是提示,不是安全校验
TextField(
keyboardType: TextInputType.number,
)
这通常会让移动端显示数字键盘,但不能保证:
- 输入一定是 ASCII 数字;
- 桌面端只能输入数字;
- 粘贴内容符合规则;
- Web 浏览器行为一致。
真正的约束应由 validator、服务端校验和必要的 formatter 共同完成:
TextField(
keyboardType: TextInputType.number,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
],
)
不同平台和输入法对 TextInputType 的解释可能不同。Android 和 iOS 的键盘布局、自动纠错、密码管理器、自动填充提示并不完全相同;桌面通常没有软键盘,因此 keyboardType 对物理键盘输入几乎不起限制作用;Web 依赖浏览器和操作系统输入控件桥接,自动填充和提交键行为可能受浏览器策略影响。
2. 自动填充必须与字段语义一致
AutofillGroup(
child: Column(
children: [
TextFormField(
autofillHints: const [AutofillHints.username],
),
TextFormField(
autofillHints: const [AutofillHints.password],
obscureText: true,
),
],
),
)
autofillHints 是给平台自动填充服务的语义提示,不是读取密码的权限,也不是保证自动填充一定发生。平台可能没有保存数据、用户可能关闭自动填充、字段结构也可能不符合平台识别规则。
在密码注册页面中,newPassword 和 password 等提示应根据页面语义选择;不要把所有密码字段都标成登录密码,否则密码管理器可能产生错误建议。
3. 中文输入与 composing 区间
用户输入拼音时,输入法可能先写入 composing 文本,之后才提交汉字。如果业务监听器在 composing 阶段强行把文本转成大写、删除字符或重设 selection,可能打断输入法,出现:
- 拼音无法继续输入;
- 光标跳到末尾;
- 候选词消失;
- 文本重复或丢失。
因此,复杂文本变换应谨慎处理 controller.value.composing,不要在用户仍处于组合输入时进行破坏性重写。密码、纯数字等字段受到的影响通常较小,但国际化应用不能假定所有用户都使用直接提交字符的键盘。
七、无障碍:可访问性不是给控件加一个 Label 就结束
1. Flutter 输入控件已经提供基础语义
TextField、TextFormField、Checkbox、Radio、Switch 和按钮等 Material/Cupertino 控件通常会向语义树提供角色、当前值、可编辑性和操作。最重要的第一步是使用有意义的 labelText:
TextFormField(
decoration: const InputDecoration(
labelText: '手机号',
),
)
不要只放一个视觉图标:
TextFormField(
decoration: const InputDecoration(
prefixIcon: Icon(Icons.email),
),
)
图标不能可靠替代字段名称。hintText 是输入示例或提示,不能长期代替 label,因为用户输入后 hint 可能消失,读屏用户也需要稳定识别字段。
2. 错误信息必须进入语义路径
TextFormField 的 validator 错误通常会与字段语义关联,但自定义错误文本、顶部错误摘要或异步服务端错误需要额外测试。自定义错误区域可以使用:
Semantics(
liveRegion: true,
label: '错误:邮箱已被注册',
child: Text(
'邮箱已被注册',
style: TextStyle(color: Colors.red),
),
)
liveRegion 表示内容变化时,辅助技术可以主动获知更新。不能假设所有平台读屏器对相同语义属性表现完全一致,因此应在 Android TalkBack、iOS VoiceOver 以及桌面/Web 的目标辅助技术上实际测试。
错误摘要还应避免只用颜色表示。下面的表达比红色边框更完整:
Semantics(
label: '邮箱,错误:请输入有效的邮箱地址',
child: TextFormField(
decoration: const InputDecoration(
labelText: '邮箱',
),
),
)
但不要无条件给原生输入控件包一层重复完整 label,否则读屏器可能读出两次字段名称。是否需要 Semantics 包装,取决于控件本身已有语义和自定义布局结果。
3. 必填、密码按钮和图标按钮
必填信息不能只通过红色星号表达:
TextFormField(
decoration: const InputDecoration(
labelText: '邮箱(必填)',
),
)
也可以通过辅助文本或更完整的语义说明表达。显示/隐藏密码按钮必须有可读名称:
IconButton(
tooltip: obscure ? '显示密码' : '隐藏密码',
onPressed: onPressed,
icon: Icon(obscure ? Icons.visibility : Icons.visibility_off),
)
IconButton 的 tooltip 既服务于鼠标悬停,也能为无障碍用户提供动作名称,但仍应检查读屏器是否会把字段和按钮读成清晰的两个交互元素。
4. 焦点顺序不能只为鼠标设计
移动端读屏用户、桌面键盘用户和 Web 用户都需要明确的焦点顺序。通常 Widget 顺序应与视觉和业务顺序一致:
Column(
children: const [
EmailField(),
PasswordField(),
SubmitButton(),
],
)
如果视觉顺序通过 Stack、绝对定位或复杂响应式布局改变,语义顺序可能仍按树顺序读取。需要时使用 FocusTraversalGroup、明确的 traversal policy 或重新组织 Widget 树,而不是用大量 FocusScope.of(context).nextFocus() 强行修补所有路径。
对于 Web 和桌面,必须测试:
- Tab 是否能到达每个字段;
- Shift+Tab 是否能反向移动;
- 焦点是否有足够的视觉指示;
- Enter 是否会触发预期提交;
- 键盘焦点是否被弹窗或加载状态困住。
八、表单的状态机与故障路径
一个提交按钮至少涉及以下状态:
stateDiagram-v2
[*] --> Idle
Idle --> Editing: 用户修改字段
Editing --> Invalid: validate() 返回错误
Editing --> Submitting: 本地校验通过
Invalid --> Editing: 用户继续修改
Submitting --> Success: 服务端成功
Submitting --> ServerError: 业务错误
Submitting --> NetworkError: 网络/超时错误
ServerError --> Editing: 字段或表单可修正
NetworkError --> Editing: 用户重试
Success --> [*]
关键路径如下:
- 用户输入改变 controller;
validator可能根据autovalidateMode运行;- 点击提交后先执行同步校验;
- 校验通过才进入
Submitting; - 提交期间禁用按钮,防止重复请求;
- 异步完成后检查
mounted; - 根据结果显示成功、字段错误或网络错误;
- 用户修改相关字段后清除过时的服务端错误。
这不是单纯的 UI 状态。它还涉及并发:
- 两次点击可能产生两个请求;
- 用户修改字段后,旧异步校验可能晚于新校验返回;
- 页面离开后,异步回调仍可能完成;
- 重试时,旧错误状态可能覆盖新成功状态。
生产代码可以使用提交令牌或请求版本号保证“只有最后一次请求能更新当前页面”:
int _submitVersion = 0;
Future<void> submit() async {
final version = ++_submitVersion;
setState(() => _submitting = true);
try {
await sendRequest();
if (!mounted || version != _submitVersion) return;
// 更新成功状态
} catch (error) {
if (!mounted || version != _submitVersion) return;
// 更新错误状态
} finally {
if (!mounted || version != _submitVersion) return;
setState(() => _submitting = false);
}
}
请求取消、超时和版本检查分别解决不同问题:
- 取消请求:减少无效网络和计算;
- 超时:避免 UI 永久处于提交状态;
- 版本检查:防止旧结果覆盖新状态。
三者不能互相完全替代。
九、与布局约束和渲染树的关系
表单的可用性经常被误判为“输入 API 的问题”,实际根因可能是布局约束。
例如:
Column(
children: [
Expanded(
child: ListView(
children: const [
TextField(),
],
),
),
SizedBox(
height: 80,
child: SubmitButton(),
),
],
)
在有界的 Scaffold body 中,Expanded 给 ListView 一个有限高度,因此滚动视口能够计算尺寸。相反,若把 Expanded 放进另一个无界的竖向滚动环境,Flutter 会无法满足约束。
输入框获得焦点后,系统需要沿渲染树找到它所在的可滚动祖先,并尝试将其暴露在可见区域。如果中间存在错误的无界约束、嵌套滚动或裁剪组件,表现可能是:
- 键盘覆盖输入框;
ensureVisible无效;- 页面突然跳到错误位置;
- 出现 viewport 无界高度异常。
诊断时应同时检查:
debugPrint('${MediaQuery.sizeOf(context)}');
debugPrint('${MediaQuery.viewInsetsOf(context)}');
并打开 Flutter Inspector 查看:
- 输入框是否位于预期的滚动组件中;
Column是否获得有限高度;- 是否存在同方向嵌套滚动;
- 输入框是否被
ClipRect或自定义布局裁剪; - 焦点节点是否仍附着在当前 Widget 实例上。
十、桌面、Web、Android 和 iOS 的边界
Android 与 iOS
Android 和 iOS 都提供软键盘、自动填充和系统输入法,但以下行为不应假定一致:
- 键盘的“下一步”“完成”按钮样式;
keyboardType对键盘布局的具体影响;- 密码自动填充提示;
- 键盘出现时窗口调整方式;
- 读屏器对错误和动态区域的播报时机。
应在真实设备上验证,而不是只依赖模拟器。
桌面
桌面通常使用物理键盘和鼠标:
keyboardType通常不能限制物理键盘;- Tab、Shift+Tab、方向键和 Enter 变得重要;
- 鼠标悬停 tooltip 有实际意义;
- 没有软键盘时,
viewInsets.bottom通常不会代表输入遮挡; - 大屏布局不应简单拉伸成极宽的输入框,应通过约束控制表单最大宽度。
例如:
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 480),
child: Form(
child: /* 表单内容 */,
),
),
)
Web
Flutter Web 的文本输入依赖浏览器和平台 DOM/键盘机制:
- 浏览器自动填充策略可能覆盖应用提示;
- Tab 焦点顺序与浏览器页面其他元素交互;
- 浏览器可能拦截某些快捷键;
- 移动浏览器软键盘带来的可视区域变化不完全等同于 Android 原生窗口;
- 页面滚动、应用内滚动和浏览器 viewport 可能共同影响定位。
因此 Web 表单需要专门测试键盘导航、浏览器自动填充、移动浏览器旋转和窗口缩放。
十一、常见误解与诊断方法
误解一:数字键盘保证输入是数字
不保证。它只是输入法提示。要限制字符,需要 formatter;要保证业务合法,还需要 validator 和服务端校验。
误解二:obscureText 就能保护密码
obscureText 主要控制屏幕上的显示方式,不代表日志、controller、网络请求或系统自动填充都安全。不要打印密码,不要把密码放入 URL,不要把敏感值无意写入持久化日志。
误解三:调用 unfocus() 就解决了键盘问题
unfocus() 只改变焦点状态,键盘隐藏是平台文本输入连接和动画的结果,可能不是同步完成的。它也不会自动滚动页面,更不会执行校验。
误解四:validator 返回错误后服务端就不用校验
客户端校验可以改善体验,但客户端代码可被修改,且无法知道服务端实时业务状态。服务端必须重新校验所有安全和业务约束。
误解五:给每个 Widget 加 Semantics 就更无障碍
重复语义可能让读屏器播报混乱。首先使用控件已有语义;只有在自定义错误、装饰性布局、动态状态或组合控件确实缺少语义时,才增加 Semantics。
诊断顺序
遇到“输入丢失”时:
- 检查 controller 是否在
build中创建; - 检查父 Widget 是否因 key 或条件分支替换了输入控件;
- 检查是否在监听器中无条件修改
controller.value; - 检查 composing 和 selection 是否被清除。
遇到“键盘遮挡”时:
- 检查是否有可滚动父组件;
- 检查
MediaQuery.viewInsets是否变化; - 检查滚动组件是否处于正确的有限约束中;
- 检查焦点字段是否在嵌套滚动或裁剪区域内;
- 必要时使用
Scrollable.ensureVisible。
遇到“校验不触发”时:
- 检查是否使用了
TextFormField而不是普通TextField; - 检查字段是否位于同一个
Form下; - 检查是否调用了正确的
FormState; - 检查
validator是否真的返回了非空字符串; - 确认
autovalidateMode是否符合预期; - 区分本地 validator 错误和服务端错误状态。
遇到“读屏器读不清楚”时:
- 检查字段是否有稳定的
labelText; - 检查必填和错误是否只通过颜色或图标表达;
- 检查自定义
Semantics是否造成重复播报; - 检查动态错误是否需要
liveRegion; - 分别使用 TalkBack、VoiceOver、桌面读屏器和浏览器键盘导航验证。
十二、一个可执行的检查标准
一个表单至少应满足以下可验证条件:
- controller 和 focus node 的创建位置稳定,并在
dispose中释放; - 初始值不会在每次重建时覆盖用户输入;
- 光标和中文输入组合区间不会被无意破坏;
validator只做同步、无副作用的本地校验;- 提交前调用
validate(),服务端仍然重新校验; - 异步请求有重复提交保护、异常处理、超时或取消策略;
- 异步结果不会被旧请求覆盖,也不会更新已销毁页面;
- 键盘打开后当前字段可以滚动到可视区域;
- Android、iOS、桌面和 Web 的键盘与焦点行为分别验证;
- 字段具有稳定标签,错误和必填信息不依赖颜色;
- 图标按钮有 tooltip 或等价的可访问名称;
- Tab、Shift+Tab、读屏焦点和移动端焦点顺序符合业务顺序。
Controller、Focus、校验、键盘和无障碍并不是五组互不相关的技巧。它们共同描述了一个输入字段从“获得焦点”到“产生编辑值”、从“本地校验”到“异步提交”、从“键盘遮挡”到“读屏播报”的完整生命周期。只有把这些状态和边界连接起来,表单才不仅能输入,还能在不同平台、不同输入法、不同辅助技术和真实网络故障下保持可预测。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 导航与路由:Navigator、Router、Deep Link 和返回栈
- 下一篇:Flutter 动画体系:Implicit、Controller、Hero、CustomPainter 和性能
- 延伸:Flutter Widget 与布局:约束、尺寸、Flex、Sliver 和渲染树
- 延伸:Flutter 可访问性与国际化:Semantics、焦点、Locale 和文本适配
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论