了解 Flutter 的键盘焦点系统
如何在你的 Flutter 应用中使用焦点系统。
本文档解释了如何控制键盘输入的方向。 如果你正在实现一个使用物理键盘的应用程序,例如大多数桌面和 Web 应用程序,那么本页内容对你来说很有用。 如果你的应用程序不会使用物理键盘,你可以跳过此页。
概述
#Flutter 配备了一个焦点系统,可以将键盘输入定向到应用程序的特定部分。 为了实现这一点,用户通过点击或单击所需的 UI 元素将输入“聚焦”到应用程序的该部分。 一旦发生这种情况,通过键盘输入的内容就会流向应用程序的该部分,直到焦点移动到应用程序的另一个部分。 焦点也可以通过按特定的键盘快捷键来移动,该快捷键通常绑定到 Tab 键,因此有时也称为“选项卡遍历”。
本页探讨了用于在 Flutter 应用程序上执行这些操作的 API,以及焦点系统的工作方式。 我们注意到,开发者们对如何定义和使用 FocusNode 对象存在一些困惑。 如果你的体验如此,请跳到 创建 FocusNode 对象的最佳实践。
焦点使用场景
#以下是一些你可能需要了解如何使用焦点系统的示例场景
术语表
#以下是 Flutter 使用的焦点系统元素的术语。 以下介绍了一些实现这些概念的类。
- 焦点树 - 一个焦点节点树,通常稀疏地镜像小部件树,表示可以接收焦点的所有小部件。
- 焦点节点 - 焦点树中的单个节点。 该节点可以接收焦点,当它成为焦点链的一部分时,被称为“拥有焦点”。 它仅在拥有焦点时才参与处理按键事件。
- 主焦点 - 从焦点树的根到拥有焦点的最远的焦点节点。 这是按键事件开始传播到主焦点节点及其祖先的焦点节点。
- 焦点链 - 一个有序的焦点节点列表,从主焦点节点开始,沿着焦点树的分支到焦点树的根。
- 焦点范围 - 一个特殊的焦点节点,其工作是包含其他焦点节点的组,并仅允许这些节点接收焦点。 它包含有关其子树中先前聚焦的节点的的信息。
- 焦点遍历 - 在可预测的顺序中从一个可聚焦节点移动到另一个可聚焦节点的过程。 这通常在应用程序中可以看到,当用户按下 Tab 键移动到下一个可聚焦的控件或字段时。
FocusNode 和 FocusScopeNode
#FocusNode 和 FocusScopeNode 对象实现了焦点系统的机制。 它们是长寿命对象(比小部件长,类似于渲染对象),保存焦点状态和属性,以便在小部件树的构建之间保持持久性。 它们共同构成焦点树数据结构。
最初的设想是,它们是开发者面向的对象,用于控制焦点系统的一些方面,但随着时间的推移,它们已经发展成为主要实现焦点系统细节。 为了防止破坏现有的应用程序,它们仍然包含其属性的公共接口。 但是,通常来说,它们最有用的作用是充当相对不透明的句柄,传递给后代小部件,以便调用祖先小部件上的 requestFocus(),请求后代小部件获得焦点。 其他属性的设置最好由 Focus 或 FocusScope 部件管理,除非你不使用它们,或者实现你自己的版本。
创建 FocusNode 对象的最佳实践
#关于使用这些对象的一些注意事项包括
- 不要为每次构建分配一个新的
FocusNode。 这可能会导致内存泄漏,并且偶尔会导致在小部件重建时失去焦点。 - 在有状态小部件中创建
FocusNode和FocusScopeNode对象。FocusNode和FocusScopeNode需要在你完成使用它们时进行处置,因此它们应该只在有状态小部件的状态对象内创建,在那里你可以覆盖dispose来处置它们。 - 不要为多个小部件使用相同的
FocusNode。 如果这样做,小部件将争夺管理节点的属性,你可能无法获得预期的结果。 - 设置焦点小部件的
debugLabel以帮助诊断焦点问题。 - 如果它们由
Focus或FocusScope部件管理,请不要设置FocusNode或FocusScopeNode上的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(),或者使用焦点遍历机制使用 focusInDirection、nextFocus 或 previousFocus 方法在 FocusNode 上找到另一个节点。
调用 unfocus() 时,disposition 参数允许两种取消聚焦模式:UnfocusDisposition.scope 和 UnfocusDisposition.previouslyFocusedChild。 默认值为 scope,它将焦点传递给最近的父焦点范围。 这意味着如果焦点随后使用 FocusNode.nextFocus 移动到下一个节点,它将从该范围内的“第一个”可聚焦项开始。
previouslyFocusedChild disposition 将搜索范围以查找先前聚焦的子项并在其上请求焦点。 如果没有先前聚焦的子项,则它等效于 scope。
Focus 部件
#Focus 部件拥有并管理焦点节点,并且是焦点系统的核心。 它管理其拥有的焦点节点从焦点树的附加和分离,管理焦点节点的属性和回调,并具有静态函数以启用对附加到小部件树的焦点节点的发现。
在最简单的形式下,将 Focus 部件包装在部件子树周围,允许该部件子树作为焦点遍历过程的一部分或每当在传递给它的 FocusNode 上调用 requestFocus 时获得焦点。 当与调用 requestFocus 的手势检测器结合使用时,它可以单击或点击时获得焦点。
你可能会将 FocusNode 对象传递给 Focus 部件进行管理,但如果你不这样做,它会创建一个自己的对象。 创建你自己的 FocusNode 的主要原因是能够从父小部件调用 requestFocus() 来控制焦点。 FocusNode 的大部分其他功能最好通过更改 Focus 部件本身的属性来访问。
Focus 部件用于 Flutter 自己的大多数控件来实现其焦点功能。
以下是一个示例,展示了如何使用 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 部件使用此方法在它们成为文本输入之前处理快捷键。
控制获得焦点
#焦点的主要方面之一是控制哪些可以接收焦点以及如何控制。属性 canRequestFocus、skipTraversal, 和 descendantsAreFocusable 控制此节点及其后代参与焦点过程的方式。
如果 skipTraversal 属性为 true,则此焦点节点不参与焦点遍历。如果对其焦点节点调用 requestFocus,它仍然可以被聚焦,但否则在焦点遍历系统寻找下一个要聚焦的内容时会被跳过。
canRequestFocus 属性,顾名思义,控制此 Focus 部件管理的焦点节点是否可以用于请求焦点。如果此属性为 false,则对该节点调用 requestFocus 将无效。它还意味着该节点在焦点遍历时会被跳过,因为它无法请求焦点。
descendantsAreFocusable 属性控制此节点的后代是否可以接收焦点,但仍然允许此节点接收焦点。可以使用此属性关闭整个部件子树的焦点能力。这就是 ExcludeFocus 部件的工作方式:它只是一个将此属性设置为 true 的 Focus 部件。
自动聚焦
#设置 Focus 部件的 autofocus 属性会告诉该部件在它所属的焦点范围被聚焦时请求焦点。如果多个部件设置了 autofocus,则接收焦点的部件是任意的,因此请尝试仅在一个焦点范围内的部件上设置它。
autofocus 属性仅在节点所属的范围内尚不存在焦点的情况下才生效。
在属于不同焦点范围的两个节点上设置 autofocus 属性是明确定义的:当它们各自的范围被聚焦时,它们各自成为聚焦的部件。
变更通知
#可以使用 Focus.onFocusChanged 回调来获取有关特定节点焦点状态更改的通知。它会通知节点是否被添加到或从焦点链中移除,这意味着即使它不是主焦点,也会收到通知。如果您只想知道是否已接收主焦点,请检查焦点节点上 hasPrimaryFocus 是否为 true。
获取 FocusNode
#有时,获取 Focus 部件的焦点节点以询问其属性会很有用。
要从 Focus 部件的祖先访问焦点节点,请创建一个 FocusNode 并将其作为 focusNode 属性传递给 Focus 部件。由于需要对其进行处置,因此您传递的焦点节点需要由状态型部件拥有,因此不要每次构建时都创建一个。
如果您需要从 Focus 部件的后代访问焦点节点,则可以调用 Focus.of(context) 来获取给定上下文附近 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 而不是 FocusNode。FocusScopeNode 是焦点树中的一个特殊节点,充当焦点树中焦点节点的分组机制。除非显式聚焦范围外的节点,否则焦点遍历保持在焦点范围内。
焦点范围还会跟踪其子树中当前焦点和已聚焦节点的历史记录。这样,如果节点释放焦点或在具有焦点的状态下被移除,则可以将焦点返回到先前具有焦点的节点。
焦点范围还充当返回焦点的位置,如果其后代没有焦点。这使得焦点遍历代码能够为查找下一个(或第一个)可聚焦控件提供起始上下文。
如果您聚焦焦点范围节点,它首先尝试聚焦其子树中的当前或最近聚焦的节点,或者请求自动焦点的节点(如果有)。如果没有这样的节点,它将接收焦点。
FocusableActionDetector 部件
#FocusableActionDetector 部件是一个将 Actions、Shortcuts、MouseRegion 和 Focus 部件的功能组合在一起的部件,以创建一个检测器,该检测器定义操作和键绑定,并提供用于处理焦点和悬停高亮的回调。这是 Flutter 控件实现所有这些控件方面的方式。它只是使用组成部件来实现的,因此如果您不需要所有功能,则可以使用您需要的功能,但它是一种将这些行为构建到自定义控件中的便捷方式。
控制焦点遍历
#一旦应用程序具有聚焦能力,许多应用程序想要做的下一步就是允许用户使用键盘或其他输入设备控制焦点。最常见的示例是“选项卡遍历”,用户按下 Tab 键转到“下一个”控件。控制“下一个”的含义是本节的主题。Flutter 默认提供这种遍历。
在简单的网格布局中,确定哪个控件是下一个控件相对容易。如果您不在行的末尾,则它是右侧的控件(对于从右到左的语言环境,则是左侧)。如果您在行的末尾,则是下一行的第一个控件。不幸的是,应用程序很少以网格布局,因此通常需要更多指导。
Flutter 中的默认算法(ReadingOrderTraversalPolicy)用于焦点遍历非常好:它为大多数应用程序提供了正确的答案。但是,总会有病态情况,或者上下文或设计需要与默认排序算法得出的不同顺序的情况。对于这些情况,还有其他机制可以实现所需的顺序。
FocusTraversalGroup 部件
#FocusTraversalGroup 部件应放置在树中围绕应完全遍历后再移动到另一个部件或部件组的部件子树周围。仅将部件分组到相关组中通常足以解决许多选项卡遍历排序问题。如果不是,该组还可以提供 FocusTraversalPolicy 来确定组内的顺序。
默认的 ReadingOrderTraversalPolicy 通常就足够了,但在需要更多排序控制的情况下,可以使用 OrderedTraversalPolicy。围绕可聚焦组件包装的 FocusTraversalOrder 部件的 order 参数确定顺序。顺序可以是 FocusOrder 的任何子类,但提供了 NumericFocusOrder 和 LexicalFocusOrder。
如果提供的焦点遍历策略都不足以满足您的应用程序,您还可以编写自己的策略并使用它来确定所需的任何自定义顺序。
这里有一个使用 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 是确定给定请求和当前焦点节点时下一个部件的对象。请求(成员函数)包括 findFirstFocus、findLastFocus、next、previous 和 inDirection。
FocusTraversalPolicy 是具体策略的抽象基类,例如 ReadingOrderTraversalPolicy、OrderedTraversalPolicy 和 DirectionalFocusTraversalPolicyMixin 类。
为了使用 FocusTraversalPolicy,您将其提供给 FocusTraversalGroup,它确定策略有效的部件子树。该类的成员函数很少被直接调用:它们旨在由焦点系统使用。
焦点管理器
#FocusManager 维护系统的当前主焦点。它只有几个对焦点系统用户有用的 API 片段。其中一个是 FocusManager.instance.primaryFocus 属性,它包含当前聚焦的焦点节点,也可以从全局 primaryFocus 字段访问。
其他有用的属性是 FocusManager.instance.highlightMode 和 FocusManager.instance.highlightStrategy。这些属性被需要根据设备的使用模式在“触摸”模式和“传统”(鼠标和键盘)模式之间切换焦点高亮的组件使用。当用户使用触摸进行导航时,焦点高亮通常是隐藏的,当他们切换到鼠标或键盘时,需要再次显示焦点高亮,以便他们知道哪个组件获得了焦点。hightlightStrategy 告诉焦点管理器如何解释设备使用模式的变化:它可以根据最新的输入事件自动在两者之间切换,或者可以锁定在触摸或传统模式。Flutter 提供的组件已经知道如何使用这些信息,因此只有在从头开始编写自己的控件时才需要它。您可以使用 addHighlightModeListener 回调来监听焦点高亮模式的变化。