了解 Flutter 的键盘焦点系统

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

概述

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

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

焦点用例

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

词汇表

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

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

FocusNode 和 FocusScopeNode

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

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

创建 FocusNode 对象的最佳实践

以下列出有关使用这些对象的一些注意事项

  • 不要为每次构建分配一个新的 FocusNode。这会导致内存泄漏,并且偶尔会导致小组件在节点具有焦点时重新构建时失去焦点。
  • 在有状态小组件中创建 FocusNodeFocusScopeNode 对象。当您使用完 FocusNodeFocusScopeNode 时需要将其释放,因此它们只能在有状态小组件的状态对象中创建,您可以在其中覆盖 dispose 来释放它们。
  • 不要对多个小组件使用相同的 FocusNode。如果您这样做,小组件将争夺管理节点属性,您可能无法获得预期结果。
  • 设置焦点节点小组件的 debugLabel 以帮助诊断焦点问题。
  • 如果 FocusNodeFocusScopeNodeFocusFocusScope 小组件管理,则不要在 FocusNodeFocusScopeNode 上设置 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(),或者使用焦点遍历机制来查找具有 focusInDirectionnextFocuspreviousFocus 方法的 FocusNode 上的另一个节点。

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

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

焦点小部件

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

在最简单的形式中,将 Focus 小组件包装到小组件子树中,允许该小组件子树在焦点遍历过程中获取焦点,或在 requestFocus 被调用到传递给它的 FocusNode 时获取焦点。当与调用 requestFocus 的手势检测器结合使用时,它可以在被轻触或单击时接收焦点。

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

大多数 Flutter 自有控件中都使用 Focus 小组件来实现其焦点功能。

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

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 小组件的示例,它吸收其子树未处理的每个按键,但不能成为主要焦点

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

焦点按键事件在文本输入事件之前处理,因此在焦点小组件包围文本字段时处理按键事件可以防止该按键被输入到文本字段中。

以下是一个小组件的示例,该小组件不允许在文本字段中键入字母“a”

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

如果意图是输入验证,此示例的功能可能会使用 TextInputFormatter 更好地实现,但该技术仍然有用:Shortcuts 小部件使用此方法在它们变为文本输入之前处理快捷方式,例如。

控制获得焦点的内容

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

如果 skipTraversal 属性为真,则此焦点节点不参与焦点遍历。如果对它的焦点节点调用 requestFocus,它仍然可获得焦点,但当焦点遍历系统寻找下一个要关注的内容时,它会被跳过。

毫不奇怪,canRequestFocus 属性控制此 Focus 小部件管理的焦点节点是否可用于请求焦点。如果此属性为假,则对节点调用 requestFocus 不起作用。它还暗示此节点被跳过焦点遍历,因为它无法请求焦点。

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

自动对焦

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

仅当节点所属的范围内尚未存在焦点时,autofocus 属性才会生效。

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

更改通知

可以使用 Focus.onFocusChanged 回调来获取有关特定节点的焦点状态已更改的通知。如果将节点添加到焦点链或从焦点链中删除,它会发出通知,这意味着即使它不是主焦点,它也会获取通知。如果您只想了解是否已接收主焦点,请检查焦点节点上的 hasPrimaryFocus 是否为真。

获取 FocusNode

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

要从 Focus 小组件的祖先访问焦点节点,请创建一个 FocusNode 并将其作为 Focus 小组件的 focusNode 属性传递进去。由于需要处理,您传递的焦点节点需要归有状态小组件所有,因此不要在每次构建时都创建一个。

如果您需要从 Focus 小组件的后代访问焦点节点,您可以调用 Focus.of(context) 以获取给定上下文最近的 Focus 小组件的焦点节点。如果您需要在同一个构建函数中获取 Focus 小组件的 FocusNode,请使用 Builder 以确保您拥有正确的上下文。以下示例对此进行了说明

@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 是一个结合了 ActionsShortcutsMouseRegionFocus 小部件的功能,以创建一个检测器,该检测器定义操作和键绑定,并提供用于处理焦点和悬停高亮的回调。这是 Flutter 控件用于实现控件的所有这些方面的内容。它只是使用组成小部件实现的,因此如果你不需要它的所有功能,你只需使用你需要的功能即可,但它是一种将这些行为构建到自定义控件中的便捷方式。

控制焦点遍历

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

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

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

FocusTraversalGroup 组件

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

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

如果提供的焦点遍历策略都不足以满足你的应用程序,你还可以编写自己的策略,并使用它来确定所需的任何自定义排序。

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

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 回调来侦听高亮模式的变化。