跳转至正文

理解 Flutter 的键盘焦点系统

如何在你的 Flutter 应用中使用焦点系统。

本篇文档说明如何控制键盘输入的指向。若你正在实现使用物理键盘的应用(例如大多数桌面和 Web 应用),本篇文档适合你。若你的应用不会配合物理键盘使用,可以跳过本文。

概览

#

Flutter 自带焦点系统,将键盘输入导向应用的特定部分。为此,用户通过点击或点按所需的 UI 元素,将输入「焦点」到应用的该部分。之后,键盘输入的文字会流向该部分,直到焦点移到应用的其他部分。焦点也可以通过按下特定键盘快捷键来移动,通常绑定到 Tab,因此有时称为「Tab 遍历」(tab traversal)。

本页探讨在 Flutter 应用中执行这些操作所用的 API,以及焦点系统的工作原理。我们注意到开发者在如何定义和使用 FocusNode 对象方面存在一些困惑。若你也有类似经历,请跳转到 创建 FocusNode 对象的最佳实践

焦点使用场景

#

以下是你可能需要了解如何使用焦点系统的一些场景示例:

术语表

#

以下是 Flutter 对焦点系统各元素的术语定义。实现其中部分概念的各种类将在下文介绍。

  • 焦点树 (Focus tree) - 由焦点节点构成的树,通常稀疏地映射 widget 树,代表所有可以获得焦点的 widget。

  • 焦点节点 (Focus node) - 焦点树中的单个节点。该节点可以获得焦点;当它处于焦点链中时,称其「拥有焦点」。只有在拥有焦点时,它才参与处理按键事件。

  • 主焦点 (Primary focus) - 焦点树中距树根最远且拥有焦点的焦点节点。按键事件正是从这个节点开始,向主焦点节点及其祖先传播。

  • 焦点链 (Focus chain) - 由焦点节点组成的有序列表,从主焦点节点开始,沿焦点树的分支一直延伸到焦点树的根。

  • 焦点作用域 (Focus scope) - 一种特殊的焦点节点,其职责是容纳一组其他焦点节点,并只允许这些节点获得焦点。它保存着其子树中先前哪些节点曾获得焦点的信息。

  • 焦点遍历 (Focus traversal) - 以可预测的顺序从一个可获得焦点的节点移动到另一个节点的过程。这通常出现在用户按下 Tab 移到下一个可获得焦点的控件或字段时。

FocusNode 与 FocusScopeNode

#

FocusNodeFocusScopeNode 对象实现焦点系统的机制。它们是长生命周期对象(比 widget 更持久,类似 render object),保存焦点状态与属性,从而在 widget 树多次构建之间保持持久。它们共同构成焦点树数据结构。

它们最初旨在作为面向开发者的对象,用于控制焦点系统的某些方面,但随着时间推移,已大多演变为实现焦点系统细节。为避免破坏现有应用,它们仍保留属性的公开接口。但一般而言,它们最有用的用途是作为相对不透明的句柄,传给子 widget,以便在祖先 widget 上调用 requestFocus(),请求子 widget 获得焦点。其他属性的设置最好由 FocusFocusScope widget 管理,除非你未使用它们,或正在实现自己的版本。

创建 FocusNode 对象的最佳实践

#

使用这些对象时的一些建议与禁忌包括:

  • 不要在每次 build 时分配新的 FocusNode。这可能导致内存泄漏,且当节点拥有焦点时 widget 重建偶尔会导致失去焦点。

  • 应在有状态 widget 中创建 FocusNodeFocusScopeNode 对象。使用完毕后需要 dispose,因此应只在有状态 widget 的 state 对象内创建,以便在 dispose 中释放它们。

  • 不要对多个 widget 使用同一个 FocusNode。否则 widget 会争夺节点属性的管理权,结果往往不符合预期。

  • 应设置焦点节点 widget 的 debugLabel,以便诊断焦点问题。

  • FocusNodeFocusScopeNodeFocusFocusScope widget 管理,不要在其上设置 onKeyEvent 回调。若需要 onKeyEvent 处理器,在你想监听的 widget 子树外再包一层 Focus widget,并将该 widget 的 onKeyEvent 属性设为你的处理器。若你也不希望它能获得主焦点,将 widget 的 canRequestFocus 设为 false。这是因为 Focus widget 的 onKeyEvent 属性可能在后续 build 中被设为其他值,从而覆盖你在节点上设置的 onKeyEvent 处理器。

  • 应在节点上调用 requestFocus() 以请求其获得主焦点,尤其是从已将自有节点传给子代的祖先处,在你希望获得焦点的子代上调用。

  • 应使用 focusNode.requestFocus()。不必调用 FocusScope.of(context).requestFocus(focusNode)focusNode.requestFocus() 方法等价且性能更好。

取消焦点

#

有一个 API 用于让节点「放弃焦点」,名为 FocusNode.unfocus()。虽然它会从该节点移除焦点,但重要的是要理解,并不存在真正「取消所有节点焦点」这回事。若某节点失去焦点,焦点必须转移到别处,因为 始终 存在主焦点。节点调用 unfocus() 时接收焦点的节点,取决于传给 unfocus()disposition 参数,要么是最近的 FocusScopeNode,要么是该作用域内先前拥有焦点的节点。若你想更精确地控制从某节点移除焦点后焦点去向,应显式让另一节点获得焦点,而不是调用 unfocus(),或使用焦点遍历机制,通过 FocusNode 上的 focusInDirectionnextFocuspreviousFocus 方法找到另一节点。

调用 unfocus() 时,disposition 参数提供两种取消焦点模式: UnfocusDisposition.scopeUnfocusDisposition.previouslyFocusedChild。默认为 scope,将焦点交给最近的父焦点作用域。这意味着若之后用 FocusNode.nextFocus 将焦点移到下一节点,会从作用域内「第一个」可获得焦点的项开始。

previouslyFocusedChild 处置会在作用域内查找先前拥有焦点的子节点并请求其获得焦点。若没有先前拥有焦点的子节点,则与 scope 等价。

Focus widget

#

Focus widget 拥有并管理焦点节点,是焦点系统的主力。它管理其拥有的焦点节点在焦点树上的挂载与卸载,管理焦点节点的属性与回调,并提供静态函数以便发现挂载在 widget 树上的焦点节点。

最简单用法是用 Focus widget 包裹 widget 子树,使该子树在焦点遍历过程中,或在传入的 FocusNode 上调用 requestFocus 时获得焦点。若与调用 requestFocus 的手势检测器配合,可在点按或点击时获得焦点。

你可以将 FocusNode 对象传给 Focus widget 由其管理;若不传,它会自行创建。自行创建 FocusNode 的主要原因是从父 widget 在节点上调用 requestFocus() 以控制焦点。 FocusNode 的其他大部分功能最好通过修改 Focus widget 自身的属性来访问。

Flutter 的大多数内置控件都使用 Focus widget 实现其焦点功能。

下面示例展示如何使用 Focus widget 使自定义控件可获得焦点。它创建一个带文字的容器,在获得焦点时做出反应。

dart
import 'package:flutter/material.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});
  static const String _title = 'Focus Sample';

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: _title,
      home: Scaffold(
        appBar: AppBar(title: const Text(_title)),
        body: const Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[MyCustomWidget(), MyCustomWidget()],
        ),
      ),
    );
  }
}

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

  @override
  State<MyCustomWidget> createState() => _MyCustomWidgetState();
}

class _MyCustomWidgetState extends State<MyCustomWidget> {
  Color _color = Colors.white;
  String _label = 'Unfocused';

  @override
  Widget build(BuildContext context) {
    return Focus(
      onFocusChange: (focused) {
        setState(() {
          _color = focused ? Colors.black26 : Colors.white;
          _label = focused ? 'Focused' : 'Unfocused';
        });
      },
      child: Center(
        child: Container(
          width: 300,
          height: 50,
          alignment: Alignment.center,
          color: _color,
          child: Text(_label),
        ),
      ),
    );
  }
}

按键事件

#

若要在子树中监听按键事件,将 Focus widget 的 onKeyEvent 属性设为处理器,该处理器仅监听按键,或处理按键并阻止其向其他 widget 传播。

按键事件从拥有主焦点的焦点节点开始。若该节点的 onKeyEvent 处理器未返回 KeyEventResult.handled,则事件交给其父焦点节点。若父节点未处理,则继续向上,直至焦点树根。若事件到达焦点树根仍未被处理,则返回平台,交给应用中的下一个原生控件(当 Flutter UI 是更大原生应用 UI 的一部分时)。已处理的事件不会传播到其他 Flutter widget,也不会传播到原生 widget。

下面是一个 Focus widget 示例,它吸收子树未处理的每个按键,且自身不能成为主焦点:

dart
@override
Widget build(BuildContext context) {
  return Focus(
    onKeyEvent: (node, event) => KeyEventResult.handled,
    canRequestFocus: false,
    child: child,
  );
}

焦点按键事件在文本输入事件之前处理,因此在焦点 widget 包裹文本字段时处理按键事件会阻止该键输入到文本字段。

下面是一个不允许在文本字段中输入字母「a」的 widget 示例:

dart
@override
Widget build(BuildContext context) {
  return Focus(
    onKeyEvent: (node, event) {
      return (event.logicalKey == LogicalKeyboardKey.keyA)
          ? KeyEventResult.handled
          : KeyEventResult.ignored;
    },
    child: const TextField(),
  );
}

若目的是输入校验,该示例功能用 TextInputFormatter 实现可能更合适,但该技巧仍有用处:例如 Shortcuts widget 用此方法在快捷键成为文本输入之前处理它们。

控制哪些可获得焦点

#

焦点的主要方面之一是控制什么可以获得焦点以及如何获得。属性 canRequestFocusskipTraversaldescendantsAreFocusable 控制该节点及其子代如何参与焦点过程。

skipTraversal 属性为 true,则该焦点节点不参与焦点遍历。若在其焦点节点上调用 requestFocus,仍可获得焦点,但在焦点遍历系统寻找下一个焦点目标时会被跳过。

canRequestFocus 属性(顾名思义)控制该 Focus widget 管理的焦点节点是否可用于请求焦点。若该属性为 false,在节点上调用 requestFocus 无效。这也意味着该节点在焦点遍历中被跳过,因为它无法请求焦点。

descendantsAreFocusable 属性控制该节点的子代是否可获得焦点,但仍允许该节点自身获得焦点。该属性可用于关闭整个 widget 子树的焦点能力。 ExcludeFocus widget 就是这样工作的:它只是将该属性设好的 Focus widget。

自动焦点

#

Focus widget 的 autofocus 属性设为 true 会告诉 widget 在其所属焦点作用域第一次获得焦点时请求焦点。若有多个 widget 设置了 autofocus,哪个获得焦点是任意的,因此尽量每个焦点作用域只在一个 widget 上设置。

仅当节点所属作用域内尚无焦点时,autofocus 属性才会生效。

在两个属于不同焦点作用域的节点上设置 autofocus 是明确定义的:各自在对应作用域获得焦点时成为获得焦点的 widget。

变化通知

#

Focus.onFocusChanged 回调可用于在特定节点的焦点状态变化时收到通知。节点被加入或移出焦点链时都会通知,这意味着即使不是主焦点也会收到通知。若你只想知道是否获得了主焦点,请检查焦点节点上的 hasPrimaryFocus 是否为 true。

获取 FocusNode

#

有时需要获取 Focus widget 的焦点节点以查询其属性。

要从 Focus widget 的祖先访问焦点节点,创建 FocusNode 并作为 Focus widget 的 focusNode 属性传入。因其需要 dispose,你传入的焦点节点应由有状态 widget 拥有,不要在每次 build 时新建。

若要从 Focus widget 的子代访问焦点节点,可调用 Focus.of(context) 获取距给定 context 最近的 Focus widget 的焦点节点。若要在同一 build 函数内获取 Focus widget 的 FocusNode,请使用 Builder 确保 context 正确。如下例所示:

dart
@override
Widget build(BuildContext context) {
  return Focus(
    child: Builder(
      builder: (context) {
        final bool hasPrimary = Focus.of(context).hasPrimaryFocus;
        print('Building with primary focus: $hasPrimary');
        return const SizedBox(width: 100, height: 100);
      },
    ),
  );
}

时机

#

焦点系统的一个细节是:请求焦点时,仅在当前 build 阶段完成后才生效。这意味着焦点变化总是延迟一帧,因为改变焦点可能导致 widget 树任意部分(包括当前请求焦点的 widget 的祖先)重建。子代不能使祖先变脏,因此必须在帧之间进行,以便所需变化在下一帧发生。

FocusScope widget

#

FocusScope widget 是 Focus widget 的特殊版本,管理 FocusScopeNode 而非 FocusNodeFocusScopeNode 是焦点树中的特殊节点,作为子树中焦点节点的分组机制。除非显式聚焦作用域外的节点,否则焦点遍历停留在焦点作用域内。

焦点作用域还跟踪其子树内当前焦点及曾获得焦点的节点历史。这样,若某节点在拥有焦点时释放焦点或被移除,焦点可返回到先前拥有焦点的节点。

当没有子代拥有焦点时,焦点作用域也作为焦点返回的落脚点。这使焦点遍历代码有起始上下文,用于查找下一个(或第一个)可移到的可获得焦点的控件。

若你聚焦焦点作用域节点,它会先尝试聚焦其子树中当前或最近拥有焦点的节点,或请求了 autofocus 的节点(若有)。若没有此类节点,则由作用域节点自身获得焦点。

FocusableActionDetector widget

#

FocusableActionDetector 是将 ActionsShortcutsMouseRegionFocus widget 的功能组合在一起的 widget,用于创建定义动作与按键绑定、并提供处理焦点与悬停高亮回调的检测器。 Flutter 控件用它实现控件的上述所有方面。它仅用组成 widget 实现,因此若你不需要全部功能,可只使用需要的部分,但它是将这些行为融入自定义控件的便捷方式。

控制焦点遍历

#

应用具备焦点能力后,许多应用接下来希望让用户用键盘或其他输入设备控制焦点。最常见的是「Tab 遍历」:用户按 Tab 移到「下一个」控件。控制「下一个」的含义是本节主题。 Flutter 默认提供此类遍历。

在简单网格布局中,较容易决定下一个控件。若不在行末,则是右侧(从右到左语言环境则为左侧)的控件。若在行末,则是下一行第一个控件。遗憾的是,应用很少按网格布局,因此往往需要更多指引。

Flutter 用于焦点遍历的默认算法(ReadingOrderTraversalPolicy)相当好:对大多数应用能给出正确结果。但总有极端情况,或上下文/设计要求的顺序与默认排序算法不同。对这些情况,有其他机制可实现所需顺序。

FocusTraversalGroup widget

#

FocusTraversalGroup widget 应放在 widget 树中,包裹应在移到其他 widget 或 widget 组之前完整遍历的 widget 子树。仅将 widget 分组为相关组往往足以解决许多 Tab 遍历顺序问题。若不够,还可为组指定 FocusTraversalPolicy 以确定组内顺序。

默认的 ReadingOrderTraversalPolicy 通常足够,但若需要更多顺序控制,可使用 OrderedTraversalPolicy。包裹可获得焦点的 widget 的 FocusTraversalOrder widget 的 order 参数决定顺序。顺序可以是 FocusOrder 的任意子类,但提供了 NumericFocusOrderLexicalFocusOrder

若提供的焦点遍历策略都不满足应用需求,你也可以编写自己的策略,以确定任意自定义顺序。

下面示例展示如何使用 FocusTraversalOrder widget,通过 NumericFocusOrder 按 TWO、ONE、THREE 顺序遍历一行按钮:

dart
class OrderedButtonRow extends StatelessWidget {
  const OrderedButtonRow({super.key});

  @override
  Widget build(BuildContext context) {
    return FocusTraversalGroup(
      policy: OrderedTraversalPolicy(),
      child: Row(
        children: <Widget>[
          const Spacer(),
          FocusTraversalOrder(
            order: const NumericFocusOrder(2),
            child: TextButton(child: const Text('ONE'), onPressed: () {}),
          ),
          const Spacer(),
          FocusTraversalOrder(
            order: const NumericFocusOrder(1),
            child: TextButton(child: const Text('TWO'), onPressed: () {}),
          ),
          const Spacer(),
          FocusTraversalOrder(
            order: const NumericFocusOrder(3),
            child: TextButton(child: const Text('THREE'), onPressed: () {}),
          ),
          const Spacer(),
        ],
      ),
    );
  }
}

FocusTraversalPolicy

#

FocusTraversalPolicy 是根据请求和当前焦点节点决定下一个 widget 的对象。请求(成员函数)包括 findFirstFocusfindLastFocusnextpreviousinDirection 等。

FocusTraversalPolicy 是具体策略的抽象基类,例如 ReadingOrderTraversalPolicyOrderedTraversalPolicy 以及 DirectionalFocusTraversalPolicyMixin 类。

要使用 FocusTraversalPolicy,需将其交给 FocusTraversalGroup,由后者确定策略生效的 widget 子树。该类的成员函数很少直接调用:它们供焦点系统使用。

焦点管理器

#

FocusManager 维护系统当前的主焦点。它对焦点系统用户仅有少量有用 API。之一是 FocusManager.instance.primaryFocus 属性,包含当前获得焦点的焦点节点,也可通过全局 primaryFocus 字段访问。

其他有用属性包括 FocusManager.instance.highlightModeFocusManager.instance.highlightStrategy。需要在其焦点高亮之间切换「触摸」模式与「传统」(鼠标和键盘)模式的 widget 会使用它们。用户用触摸导航时,焦点高亮通常隐藏;切换到鼠标或键盘时,需再次显示焦点高亮,以便知道当前焦点在哪。 highlightStrategy 告诉焦点管理器如何解释设备使用模式的变化:可根据最近输入事件自动在两种模式间切换,或锁定为触摸或传统模式。Flutter 提供的 widget 已知道如何使用这些信息,因此仅在你从零编写自己的控件时才需要。可用 addHighlightModeListener 回调监听高亮模式变化。