Flutter 基础体系 · 第 8/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter 表单与输入:Controller、Focus、校验、键盘和无障碍

表单输入不是“把几个 TextField 放进 Column”这么简单。一个可维护的表单至少同时处理五类状态:

  1. 文本状态:用户当前输入的字符、光标和选区。
  2. 焦点状态:哪个控件正在接收键盘事件。
  3. 校验状态:字段是否为空、格式是否正确、服务端是否接受。
  4. 键盘与窗口状态:软键盘是否弹出、可视区域是否被遮挡、下一步动作是什么。
  5. 语义与可访问性状态:读屏软件如何理解标签、错误、必填性和焦点移动。

Flutter 将这些职责拆成了不同的 API:TextEditingController 管理编辑值,FocusNode 管理焦点,FormFormField 组织同步校验,MediaQueryviewInsets 和滚动组件处理键盘遮挡,Semantics 与输入控件自身的语义树支持无障碍访问。


一、先建立输入模型:文本、选区、组合文本和提交

1. TextEditingController 保存的不只是字符串

TextEditingControllerValueNotifier<TextEditingValue>。它保存一个 TextEditingValue,其中至少包含:

class TextEditingValue {
  final String text;
  final TextSelection selection;
  final TextRange composing;
}

因此,输入框的实际状态可以表示为:

E=(T,S,C)E = (T, S, C)

其中:

  • TT:当前文本;
  • SS:光标或选区;
  • CC:输入法正在组合的文本范围,例如用户正在拼写中文、日文或韩文时尚未提交的部分。

只读取 controller.text 只能得到 TT,不能表达光标位置和输入法组合状态。多数业务只需要文本,但需要格式化、光标定位或处理复杂输入时,应使用 controller.value

final controller = TextEditingController();

controller.addListener(() {
  final value = controller.value;
  debugPrint('文本=${value.text}');
  debugPrint('选区=${value.selection}');
  debugPrint('组合区间=${value.composing}');
});

TextFieldTextFormField 会把用户编辑产生的新 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 不能同时设置 initialValuecontroller。一旦传入 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 划分焦点范围;FocusTraversalGroupFocusTraversalPolicy 决定 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

  1. 执行每个字段的 validator
  2. 保存每个字段的错误文本;
  3. 触发相关字段重建;
  4. 返回所有字段是否通过校验。

形式化地说,若有字段集合 F={f1,f2,,fn}F = \{f_1, f_2, \ldots, f_n\},每个字段校验结果为:

V(fi){valid,invalid}V(f_i) \in \{\text{valid}, \text{invalid}\}

则:

FormValid=i=1nV(fi)\text{FormValid} = \bigwedge_{i=1}^{n} V(f_i)

也就是说,只要一个字段返回错误字符串,整个表单就不能进入本地提交阶段。

3. save() 不会自动发生

FormState.save() 会调用每个 FormFieldonSaved,但不会替代 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.nextrequestFocus 配合,形成明确的字段流转;
  • 密码显示按钮有 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,
)

同时使用 validatordecoration.errorText 时,必须测试两者同时存在时的显示行为,避免用户看到重复或相互矛盾的错误。生产代码通常会把错误状态归一化为一个字段级错误源。


五、键盘、窗口内边距和滚动

1. 软键盘会改变可视区域

在移动端,软键盘通常通过窗口内边距反映其遮挡区域:

final bottomInset = MediaQuery.viewInsetsOf(context).bottom;

键盘打开时,bottomInset 通常大于零。它表示被系统视图遮挡的区域,不是设备底部安全区。两者区别是:

  • viewPadding:系统区域的原始安全边距;
  • padding:考虑系统遮挡后的安全边距;
  • viewInsets:完全被系统 UI 覆盖的区域,键盘是常见来源。

SafeArea 主要处理安全边距;ScaffoldresizeToAvoidBottomInset 控制 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 是给平台自动填充服务的语义提示,不是读取密码的权限,也不是保证自动填充一定发生。平台可能没有保存数据、用户可能关闭自动填充、字段结构也可能不符合平台识别规则。

在密码注册页面中,newPasswordpassword 等提示应根据页面语义选择;不要把所有密码字段都标成登录密码,否则密码管理器可能产生错误建议。

3. 中文输入与 composing 区间

用户输入拼音时,输入法可能先写入 composing 文本,之后才提交汉字。如果业务监听器在 composing 阶段强行把文本转成大写、删除字符或重设 selection,可能打断输入法,出现:

  • 拼音无法继续输入;
  • 光标跳到末尾;
  • 候选词消失;
  • 文本重复或丢失。

因此,复杂文本变换应谨慎处理 controller.value.composing,不要在用户仍处于组合输入时进行破坏性重写。密码、纯数字等字段受到的影响通常较小,但国际化应用不能假定所有用户都使用直接提交字符的键盘。


七、无障碍:可访问性不是给控件加一个 Label 就结束

1. Flutter 输入控件已经提供基础语义

TextFieldTextFormFieldCheckboxRadioSwitch 和按钮等 Material/Cupertino 控件通常会向语义树提供角色、当前值、可编辑性和操作。最重要的第一步是使用有意义的 labelText

TextFormField(
  decoration: const InputDecoration(
    labelText: '手机号',
  ),
)

不要只放一个视觉图标:

TextFormField(
  decoration: const InputDecoration(
    prefixIcon: Icon(Icons.email),
  ),
)

图标不能可靠替代字段名称。hintText 是输入示例或提示,不能长期代替 label,因为用户输入后 hint 可能消失,读屏用户也需要稳定识别字段。

2. 错误信息必须进入语义路径

TextFormFieldvalidator 错误通常会与字段语义关联,但自定义错误文本、顶部错误摘要或异步服务端错误需要额外测试。自定义错误区域可以使用:

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 --> [*]

关键路径如下:

  1. 用户输入改变 controller;
  2. validator 可能根据 autovalidateMode 运行;
  3. 点击提交后先执行同步校验;
  4. 校验通过才进入 Submitting
  5. 提交期间禁用按钮,防止重复请求;
  6. 异步完成后检查 mounted
  7. 根据结果显示成功、字段错误或网络错误;
  8. 用户修改相关字段后清除过时的服务端错误。

这不是单纯的 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 中,ExpandedListView 一个有限高度,因此滚动视口能够计算尺寸。相反,若把 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

诊断顺序

遇到“输入丢失”时:

  1. 检查 controller 是否在 build 中创建;
  2. 检查父 Widget 是否因 key 或条件分支替换了输入控件;
  3. 检查是否在监听器中无条件修改 controller.value
  4. 检查 composing 和 selection 是否被清除。

遇到“键盘遮挡”时:

  1. 检查是否有可滚动父组件;
  2. 检查 MediaQuery.viewInsets 是否变化;
  3. 检查滚动组件是否处于正确的有限约束中;
  4. 检查焦点字段是否在嵌套滚动或裁剪区域内;
  5. 必要时使用 Scrollable.ensureVisible

遇到“校验不触发”时:

  1. 检查是否使用了 TextFormField 而不是普通 TextField
  2. 检查字段是否位于同一个 Form 下;
  3. 检查是否调用了正确的 FormState
  4. 检查 validator 是否真的返回了非空字符串;
  5. 确认 autovalidateMode 是否符合预期;
  6. 区分本地 validator 错误和服务端错误状态。

遇到“读屏器读不清楚”时:

  1. 检查字段是否有稳定的 labelText
  2. 检查必填和错误是否只通过颜色或图标表达;
  3. 检查自定义 Semantics 是否造成重复播报;
  4. 检查动态错误是否需要 liveRegion
  5. 分别使用 TalkBack、VoiceOver、桌面读屏器和浏览器键盘导航验证。

十二、一个可执行的检查标准

一个表单至少应满足以下可验证条件:

  • controller 和 focus node 的创建位置稳定,并在 dispose 中释放;
  • 初始值不会在每次重建时覆盖用户输入;
  • 光标和中文输入组合区间不会被无意破坏;
  • validator 只做同步、无副作用的本地校验;
  • 提交前调用 validate(),服务端仍然重新校验;
  • 异步请求有重复提交保护、异常处理、超时或取消策略;
  • 异步结果不会被旧请求覆盖,也不会更新已销毁页面;
  • 键盘打开后当前字段可以滚动到可视区域;
  • Android、iOS、桌面和 Web 的键盘与焦点行为分别验证;
  • 字段具有稳定标签,错误和必填信息不依赖颜色;
  • 图标按钮有 tooltip 或等价的可访问名称;
  • Tab、Shift+Tab、读屏焦点和移动端焦点顺序符合业务顺序。

Controller、Focus、校验、键盘和无障碍并不是五组互不相关的技巧。它们共同描述了一个输入字段从“获得焦点”到“产生编辑值”、从“本地校验”到“异步提交”、从“键盘遮挡”到“读屏播报”的完整生命周期。只有把这些状态和边界连接起来,表单才不仅能输入,还能在不同平台、不同输入法、不同辅助技术和真实网络故障下保持可预测。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。