本文解释了如何控制键盘输入的指向。如果你正在实现一个使用物理键盘的应用程序,例如大多数桌面和 Web 应用程序,那么本页面适合你。如果你的应用程序不使用物理键盘,可以跳过此部分。

概述

#

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

本页面探讨了在 Flutter 应用程序上执行这些操作所使用的 API,以及焦点系统的工作原理。我们注意到,开发人员对于如何定义和使用 FocusNode 对象存在一些困惑。如果这描述了你的经验,请跳到 创建 FocusNode 对象的最佳实践

焦点用例

#

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

术语表

#

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

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

FocusNode 和 FocusScopeNode

#

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

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

创建 FocusNode 对象的最佳实践

#

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

  • 不要为每次构建都分配一个新的 FocusNode。这可能导致内存泄漏,并且偶尔在节点拥有焦点时部件重建会导致焦点丢失。
  • 请在有状态部件中创建 FocusNodeFocusScopeNode 对象。FocusNodeFocusScopeNode 在使用完毕后需要被释放,因此它们应该只在有状态部件的状态对象中创建,你可以在其中覆盖 dispose 方法来释放它们。
  • 不要为多个部件使用相同的 FocusNode。如果这样做,部件会争夺节点的属性管理权,你可能无法获得预期结果。
  • 请设置焦点节点部件的 debugLabel,以帮助诊断焦点问题。
  • 如果 FocusNodeFocusScopeNodeFocusFocusScope 部件管理,请不要在其上设置 onKeyEvent 回调。如果你想要一个 onKeyEvent 处理程序,那么请在你希望监听的部件子树周围添加一个新的 Focus 部件,并将该部件的 onKeyEvent 属性设置为你的处理程序。如果你也不希望它能够获取主焦点,请在部件上设置 canRequestFocus: 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 模式将搜索范围以查找之前聚焦的子节点并请求对其进行聚焦。如果没有之前聚焦的子节点,则等同于 scope 模式。

Focus 部件

#

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 属性为真,则此焦点节点不参与焦点遍历。如果在其焦点节点上调用 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 遍历”,即用户按下 Tab 键移动到“下一个”控件。控制“下一个”的含义是本节的主题。这种遍历由 Flutter 默认提供。

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

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

FocusTraversalGroup 部件

#

FocusTraversalGroup 部件应放置在那些应在移动到另一个部件或部件组之前完全遍历的部件子树周围。仅将部件分组到相关组通常足以解决许多 Tab 遍历排序问题。如果不能,该组还可以获得一个 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 中提供的部件已经知道如何使用此信息,因此你只有在从头开始编写自己的控件时才需要它。你可以使用 addHighlightModeListener 回调来监听高亮模式的变化。