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