跳至主要内容

了解 Flutter 的键盘焦点系统

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

概述

#

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

此页面探讨了在 Flutter 应用程序上执行这些操作时使用的 API 以及焦点系统的工作原理。我们注意到,开发人员对如何定义和使用 FocusNode 对象存在一些困惑。如果您的体验与此相同,请跳至 创建 FocusNode 对象的最佳实践 部分。

焦点用例

#

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

术语表

#

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

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

FocusNode 和 FocusScopeNode

#

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

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

创建 FocusNode 对象的最佳实践

#

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

  • 不要为每次构建分配一个新的 FocusNode。这可能导致内存泄漏,并且有时会导致 widget 在节点具有焦点时重建时焦点丢失。
  • 在有状态 widget 中创建 FocusNodeFocusScopeNode 对象。FocusNodeFocusScopeNode 在您使用完后需要被释放,因此它们应该只在有状态 widget 的状态对象内部创建,您可以在其中覆盖 dispose 以释放它们。
  • 不要对多个 widget 使用相同的 FocusNode。如果这样做,这些 widget 将争夺节点属性的管理权,并且您可能无法获得预期的结果。
  • 设置焦点节点 widget 的 debugLabel 以帮助诊断焦点问题。
  • 如果 FocusNodeFocusScopeNodeFocusFocusScope widget 管理,则不要在它们上面设置 onKeyEvent 回调。如果您想要一个 onKeyEvent 处理程序,则在您想要监听的 widget 子树周围添加一个新的 Focus widget,并将 widget 的 onKeyEvent 属性设置为您的处理程序。如果也不希望它能够获取主焦点,则在 widget 上设置 canRequestFocus: false。这是因为 Focus widget 上的 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 widget

#

Focus 组件拥有并管理一个焦点节点,并且是焦点系统的核心。它管理其拥有的焦点节点在焦点树中的附加和分离,管理焦点节点的属性和回调,并且具有静态函数来启用对附加到组件树的焦点节点的发现。

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

您可以将 FocusNode 对象传递给 Focus 组件进行管理,但如果您不传递,它会创建自己的节点。创建您自己的 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 widget

#

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

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

焦点作用域还可以作为在没有后代具有焦点时返回焦点的场所。这允许焦点遍历代码拥有一个起始上下文,用于查找要移动到的下一个(或第一个)可聚焦控件。

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

FocusableActionDetector widget

#

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

控制焦点遍历

#

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

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

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

FocusTraversalGroup widget

#

FocusTraversalGroup 组件应放置在树中,围绕应在继续到另一个组件或组件组之前完全遍历的组件子树。仅将组件分组到相关的组中通常就足以解决许多 Tab 键遍历排序问题。如果不是,还可以为该组提供一个 FocusTraversalPolicy 来确定组内的排序。

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

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

以下是如何使用 FocusTraversalOrder 组件以 TWO、ONE、THREE 的顺序遍历一行按钮的示例,使用 NumericFocusOrder

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。这些属性被需要在“触摸”模式和“传统”(鼠标和键盘)模式之间切换焦点高亮显示的小部件使用。当用户使用触摸进行导航时,焦点高亮通常隐藏,当他们切换到鼠标或键盘时,需要再次显示焦点高亮,以便他们知道哪个小部件获得焦点。hightlightStrategy 告诉焦点管理器如何解释设备使用模式的变化:它可以根据最近的输入事件自动在两者之间切换,也可以锁定在触摸或传统模式下。Flutter 中提供的部件已经知道如何使用此信息,因此只有在从头编写自己的控件时才需要它。您可以使用 addHighlightModeListener 回调来监听高亮模式的变化。