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

Flutter 焦点与键盘:FocusNode、快捷键、遍历和输入法

在 Flutter 中,“某个控件获得焦点”至少涉及三套机制:

  1. 焦点系统(Focus):决定当前键盘事件发送给哪个节点,以及焦点如何在组件树中移动。
  2. 键盘事件与命令系统(Keyboard、Shortcuts、Actions):把硬件键盘产生的事件转换为应用命令。
  3. 文本输入系统(Text input、IME):把软键盘或硬件键盘输入转换为 TextEditingValue,并处理候选词、组合文本和编辑动作。

这三套机制有关联,但不是同一件事:

  • FocusNode 获得焦点,不等于一定会弹出软键盘。
  • 键盘快捷键可以作用于非文本控件。
  • TextField 能输入文字,是因为它同时参与了焦点系统和平台文本输入系统。
  • 屏幕阅读器的“无障碍焦点”与 Flutter 的键盘焦点也不是严格同一个概念。

一、先区分三种“焦点”

1. 键盘焦点

键盘焦点表示:如果硬件键盘产生一个 KeyEvent,哪个 Flutter 焦点节点首先有机会处理它。

Flutter 的焦点树由 FocusNodeFocusScopeNode 组成,通常与 Widget 树有对应关系,但不是同一棵树:

Widget 树                         Focus 树

MaterialApp                       FocusManager.rootScope
└── Scaffold                      └── FocusScopeNode
    └── Column                         ├── FocusNode(email)
        ├── TextField                   └── FocusNode(password)
        └── Button

一个 Widget 是否显示在界面上,并不意味着它一定可获得焦点。相反,一个焦点节点也可能暂时没有对应的可见内容,或者对应的 Widget 已经被移除但节点仍未正确释放。

2. 文本输入焦点

文本输入焦点是 EditableText 与平台输入法之间建立连接的条件之一。

TextField 的内部核心是 EditableText。当它获得焦点后,通常会:

  1. 创建或打开一个 TextInputConnection
  2. 向 Android、iOS、桌面或 Web 的输入系统声明编辑状态;
  3. 根据 TextInputConfiguration 请求对应的输入方式;
  4. 接收平台返回的 TextEditingValue
  5. 更新 TextEditingController 和屏幕内容。

因此:

_focusNode.requestFocus();

只表示请求焦点。对于普通 TextField,它通常会进一步触发软键盘;但对于只处理快捷键的 Focus,不会自动弹出文本键盘。

3. 无障碍焦点

TalkBack、VoiceOver 等辅助技术有自己的可访问性导航状态。它们会通过 Semantics 树读取控件,并维护辅助技术层面的焦点。

例如:

  • 键盘用户按 Tab 移动的是 Flutter 键盘焦点;
  • TalkBack 用户滑动屏幕移动的是无障碍焦点;
  • SemanticslabelbuttontextField 等信息影响辅助技术如何描述控件;
  • 一个控件可以获得无障碍焦点,但不一定成为当前文本编辑目标。

所以,解决“键盘 Tab 无法移动”的问题,不能只检查 Semantics;解决“屏幕阅读器读错控件”的问题,也不能只检查 FocusNode


二、FocusNode、FocusScopeNode 和 FocusManager

1. FocusNode 是焦点树中的节点

FocusNode 是一个可参与焦点系统的对象。它通常由 StatefulWidget 持有,并传给 FocusTextField 等 Widget:

class LoginPageState extends State<LoginPage> {
  late final FocusNode emailFocusNode;
  late final FocusNode passwordFocusNode;

  @override
  void initState() {
    super.initState();
    emailFocusNode = FocusNode(debugLabel: 'email');
    passwordFocusNode = FocusNode(debugLabel: 'password');
  }

  @override
  void dispose() {
    emailFocusNode.dispose();
    passwordFocusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(focusNode: emailFocusNode),
        TextField(focusNode: passwordFocusNode),
      ],
    );
  }
}

这里有两个重要生命周期规则:

  • 不要在 build 中反复创建 FocusNode
  • 自己创建并持有的 FocusNode 必须在 dispose 中释放。

错误写法:

@override
Widget build(BuildContext context) {
  return TextField(
    focusNode: FocusNode(), // 每次 build 都创建新节点
  );
}

这样会导致焦点状态丢失、节点不断产生和分离,并可能造成资源泄漏或焦点行为不稳定。

2. FocusAttachment 连接节点与 Widget 位置

FocusNode 本身是一个长期存在的对象,而 Widget 可能因为重建、移动或销毁而改变位置。Flutter 通过 FocusAttachment 把节点附着到 Widget 树中的 BuildContext

手动使用焦点节点时,通常不需要直接管理 FocusAttachment,因为 Focus Widget 会完成附着和重新附着:

Focus(
  focusNode: emailFocusNode,
  child: const TextField(),
)

在更底层的自定义 RenderObject 或焦点组件中,才可能直接调用:

final FocusAttachment attachment = focusNode.attach(context);
attachment.reparent();
attachment.detach();

reparent() 的意义是让焦点节点知道自己当前对应的上下文位置。如果手动管理附着关系却没有在布局变化后重新附着,焦点遍历的几何位置可能不正确。

3. FocusScopeNode 是焦点范围

FocusScopeNode 表示一个焦点范围。每个路由通常有自己的焦点范围,表单、弹窗、菜单也经常建立局部焦点范围。

焦点范围会记录其范围内的焦点历史。例如:

  1. 邮箱输入框获得焦点;
  2. 密码输入框获得焦点;
  3. 密码输入框被移除;
  4. 返回该范围时,焦点系统可能恢复到之前可用的节点。

常见操作:

final scope = FocusScope.of(context);

// 把焦点移动到下一个可遍历节点
scope.nextFocus();

// 移动到上一个节点
scope.previousFocus();

// 清除当前范围中的焦点
scope.unfocus();

// 请求范围本身获得焦点
scope.requestFocus();

unfocus() 并不等于“关闭键盘”的低层命令。对文本输入框来说,失去焦点通常会导致输入连接关闭,从而让系统隐藏软键盘,但最终表现仍受平台和当前输入连接状态影响。

4. FocusManager 是全局焦点管理器

FocusManager.instance 管理应用级焦点树,并维护 primaryFocus

final FocusNode? node = FocusManager.instance.primaryFocus;

primaryFocus 是当前主焦点节点。调试时可以使用:

debugDumpFocusTree();

输出焦点树,检查:

  • 哪个节点是当前焦点;
  • 节点是否被 canRequestFocus 禁止;
  • 节点是否被 skipTraversal 排除;
  • 焦点范围的父子关系;
  • 是否存在已经失效但仍被引用的节点。

primaryFocus 为空并不一定是错误。例如页面刚打开、弹窗关闭或应用主动取消焦点时,都可能暂时没有主焦点。


三、请求焦点与焦点变化时序

requestFocus() 是请求,不是同步强制赋值。典型流程如下:

调用 requestFocus()
        │
        ▼
Flutter 更新焦点树
        │
        ▼
旧节点收到失焦通知
        │
        ▼
新节点收到获焦通知
        │
        ▼
TextField 可能建立 TextInputConnection
        │
        ▼
平台决定是否显示软键盘

可以监听焦点变化:

late final FocusNode searchFocusNode;

@override
void initState() {
  super.initState();
  searchFocusNode = FocusNode();

  searchFocusNode.addListener(() {
    if (searchFocusNode.hasFocus) {
      debugPrint('搜索框获得焦点');
    } else {
      debugPrint('搜索框失去焦点');
    }
  });
}

@override
void dispose() {
  searchFocusNode.dispose();
  super.dispose();
}

如果页面刚创建就请求焦点,通常应在第一帧之后进行:

@override
void initState() {
  super.initState();

  WidgetsBinding.instance.addPostFrameCallback((_) {
    if (!mounted) return;
    searchFocusNode.requestFocus();
  });
}

原因是:在 initState 期间,Widget 可能还没有完成挂载,焦点节点尚未附着到正确的上下文。直接请求有时仍然有效,但依赖布局结果或路由动画时容易产生时序问题。


四、FocusNode 的关键属性

1. canRequestFocus

Focus(
  canRequestFocus: false,
  child: const Text('不可获得键盘焦点'),
)

canRequestFocus: false 表示该节点不能主动成为焦点目标。它通常也会影响遍历行为。

适用场景包括:

  • 禁用中的控件;
  • 只展示内容的装饰节点;
  • 暂时不可交互的区域。

不要用它替代业务层的 enabled: falsecanRequestFocus 只控制焦点能力,不会自动禁用按钮动作、改变颜色或阻止点击。

2. skipTraversal

Focus(
  skipTraversal: true,
  child: const Icon(Icons.info),
)

skipTraversal: true 表示该节点不参与 Tab、方向键等自动遍历,但仍可能通过显式 requestFocus() 获得焦点。

这与 canRequestFocus: false 不同:

属性 能否显式请求焦点 是否参与自动遍历
skipTraversal: true 通常可以
canRequestFocus: false

3. onKeyEvent

FocusNode.onKeyEvent 用于处理发送到该节点的键盘事件:

final FocusNode node = FocusNode(
  debugLabel: 'editor',
  onKeyEvent: (FocusNode node, KeyEvent event) {
    if (event is KeyDownEvent &&
        event.logicalKey == LogicalKeyboardKey.escape) {
      node.unfocus();
      return KeyEventResult.handled;
    }

    return KeyEventResult.ignored;
  },
);

返回值决定事件是否继续传播:

  • KeyEventResult.handled:当前节点已处理,停止向上层传播;
  • KeyEventResult.ignored:当前节点不处理,继续交给焦点树中的祖先节点;
  • KeyEventResult.skipRemainingHandlers:跳过剩余处理器,但具体传播结果应根据当前 Flutter 版本的 API 文档和场景确认。

常见错误是无条件返回 handled

onKeyEvent: (_, event) {
  doSomething();
  return KeyEventResult.handled;
}

这样会吞掉回车、方向键、文本编辑键等本应由 TextField 或系统处理的事件。只有确定当前节点已经消费该事件时,才返回 handled


五、键盘事件:PhysicalKey、LogicalKey 和 KeyEvent

Flutter 的键盘 API 同时区分物理按键和逻辑按键。

1. 物理按键

PhysicalKeyboardKey 表示键盘上的物理位置。例如不同键盘布局中,物理位置相同的键可能产生不同字符。

它适合:

  • 游戏控制;
  • 需要识别 WASD 物理位置的场景;
  • 不希望受键盘布局影响的快捷控制。

2. 逻辑按键

LogicalKeyboardKey 表示操作意义,例如:

LogicalKeyboardKey.enter
LogicalKeyboardKey.escape
LogicalKeyboardKey.arrowDown
LogicalKeyboardKey.keyS

它适合:

  • “按 Enter 提交”;
  • “按 Escape 关闭”;
  • “按 Ctrl/Command + S 保存”。

3. KeyEvent 类型

当前 Flutter 键盘事件模型使用 KeyEvent 及其子类,例如:

  • KeyDownEvent:按键按下;
  • KeyUpEvent:按键释放;
  • KeyRepeatEvent:按住按键后产生的重复事件。

因此,不应把每次 KeyEvent 都当成一次独立按下:

if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
  moveDown();
}

如果需要只在第一次按下时执行,应判断:

if (event is KeyDownEvent &&
    event.logicalKey == LogicalKeyboardKey.arrowDown) {
  moveDown();
}

文本输入框一般不应自行处理字符拼接。平台输入法、组合文本和编辑命令都可能使“一个物理按键对应一个字符”的假设失效。


六、Shortcuts 与 Actions:把按键转换为命令

直接在 onKeyEvent 中写业务逻辑适合简单局部行为,但复杂应用通常应将“按键”与“动作”分离:

KeyEvent
  │
  ▼
Shortcuts:匹配 ShortcutActivator
  │
  ▼
Intent:描述用户想做什么
  │
  ▼
Actions:找到并执行对应 Action
  │
  ▼
业务逻辑

1. Intent 描述意图

class SaveFormIntent extends Intent {
  const SaveFormIntent();
}

class ClearFocusIntent extends Intent {
  const ClearFocusIntent();
}

Intent 不描述具体按键,而描述“保存表单”“清除焦点”等语义。

2. SingleActivator 描述快捷键

SingleActivator 可以表达一个逻辑按键和修饰键组合:

const <ShortcutActivator, Intent>{
  SingleActivator(
    LogicalKeyboardKey.keyS,
    control: true,
  ): SaveFormIntent(),
}

在 macOS 上,用户通常习惯 Command,而不是 Control。可以根据平台配置:

final bool isApple = switch (defaultTargetPlatform) {
  TargetPlatform.macOS || TargetPlatform.iOS => true,
  _ => false,
};

final shortcuts = <ShortcutActivator, Intent>{
  SingleActivator(
    LogicalKeyboardKey.keyS,
    control: !isApple,
    meta: isApple,
  ): const SaveFormIntent(),
};

这段代码需要导入:

import 'package:flutter/foundation.dart';

注意:LogicalKeyboardKey.keyS 表示逻辑上的 S 键,不是字符输入结果。用户使用中文输入法时,快捷键与正在提交的文本之间还会受到 IME 状态影响,后文会说明。

3. Actions 执行动作

下面是一个可以运行的完整示例。它实现:

  • 邮箱和密码输入;
  • Tab 在输入框和按钮之间遍历;
  • Enter 由 TextFieldtextInputAction 进入下一项;
  • Escape 清除焦点;
  • Control/Command + S 触发表单保存;
  • 不在 onKeyEvent 中手动拼接文本。
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

class SaveFormIntent extends Intent {
  const SaveFormIntent();
}

class ClearFocusIntent extends Intent {
  const ClearFocusIntent();
}

void main() {
  runApp(const MaterialApp(home: LoginPage()));
}

class LoginPage extends StatefulWidget {
  const LoginPage({super.key});

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final emailController = TextEditingController();
  final passwordController = TextEditingController();

  final emailFocusNode = FocusNode(debugLabel: 'email');
  final passwordFocusNode = FocusNode(debugLabel: 'password');
  final submitFocusNode = FocusNode(debugLabel: 'submit');

  late final Map<ShortcutActivator, Intent> shortcuts;

  @override
  void initState() {
    super.initState();

    final isApple = switch (defaultTargetPlatform) {
      TargetPlatform.macOS || TargetPlatform.iOS => true,
      _ => false,
    };

    shortcuts = {
      SingleActivator(
        LogicalKeyboardKey.keyS,
        control: !isApple,
        meta: isApple,
      ): const SaveFormIntent(),
      const SingleActivator(
        LogicalKeyboardKey.escape,
      ): const ClearFocusIntent(),
    };
  }

  @override
  void dispose() {
    emailController.dispose();
    passwordController.dispose();

    emailFocusNode.dispose();
    passwordFocusNode.dispose();
    submitFocusNode.dispose();

    super.dispose();
  }

  void save() {
    final email = emailController.text.trim();
    final password = passwordController.text;

    debugPrint('save: email=$email, passwordLength=${password.length}');

    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('已提交')),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Shortcuts(
      shortcuts: shortcuts,
      child: Actions(
        actions: <Type, Action<Intent>>{
          SaveFormIntent: CallbackAction<SaveFormIntent>(
            onInvoke: (_) {
              save();
              return null;
            },
          ),
          ClearFocusIntent: CallbackAction<ClearFocusIntent>(
            onInvoke: (_) {
              FocusScope.of(context).unfocus();
              return null;
            },
          ),
        },
        child: FocusTraversalGroup(
          policy: ReadingOrderTraversalPolicy(),
          child: Scaffold(
            appBar: AppBar(title: const Text('登录')),
            body: Padding(
              padding: const EdgeInsets.all(24),
              child: Column(
                children: [
                  TextField(
                    controller: emailController,
                    focusNode: emailFocusNode,
                    keyboardType: TextInputType.emailAddress,
                    textInputAction: TextInputAction.next,
                    onSubmitted: (_) {
                      passwordFocusNode.requestFocus();
                    },
                    decoration: const InputDecoration(
                      labelText: '邮箱',
                    ),
                  ),
                  const SizedBox(height: 16),
                  TextField(
                    controller: passwordController,
                    focusNode: passwordFocusNode,
                    obscureText: true,
                    textInputAction: TextInputAction.done,
                    onSubmitted: (_) {
                      save();
                    },
                    decoration: const InputDecoration(
                      labelText: '密码',
                    ),
                  ),
                  const SizedBox(height: 16),
                  Focus(
                    focusNode: submitFocusNode,
                    child: FilledButton(
                      onPressed: save,
                      child: const Text('提交'),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ),
      ),
    );
  }
}

4. 这个示例的事件路径

以桌面端按下 Command/Control + S 为例:

  1. 操作系统产生按键事件;
  2. Flutter 将其转换为 KeyEvent
  3. 当前主焦点及其祖先节点有机会处理事件;
  4. Shortcuts 查找 SingleActivator
  5. 匹配到 SaveFormIntent
  6. Actions 找到 SaveFormIntent 对应的 CallbackAction
  7. 调用 save()
  8. 业务代码读取 TextEditingController 当前值并提交。

如果快捷键没有生效,诊断顺序应是:

  1. 当前是否有主焦点;
  2. 当前节点是否吞掉了事件;
  3. Shortcuts 是否位于当前焦点节点的可见祖先路径上;
  4. 修饰键是否使用了正确的平台组合;
  5. 浏览器或操作系统是否保留了该快捷键;
  6. 当前是否处于输入法组合状态。

5. Shortcuts 不等于全局系统快捷键

Shortcuts 只在 Flutter 的 Widget 范围内匹配。它不能保证:

  • 截获操作系统级快捷键;
  • 截获浏览器保留的快捷键;
  • 在 Flutter 窗口失去激活状态时继续工作;
  • 在所有平台覆盖系统菜单行为。

例如 Web 浏览器可能保留某些组合键,桌面系统也可能优先处理系统级快捷键。应用不应把 Ctrl/Command + W、浏览器导航键等作为无条件可覆盖的业务快捷键。


七、焦点遍历:Tab、方向键和遍历策略

1. 遍历不是简单的 Widget 顺序

焦点遍历是从当前焦点出发,找到同一焦点范围中下一个合格节点的过程。节点是否合格至少受这些条件影响:

  • 节点已附着到焦点树;
  • 节点允许获得焦点;
  • 节点没有被 skipTraversal 排除;
  • 所属 Widget 处于可用的焦点范围;
  • 当前遍历策略认为它是下一个目标。

可以形式化地表示为:

候选节点集合 C =
  所有属于当前 FocusScope 的节点
  ∩ 可附着节点
  ∩ canRequestFocus 为 true
  ∩ skipTraversal 为 false

遍历策略 P 再根据方向、布局位置或显式顺序,从 C 中选择目标:

next = P(current, C, direction)

因此,“Widget 在代码中写得靠后”不一定意味着它在所有平台上都最后获得焦点。

2. ReadingOrderTraversalPolicy

FocusTraversalGroup(
  policy: ReadingOrderTraversalPolicy(),
  child: ...,
)

它主要根据界面布局中的阅读顺序排列节点。对于表单和普通页面,这通常比单纯的 Widget 树顺序更符合用户预期。

但阅读顺序依赖布局和几何位置。响应式布局在窄屏和宽屏下改变排列方式时,Tab 顺序也可能改变。

3. WidgetOrderTraversalPolicy

它倾向于按照 Widget 树中的顺序遍历,适合希望焦点顺序与构建顺序一致的场景,但复杂布局中未必符合视觉阅读顺序。

4. OrderedTraversalPolicy

需要严格定义顺序时,可以使用 FocusTraversalOrder

FocusTraversalGroup(
  policy: OrderedTraversalPolicy(),
  child: Column(
    children: [
      FocusTraversalOrder(
        order: const NumericFocusOrder(1),
        child: const TextField(
          decoration: InputDecoration(labelText: '姓名'),
        ),
      ),
      FocusTraversalOrder(
        order: const NumericFocusOrder(2),
        child: const TextField(
          decoration: InputDecoration(labelText: '电话'),
        ),
      ),
    ],
  ),
)

这里的约束是:

  1. 所有参与同一组排序的节点使用兼容的 FocusOrder
  2. 顺序值能稳定比较;
  3. 动态列表插入和删除后,顺序值仍然符合业务要求。

如果在同一排序组中混用不兼容的排序类型,可能产生断言或未达到预期的排序结果。

5. FocusTraversalGroup 的作用范围

FocusTraversalGroup 不会创建一个完全隔离的焦点世界。它主要为其子树指定遍历策略和排序边界。焦点仍可能通过作用域规则移动到外层节点,除非组件使用了更强的焦点范围约束。

对模态对话框而言,通常还需要确认:

  • 打开对话框时焦点是否移入对话框;
  • Tab 是否会离开对话框;
  • 关闭后焦点是否回到触发按钮;
  • 对话框内是否存在可遍历节点。

仅仅把内容放进 Dialog,不应被当成完整的焦点陷阱实现。

6. 方向键遍历

桌面和 Web 经常使用 Tab;电视、车机、游戏设备或某些无障碍场景可能使用方向键。方向键遍历通常依赖焦点节点的矩形位置和方向策略,而不是简单地执行 nextFocus()

一个常见失败原因是:

  • 节点没有尺寸;
  • 节点位置尚未完成布局;
  • 自定义绘制内容没有提供对应的 Focus
  • 节点虽然显示,但 canRequestFocus 为 false。

八、TextField、EditableText 与平台输入法

1. TextField 的数据流

一个文本输入框的核心数据流可以表示为:

用户操作软键盘/硬件键盘
          │
          ▼
Android / iOS / Desktop / Browser IME
          │
          ▼
TextInputConnection
          │
          ▼
EditableText.onChanged
          │
          ▼
TextEditingController.value
          │
          ▼
TextField 重建显示

TextEditingController 保存的不是单纯字符串,而是:

TextEditingValue(
  text: 'abc',
  selection: TextSelection(...),
  composing: TextRange(...),
)

三个部分分别表示:

  • text:当前文本;
  • selection:选区和光标;
  • composing:输入法正在组合的文本范围。

2. 为什么必须理解 composing

用户使用中文、日文、韩文等输入法时,输入过程可能是:

键盘输入拼音:nihao
        │
        ▼
输入法显示候选词,composing 范围存在
        │
        ▼
用户选择“你好”
        │
        ▼
composing 范围提交或清除

在 composing 阶段,controller.text 可能还不是最终用户想要的文字。应用如果在监听器中无条件改写文本,可能打断候选词选择:

controller.addListener(() {
  controller.text = controller.text.toUpperCase();
});

这类代码有两个风险:

  1. 修改 text 会重置或改变光标、选区;
  2. 修改 composing 区域可能与输入法互相触发更新,造成光标跳动、候选栏消失或循环更新。

需要对文本做规范化时,应明确选择时机:

  • 用户输入过程中只做不破坏 composing 的处理;
  • 在提交时使用 controller.text.trim()
  • 在明确知道编辑状态安全时,同时设置 valueselectioncomposing
  • 不要在每一次平台回调中粗暴重写整个值。

3. onChangedcontrolleronSubmitted 的职责

TextField(
  controller: controller,
  onChanged: (value) {
    // 适合更新搜索提示、校验状态等
  },
  onSubmitted: (value) {
    // 用户触发输入法动作,例如 done、search、next
  },
)
  • onChanged 表示编辑值发生变化;
  • controller 允许读取或修改完整的 TextEditingValue
  • onSubmitted 表示输入法提交了一个动作,不等于每个平台都发送了物理 Enter;
  • onEditingComplete 可以控制默认的编辑完成行为。

例如 TextInputAction.next 通常表示“进入下一个输入框”,但是否由应用自动移动焦点、是否由开发者在 onSubmitted 中显式调用 nextFocus(),应由应用逻辑决定:

TextField(
  textInputAction: TextInputAction.next,
  onSubmitted: (_) {
    FocusScope.of(context).nextFocus();
  },
)

这段代码在表单中很常见,但不要对最后一个字段也无条件调用 nextFocus()。如果没有下一个节点,焦点可能保持不变或转移到范围中的其他节点,表现会因布局而不符合预期。


九、键盘类型、输入法动作与平台差异

1. keyboardType 是输入提示,不是验证器

TextField(
  keyboardType: TextInputType.emailAddress,
)

它主要告诉平台应该显示什么样的输入界面或启用什么输入模式。它不能保证用户一定输入了合法邮箱。

正确的职责分工是:

  • keyboardType:改善输入体验;
  • inputFormatters:限制或转换部分编辑输入;
  • 业务校验:判断最终值是否有效;
  • 服务端校验:防止客户端绕过限制。

2. textInputAction 是编辑动作

常见值包括:

TextInputAction.next
TextInputAction.done
TextInputAction.search
TextInputAction.go

移动端通常将其显示为软键盘右下角的动作键。桌面端没有完全对应的软键盘按钮,因此不能假设所有平台都会以相同视觉形式表现。

3. Android

Android 输入法可能:

  • 根据 keyboardTypetextInputAction 改变键盘布局;
  • 发送组合文本更新;
  • 在窗口尺寸调整或平移时影响布局;
  • 使用返回键作为编辑动作或导航行为。

Scaffold.resizeToAvoidBottomInset 会影响键盘出现时页面是否调整可用高度,但它不负责焦点管理。键盘弹出后内容被遮挡,通常要结合布局、滚动容器和 viewInsets 处理,而不是反复调用 requestFocus()

4. iOS

iOS 对输入法候选、自动纠错、文本预测和键盘收起有自己的行为。TextInputAction 会映射到系统键盘动作,但并非每个动作在所有输入类型下都能显示。

对于“点击空白处关闭键盘”,常见写法是:

GestureDetector(
  onTap: () => FocusScope.of(context).unfocus(),
  child: const ...,
)

但要注意手势竞争:外层手势可能与 TextField、按钮或滚动组件冲突。若外层点击处理不当,可能出现按钮点击失效或输入框无法正常选择文本。

5. 桌面端

Windows、macOS 和 Linux 通常有硬件键盘,因此:

  • Tab 焦点遍历更重要;
  • Control/Command 修饰键不同;
  • 系统文本编辑快捷键可能由 Flutter 文本编辑控件处理;
  • 窗口级快捷键可能与操作系统或桌面环境冲突;
  • 没有移动端那种始终可见的软键盘。

桌面端的“提交”通常来自 Enter、快捷键或按钮,而不是 TextInputAction 的视觉按钮。

6. Web

Flutter Web 运行在浏览器中,可能受到以下因素影响:

  • 浏览器保留快捷键;
  • 浏览器地址栏、页面搜索、标签页切换优先级更高;
  • 不同渲染器和浏览器对文本选择、组合输入的行为可能不同;
  • 页面焦点可能位于 Flutter Canvas 之外的 DOM 元素;
  • 浏览器自动填充、密码管理器和原生输入行为会参与最终结果。

因此,Web 上测试快捷键必须覆盖至少一个 Chromium 系浏览器和一个 WebKit 或 Gecko 系浏览器,不能只在 Flutter desktop 模拟器中验证。


十、软键盘的显示、隐藏与焦点恢复

1. 显示键盘的正常方式

对文本框,优先通过焦点请求触发输入连接:

emailFocusNode.requestFocus();

不应把直接调用系统通道或平台专用 API 作为普通业务流程的首选。因为键盘显示依赖:

  • 当前 Widget 是否已挂载;
  • 当前窗口是否处于激活状态;
  • 是否发生在用户手势上下文中;
  • 平台是否允许应用主动弹出键盘;
  • 当前节点是否为可编辑文本节点。

2. 隐藏键盘的正常方式

FocusScope.of(context).unfocus();

它的语义是移除当前焦点。对 TextField 来说,通常也会关闭软键盘。

如果页面中有多个焦点范围,应从正确的 BuildContext 调用,否则可能只影响局部范围而不是预期的输入框。

3. 页面切换后的焦点恢复

假设一个页面打开搜索框并保存了原焦点:

final previousFocus = FocusManager.instance.primaryFocus;

关闭覆盖层后直接调用:

previousFocus?.requestFocus();

并不总是安全,因为原节点可能已经:

  • 被路由销毁;
  • 被列表重建;
  • 被设置为不可请求焦点;
  • 从当前 FocusScope 分离。

生产代码应在恢复前确认节点仍然有效,并根据页面状态在合适的帧中请求焦点。


十一、焦点与验证、滚动、异步操作的关系

1. 校验失败后移动焦点

表单提交时,不应只弹出错误文字,还应把焦点移动到第一个无效字段:

bool validateAndSubmit() {
  final email = emailController.text.trim();

  if (email.isEmpty) {
    emailFocusNode.requestFocus();
    return false;
  }

  save();
  return true;
}

如果字段位于长列表中,仅请求焦点可能导致它仍然在屏幕外。此时需要让 Scrollable 先滚动到目标,再请求焦点,或使用 Scrollable.ensureVisible

final context = emailFocusNode.context;
if (context != null) {
  await Scrollable.ensureVisible(
    context,
    duration: const Duration(milliseconds: 250),
  );
}
emailFocusNode.requestFocus();

这里的 context 只有在节点通过 Focus 附着并且相关 Widget 仍在树中时才可用。

2. 异步提交期间不要随意销毁节点

提交逻辑可能是异步的:

Future<void> submit() async {
  final value = emailController.text.trim();

  setState(() {
    // 设置 loading
  });

  try {
    await repository.login(value);
    if (!mounted) return;
    FocusScope.of(context).unfocus();
  } finally {
    if (!mounted) return;
    setState(() {
      // 取消 loading
    });
  }
}

await 之后必须检查 mounted,否则页面已经销毁时继续操作 context、调用 setState 或恢复焦点,可能造成异常。

3. loading 状态会改变焦点候选集合

如果提交时把输入框替换成进度指示器,原 FocusNode 可能暂时脱离焦点树。此时在异步完成后恢复焦点,必须基于当前 Widget 树重新判断,而不是假设旧节点仍然存在。


十二、常见失败表现与诊断路径

失败一:焦点每次重建后消失

表现:

  • 输入一个字符后光标消失;
  • TextField 失去焦点;
  • Tab 顺序不断重置。

原因:

  • build 中创建 FocusNode
  • 在列表中没有稳定的 Widget key,导致输入项被错误复用;
  • 条件渲染移除了当前焦点节点。

诊断:

debugPrint(
  'primary=${FocusManager.instance.primaryFocus?.debugLabel}',
);

并检查节点是否由 State 长期持有。

失败二:快捷键触发两次

表现:

  • 按一次 Enter 保存两次;
  • 自定义 onKeyEventShortcuts 同时执行。

原因:

  • 自定义处理器返回 ignored,导致祖先继续处理;
  • TextField.onSubmittedonKeyEvent 都调用了保存逻辑;
  • KeyDownEventKeyRepeatEvent 都被当作一次操作。

修复思路:

  • 明确哪个层负责命令;
  • 消费事件后返回 handled
  • 对只允许一次的行为判断事件类型;
  • 不要同时在物理键处理和文本提交回调中重复执行业务操作。

失败三:中文输入时快捷键误触发

表现:

  • 正在输入拼音时按某个组合键,表单被提交;
  • 候选词突然消失;
  • 文本被截断或光标跳动。

原因:

输入法处于 composing 状态时,硬件键盘事件既可能参与输入法处理,也可能被 Flutter 快捷键匹配。不同平台和输入法的优先级并不完全一致。

处理方式:

  • 不要把普通字母键设计成无修饰快捷键;
  • 对保存、关闭等命令使用明确修饰键;
  • 对文本编辑区域谨慎拦截事件;
  • 必要时根据当前编辑值的 composing 状态决定是否执行命令;
  • 不要在 TextEditingController 监听器中无条件改写组合文本。

失败四:Tab 无法到达按钮

可能原因:

  • 按钮使用了 onPressed: null,因此不可用;
  • 外层 Focus 设置了 canRequestFocus: false
  • 设置了 skipTraversal: true
  • 自定义控件只有绘制,没有建立 Focus
  • 当前按钮在另一个焦点范围中;
  • 事件被某个节点错误地返回 handled

诊断:

debugDumpFocusTree();

检查按钮是否出现在焦点树中,并观察其焦点属性。

失败五:弹窗关闭后焦点跑到页面顶部

原因:

  • 没有保存并恢复打开弹窗前的焦点;
  • 原节点已被列表重建;
  • 弹窗关闭后默认焦点策略重新选择了第一个节点;
  • 路由或 Overlay 的焦点范围发生了变化。

解决方向:

  • 在打开弹窗前记录业务上应恢复的节点;
  • 关闭后确认节点仍在树中;
  • 必要时用 addPostFrameCallback 恢复;
  • 对动态列表使用稳定 key 和稳定的节点归属。

十三、自定义可聚焦控件的完整思路

如果控件不是 TextFieldButton 等已有交互组件,而是自定义绘制控件,需要同时考虑焦点、键盘、视觉状态和无障碍语义:

class FocusableCard extends StatefulWidget {
  const FocusableCard({super.key});

  @override
  State<FocusableCard> createState() => _FocusableCardState();
}

class _FocusableCardState extends State<FocusableCard> {
  late final FocusNode focusNode;

  @override
  void initState() {
    super.initState();

    focusNode = FocusNode(
      debugLabel: 'custom-card',
      onKeyEvent: (node, event) {
        if (event is KeyDownEvent &&
            event.logicalKey == LogicalKeyboardKey.enter) {
          activate();
          return KeyEventResult.handled;
        }
        return KeyEventResult.ignored;
      },
    );
  }

  void activate() {
    debugPrint('card activated');
  }

  @override
  void dispose() {
    focusNode.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Semantics(
      button: true,
      label: '自定义卡片',
      onTap: activate,
      child: Focus(
        focusNode: focusNode,
        onFocusChange: (_) => setState(() {}),
        child: GestureDetector(
          onTap: () {
            focusNode.requestFocus();
            activate();
          },
          child: DecoratedBox(
            decoration: BoxDecoration(
              border: Border.all(
                color: focusNode.hasFocus
                    ? Theme.of(context).colorScheme.primary
                    : Colors.grey,
              ),
            ),
            child: const Padding(
              padding: EdgeInsets.all(16),
              child: Text('卡片'),
            ),
          ),
        ),
      ),
    );
  }
}

这个控件要同时满足:

  1. FocusNode 允许键盘焦点进入;
  2. 获焦时提供清晰的视觉反馈;
  3. Enter 等键可以执行与点击相同的语义动作;
  4. 鼠标或触摸点击时主动请求焦点;
  5. Semantics 告知辅助技术它是可操作控件;
  6. dispose 时释放节点;
  7. 键盘处理器只消费自己真正处理的事件。

如果只实现第 3 点而不实现第 2、4、5 点,控件可能“能用”,但键盘用户、触摸用户和无障碍用户看到的行为不一致。


十四、生产取舍:显式焦点还是自动遍历

使用显式 requestFocus() 的场景

适合:

  • 表单提交后进入下一个字段;
  • 校验失败定位到第一个错误;
  • 打开搜索页后聚焦搜索框;
  • 弹窗打开后聚焦默认操作项。

显式焦点的优点是行为确定;缺点是节点生命周期和异步时序需要自己维护。

使用自动遍历的场景

适合:

  • 普通表单;
  • 桌面端 Tab 导航;
  • 动态但结构稳定的设置页面;
  • 希望适配不同布局方向的页面。

自动遍历降低了维护成本,但依赖焦点树结构、布局位置和遍历策略。复杂页面通常需要 FocusTraversalGroup 与显式排序配合。

不要把遍历顺序当成业务顺序

遍历顺序是交互导航顺序,不一定等于:

  • 表单校验顺序;
  • 数据提交顺序;
  • 视觉层级;
  • Widget 构建顺序。

这些顺序应分别建模。把它们强行绑定,往往会导致动态表单、响应式布局和无障碍导航互相牵制。


十五、验证焦点与输入法行为的方法

Android、iOS

至少验证:

  • 点击输入框是否获得焦点;
  • 页面初始自动聚焦是否符合平台用户预期;
  • 键盘出现时字段是否被遮挡;
  • nextdonesearch 动作是否正确;
  • 中文输入法候选词提交是否正常;
  • 点击外部区域是否会意外触发按钮;
  • 路由返回后焦点是否恢复。

桌面端

至少验证:

  • Tab 和 Shift + Tab 顺序;
  • Enter、Escape 和方向键;
  • Windows/Linux 的 Control 与 macOS 的 Command;
  • 输入框中的复制、粘贴、撤销;
  • 快捷键是否与系统或桌面环境冲突;
  • 窗口重新获得激活状态后焦点是否仍合理。

Web

至少验证:

  • 浏览器页面焦点是否进入 Flutter 内容;
  • Tab 是否能从 Flutter 内容继续移动;
  • 浏览器保留快捷键是否覆盖应用快捷键;
  • 中文输入法和自动填充;
  • 浏览器缩放、响应式布局变化后遍历顺序;
  • 多个 DOM 或 Flutter Web 入口之间的焦点转移。

结语

Flutter 的焦点与键盘处理可以按四层理解:

FocusNode / FocusScopeNode
    负责“谁拥有焦点”

KeyEvent
    负责“键盘发生了什么”

Shortcuts / Actions
    负责“这个按键代表什么命令”

EditableText / TextInputConnection / IME
    负责“文本如何被编辑、组合和提交”

FocusNode 解决焦点归属,焦点遍历解决导航路径,ShortcutsActions 解决命令映射,输入法系统解决文本编辑。只有把这几层分开,才能正确处理移动端软键盘、桌面硬件键盘、Web 浏览器限制以及无障碍导航之间的差异。


系列导航与关联阅读

官方资料

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