Skip to main content

理解 Flutter 的键盘焦点系统

本文介绍如何控制键盘输入的指向位置。如果您正在实现使用物理键盘的应用程序(例如大多数桌面和 Web 应用程序),此页面适合您。如果您的应用程序不使用物理键盘,您可以跳过此内容。

概述

#

Flutter 带有一个焦点系统,该系统将键盘输入定向到应用程序的特定部分。为此,用户通过点击或单击所需的 UI 元素来将输入“聚焦”到应用程序的那一部分。一旦发生这种情况,使用键盘输入的文本就会流向应用程序的那一部分,直到焦点移动到应用程序的另一部分。焦点也可以通过按特定的键盘快捷键来移动,该快捷键通常绑定到 Tab,因此有时也称为“制表符遍历”。

此页面探讨了在 Flutter 应用程序上执行这些操作的 API,以及焦点系统的工作方式。我们注意到,开发人员对如何定义和使用 FocusNode 对象存在一些混淆。如果您的情况也是如此,请跳至创建 FocusNode 对象的最佳实践

焦点用例

#

您可能需要了解如何使用焦点系统的一些情况示例:

术语表

#

以下是 Flutter 使用的焦点系统元素的术语。下面介绍了实现其中一些概念的各种类。

  • 焦点树 - 焦点节点的树,通常稀疏地镜像小部件树,表示所有可以接收焦点的 widget。
  • 焦点节点 - 焦点树中的单个节点。此节点可以接收焦点,当它是焦点链的一部分时,据说“具有焦点”。只有当它具有焦点时,它才会参与处理按键事件。
  • 主要焦点 - 距离焦点树根最远的具有焦点的焦点节点。这是按键事件开始传播到主要焦点节点及其祖先的焦点节点。
  • 焦点链 - 一个有序的焦点节点列表,从主要焦点节点开始,沿着焦点树的分支到焦点树的根。
  • 焦点范围 - 一个特殊的焦点节点,其作用是包含一组其他焦点节点,并只允许这些节点接收焦点。它包含有关其子树中先前焦点的节点的信息。
  • 焦点遍历 - 以可预测的顺序从一个可聚焦节点移动到另一个可聚焦节点的过程。当用户按下 Tab 以移动到下一个可聚焦控件或字段时,这通常在应用程序中可见。

FocusNode 和 FocusScopeNode

#

FocusNodeFocusScopeNode 对象实现了焦点系统的机制。它们是长生命周期对象(比小部件更长,类似于渲染对象),它们保存焦点状态和属性,以便它们在小部件树的构建之间保持持久性。它们一起形成了焦点树数据结构。

它们最初旨在作为面向开发者的对象,用于控制焦点系统的某些方面,但随着时间的推移,它们已经发展成为主要实现焦点系统的细节。为了防止破坏现有应用程序,它们仍然包含其属性的公共接口。但是,一般来说,它们最有用的是充当相对不透明的句柄,传递给后代小部件以便在其祖先小部件上调用 requestFocus(),这请求后代小部件获得焦点。除非您不使用它们或实现您自己的版本,否则最好由 FocusFocusScope 小部件管理其他属性的设置。

创建 FocusNode 对象的最佳实践

#

围绕使用这些对象的一些注意事项包括:

  • 不要为每个构建分配一个新的 FocusNode。这会导致内存泄漏,并且当小部件在节点具有焦点时重建时,偶尔会导致焦点丢失。
  • 在有状态小部件中创建 FocusNodeFocusScopeNode 对象。FocusNodeFocusScopeNode 在您使用完毕后需要被释放,因此它们只能在有状态小部件的状态对象内创建,您可以在其中覆盖 dispose 来释放它们。
  • 不要对多个小部件使用相同的 FocusNode。如果您这样做,这些小部件将争夺管理节点属性的权利,并且您可能无法获得预期的结果。
  • 设置焦点节点小部件的 debugLabel 以帮助诊断焦点问题。
  • 如果 FocusNodeFocusScopeNodeFocusFocusScope 小部件管理,则不要设置 onKeyEvent 回调。 如果您需要 onKeyEvent 处理程序,请在您想要侦听的 widget 子树周围添加一个新的 Focus 小部件,并将小部件的 onKeyEvent 属性设置为您的处理程序。如果您也不希望它能够获得主要焦点,请将小部件上的 canRequestFocus: false 设置为 false。这是因为 Focus 小部件上的 onKeyEvent 属性可以在随后的构建中设置为其他内容,如果发生这种情况,它会覆盖您在节点上设置的 onKeyEvent 处理程序。
  • 调用节点上的 requestFocus() 以请求它接收主要焦点,尤其是在祖先已经将其拥有的节点传递给您想要聚焦的后代的情况下。
  • 使用 focusNode.requestFocus()。无需调用 FocusScope.of(context).requestFocus(focusNode)focusNode.requestFocus() 方法等效且性能更高。

取消焦点

#

有一个 API 用于告诉节点“放弃焦点”,名为 FocusNode.unfocus()。虽然它确实会从节点中移除焦点,但重要的是要意识到,实际上并没有“取消所有节点的焦点”这样的事情。如果一个节点失去焦点,那么它必须将焦点传递到其他地方,因为 总是 存在一个主要焦点。当节点调用 unfocus() 时接收焦点的节点是最近的 FocusScopeNode,或者该范围内的先前聚焦节点,具体取决于传递给 unfocus()disposition 参数。如果您想更好地控制在从节点中移除焦点时焦点的指向位置,请显式地聚焦另一个节点而不是调用 unfocus(),或者使用焦点遍历机制使用 FocusNode 上的 focusInDirectionnextFocuspreviousFocus 方法查找另一个节点。

调用 unfocus() 时,disposition 参数允许两种取消焦点的模式: UnfocusDisposition.scopeUnfocusDisposition.previouslyFocusedChild。默认值为 scope,它将焦点交给最近的父级焦点范围。这意味着如果此后焦点使用 FocusNode.nextFocus 移动到下一个节点,它将从范围内的“第一个”可聚焦项目开始。

previouslyFocusedChild disposition 将搜索范围以查找先前聚焦的子节点,并在其上请求焦点。如果没有先前聚焦的子节点,则它等效于 scope

Focus 小部件

#

Focus 小部件拥有并管理焦点节点,并且是焦点系统的主力军。它管理其拥有的焦点节点与焦点树的附加和分离,管理焦点节点的属性和回调,并具有静态函数以启用对附加到小部件树的焦点节点的发现。

在其最简单的形式中,将 Focus 小部件围绕小部件子树包装允许该小部件子树作为焦点遍历过程的一部分获得焦点,或者每当在其传递给它的 FocusNode 上调用 requestFocus 时获得焦点。当与调用 requestFocus 的手势检测器结合使用时,它可以在点击或单击时接收焦点。

您可以将 FocusNode 对象传递给 Focus 小部件进行管理,但如果您不这样做,它会创建自己的 FocusNode。创建您自己的 FocusNode 的主要原因是能够在节点上调用 requestFocus() 以从父小部件控制焦点。FocusNode 的大多数其他功能最好通过更改 Focus 小部件本身的属性来访问。

Focus 小部件用于 Flutter 的大多数自身控件中,以实现其焦点功能。

以下是一个示例,展示了如何使用 Focus 小部件使自定义控件可聚焦。它创建一个带有文本的容器,该容器对接收焦点做出反应。

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 小部件的 onKeyEvent 属性设置为一个处理程序,该处理程序要么只侦听按键,要么处理按键并阻止其传播到其他小部件。

按键事件从具有主要焦点的焦点节点开始。如果该节点没有从其 onKeyEvent 处理程序返回 KeyEventResult.handled,则其父焦点节点将获得该事件。如果父节点没有处理它,它会传递给它的父节点,依此类推,直到到达焦点树的根。如果事件到达焦点树的根而没有被处理,则它将返回到平台以提供给应用程序中的下一个原生控件(如果 Flutter UI 是更大原生应用程序 UI 的一部分)。

已处理的事件不会传播到其他 Flutter 小部件,也不会传播到原生小部件。

这是一个 Focus 小部件的示例,它吸收其子树未处理的每个按键,但无法成为主要焦点:

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

焦点按键事件在文本输入事件之前处理,因此当焦点小部件围绕文本字段时处理按键事件会阻止该按键输入到文本字段中。

这是一个小部件示例,不允许将字母“a”输入到文本字段中:

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 小部件使用此方法在快捷键成为文本输入之前处理快捷键。

控制获得焦点的对象

#

焦点的主要方面之一是控制什么可以接收焦点以及如何接收焦点。属性 canRequestFocusskipTraversaldescendantsAreFocusable 控制此节点及其后代如何参与焦点过程。

如果 skipTraversal 属性为 true,则此焦点节点不参与焦点遍历。如果在其焦点节点上调用 requestFocus,它仍然可聚焦,但在焦点遍历系统寻找下一个要聚焦的对象时,它会被跳过。

canRequestFocus 属性(不出所料)控制此 Focus 小部件管理的焦点节点是否可以用来请求焦点。如果此属性为 false,则在节点上调用 requestFocus 将不起作用。这也意味着此节点会被跳过焦点遍历,因为它无法请求焦点。

descendantsAreFocusable 属性控制此节点的后代是否可以接收焦点,但仍然允许此节点接收焦点。此属性可用于关闭整个小部件子树的可聚焦性。这就是 ExcludeFocus 小部件的工作方式:它只是一个设置了此属性的 Focus 小部件。

自动聚焦

#

设置 Focus 小部件的 autofocus 属性会告诉小部件在其所属的焦点范围第一次获得焦点时请求焦点。如果多个小部件设置了 autofocus,则哪个小部件接收焦点是任意的,因此请尝试每个焦点范围只在一个小部件上设置它。

只有在节点所属的范围内还没有焦点时,autofocus 属性才会生效。

在属于不同焦点范围的两个节点上设置 autofocus 属性是明确定义的:当它们的相应范围获得焦点时,每个节点都成为聚焦的小部件。

更改通知

#

Focus.onFocusChanged 回调可用于获取特定节点的焦点状态已更改的通知。它会通知节点是否添加到或从焦点链中移除,这意味着即使它不是主要焦点,它也会获得通知。如果您只想了解您是否已收到主要焦点,请检查焦点节点上的 hasPrimaryFocus 是否为 true。

获取 FocusNode

#

有时,获取 Focus 小部件的焦点节点以查询其属性非常有用。

要从 Focus 小部件的祖先访问焦点节点,请创建一个 FocusNode 并将其作为 Focus 小部件的 focusNode 属性传入。因为它需要被释放,所以您传入的焦点节点需要由一个有状态小部件拥有,因此不要每次构建时都创建一个。

如果您需要从 Focus 小部件的后代访问焦点节点,您可以调用 Focus.of(context) 以获取最接近给定上下文的 Focus 小部件的焦点节点。如果您需要在相同的构建函数中获取 Focus 小部件的 FocusNode,请使用 Builder 来确保您具有正确的上下文。这在以下示例中显示:

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);
      },
    ),
  );
}

定时

#

焦点系统的一个细节是,当请求焦点时,它只在当前构建阶段完成后才生效。这意味着焦点更改总是延迟一帧,因为更改焦点会导致小部件树的任意部分重建,包括当前请求焦点的父级小部件。由于子级不能弄脏其祖先,因此它必须发生在帧之间,以便任何必要的更改都可以在下一帧发生。

FocusScope 小部件

#

FocusScope 小部件是 Focus 小部件的一个特殊版本,它管理 FocusScopeNode 而不是 FocusNodeFocusScopeNode 是焦点树中一个特殊的节点,用作子树中焦点节点的组合机制。焦点遍历停留在焦点范围内,除非显式地聚焦范围之外的节点。

焦点范围还跟踪其子树中当前焦点和已聚焦节点的历史记录。这样,如果节点在具有焦点时释放焦点或被移除,则焦点可以返回到先前具有焦点的节点。

如果没有任何后代具有焦点,焦点范围也用作返回焦点的场所。这允许焦点遍历代码具有一个起始上下文,用于查找要移动到的下一个(或第一个)可聚焦控件。

如果您聚焦焦点范围节点,它首先尝试聚焦其子树中的当前节点或最近聚焦的节点,或其子树中请求自动聚焦的节点(如果有)。如果没有这样的节点,它将自己接收焦点。

FocusableActionDetector 小部件

#

FocusableActionDetector 是一个结合了 ActionsShortcutsMouseRegionFocus 小部件功能的小部件,用于创建一个检测器,该检测器定义操作和按键绑定,并提供用于处理焦点和悬停突出显示的回调。Flutter 控件使用它来实现控件的这些方面。它只是使用组成的小部件实现的,因此如果您不需要它的所有功能,您可以只使用您需要的小部件,但它是将这些行为构建到自定义控件中的便捷方法。

控制焦点遍历

#

一旦应用程序能够聚焦,许多应用程序接下来想要做的就是允许用户使用键盘或其他输入设备控制焦点。“制表符遍历”是最常见的示例,其中用户按下 Tab 以转到“下一个”控件。控制“下一个”的含义是本节的主题。Flutter 默认情况下提供这种类型的遍历。

在简单的网格布局中,很容易确定哪个控件是下一个。如果您不在行的末尾,则它是右侧的控件(对于从右到左的语言环境,则为左侧的控件)。如果您位于行的末尾,则它是下一行中的第一个控件。不幸的是,应用程序很少以网格形式布局,因此通常需要更多指导。

Flutter 中焦点遍历的默认算法 (ReadingOrderTraversalPolicy) 非常好:它为大多数应用程序提供了正确的答案。但是,总有一些病态的情况,或者上下文或设计要求与默认排序算法得出的顺序不同的顺序的情况。对于这些情况,还有其他机制可以实现所需的顺序。

FocusTraversalGroup 小部件

#

FocusTraversalGroup 小部件应放置在树中围绕应完全遍历的小部件子树周围,然后再移动到另一个小部件或一组小部件。将小部件组合成相关的组通常足以解决许多制表符遍历排序问题。如果没有,还可以为该组提供 FocusTraversalPolicy 来确定组内的排序。

默认的 ReadingOrderTraversalPolicy 通常就足够了,但在需要更多控制排序的情况下,可以使用 OrderedTraversalPolicy。围绕可聚焦组件的 FocusTraversalOrder 小部件的 order 参数决定顺序。顺序可以是 FocusOrder 的任何子类,但是提供了 NumericFocusOrderLexicalFocusOrder

如果提供的焦点遍历策略都不够,您也可以编写自己的策略并使用它来确定您想要的任何自定义排序。

以下是如何使用 FocusTraversalOrder 小部件使用 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 是确定下一个小部件的对象,给定请求和当前焦点节点。请求(成员函数)例如 findFirstFocusfindLastFocusnextpreviousinDirection

FocusTraversalPolicy 是具体策略的抽象基类,例如 ReadingOrderTraversalPolicyOrderedTraversalPolicyDirectionalFocusTraversalPolicyMixin 类。 为了使用 FocusTraversalPolicy,您可以将其提供给 FocusTraversalGroup,后者确定策略将生效的小部件子树。类的成员函数很少直接调用:它们旨在由焦点系统使用。

焦点管理器

#

FocusManager 保持系统的当前主要焦点。它只有几个对焦点系统用户有用的 API 部分。一个是 FocusManager.instance.primaryFocus 属性,它包含当前聚焦的焦点节点,也可以从全局 primaryFocus 字段访问。

其他有用的属性是 FocusManager.instance.highlightModeFocusManager.instance.highlightStrategy。这些由需要在其焦点高亮显示之间切换“触摸”模式和“传统”(鼠标和键盘)模式的小部件使用。当用户使用触摸进行导航时,焦点高亮通常会隐藏,当他们切换到鼠标或键盘时,需要再次显示焦点高亮,以便他们知道什么被聚焦。highlightStrategy 告诉焦点管理器如何解释设备使用模式的变化:它可以根据最新的输入事件自动在两者之间切换,也可以锁定在触摸或传统模式下。Flutter 中提供的 Widget 已经知道如何使用此信息,因此只有在您从头开始编写自己的控件时才需要它。您可以使用 addHighlightModeListener 回调来侦听高亮模式的变化。