键盘操作是计算机与人交互非常重要的一环,大部分优秀的软件都集成很多优秀的快捷键,甚至有些软件能做到使用键盘不使用鼠标就能完成整个软件的操作及使用,所以掌握软件中的键盘事件原理也是一个软件开发者的必修课,Flutter中提供了完善的事件传递及处理的方法,下面带大家学习一下。本篇幅较长,如果不想看细节和原理分析,可以直接看中间各个小结及总结部分
。
键盘事件类型
Flutter中事件有两种类型PhysicalKeyboardKey
和LogicalKeyboardKey
- PhysicalKeyboardKey(物理键):
顾名思义,PhysicalKeyboardKey表示键盘上的物理位置。它不考虑当前的键盘布局、输入语言或修饰键状态(如Shift或Alt)。Flutter在PhysicalKeyboardKey
类上提供了几乎覆盖所有按键的静态方法,可以通过如PhysicalKeyboardKey.keyA
表示A
键。
- LogicalKeyboardKey(逻辑键):
LogicalKeyboardKey则是在当前的键盘布局、输入语言和修饰键状态下的逻辑解释。它考虑了当前环境,因此同一个物理键在不同情况下可能对应不同的逻辑键。同上Flutter也在LogicalKeyboardKey
上提供了所有按键的静态方法,如LogicalKeyboardKey.keyA
表示A
键。
例如,如果你想要实现一个应用,其中按下"Q"键退出某个功能,你应该查看LogicalKeyboardKey
来检测这个,因为你想让它匹配标有"Q"的键,而不是总是查找"在TAB键旁边的那个键",因为在法式键盘上,"A"键位于TAB键旁边。相反,如果在你的游戏中,你希望CAPS LOCK键旁边的那个键(在QWERTY键盘上是"A"键)使玩家向左移动,你应该查看物理键,以确保无论该键产生什么字符,都能识别出键盘上那个位置的键。
如果要比较键值,我们可以通过PhysicalKeyboardKey
和LogicalKeyboardKey
上的静态属性来和按下的键进行比较,比如判断我当前按下了Q
,可以通过event.logiclKey == LogicalKeyboardKey.keyQ
判断。
键盘事件入口及监听方法
packages/flutter/lib/src/services/binding.dart
platformDispatcher.onKeyData = _keyEventManager.handleKeyData;
SystemChannels.keyEvent.setMessageHandler(_keyEventManager.handleRawKeyMessage);
2
键盘事件系统当前有两种触发入口,一个是onKeyData
一个是SystemChannels.keyEvent
。原因解释可以看这几个官方解释
- Platform-based Key Events
- FlutterEngineSendKeyEvent only sends message to the flutter/keydata channel after a flutter/keyevent message
- KeyEventManager解释
- Hardware keyboard: Web, embedder, and dart:ui。
简单解释是为了更好适配不同平台主要是PC端平台,Flutter新设计了onKeyData
的事件方式,但是一时半会删除不了keyEvent
的方式,所以设计了KeyEventManager
类进行抹平两个事件通知的差异,不过到目前为止,官方的Embedded还没有使用过onKeyData
方式来集成,所以本文的所有分析都建立在keyEvent
方式。
多提一点,老的keyEvent
是通过向flutter/keyevent
这个BasicMessageChannel通道发送消息的方式进行集成,新的onKeyData
是通过调用FlutterEngineSendKeyEvent
的方式进行集成。
KeyEventManager
KeyEventManager
提供两个入参_hardwareKeyboard
以及_rawKeyboard
,前者(HardwareKeyboard)是新的键盘事件管理器,提供更加规范的事件协议,后者(RawKeyboard)是老的事件管理器。
keyMessageHandler
,当键盘事件触发时会执行此函数,可以通过ServicesBinding.instance.keyEventManager.keyMessageHandler = handleKeyMessage
来绑定,但是一般不推荐通过此方式绑定,因为目前事件绑定在FocusManager
,如果其它地方绑定后会导致Focus相关的事件接收失效。
当键盘事件发生时,原始函数在进行一系列处理后,会通过keyMessageHandler
进行分发,同时也会触发_hardwareKeyboard
响应。
onKeyData
回调触发源码如下
packages/flutter/lib/src/services/hardware_keyboard.dart
//...
bool handleKeyData(ui.KeyData data) {
_transitMode ??= KeyDataTransitMode.keyDataThenRawKeyData;
switch (_transitMode!) {
case KeyDataTransitMode.rawKeyData:
assert(false, 'Should never encounter KeyData when transitMode is rawKeyData.');
return false;
case KeyDataTransitMode.keyDataThenRawKeyData:
if (data.physical == 0 && data.logical == 0) {
return false;
}
assert(data.physical != 0 && data.logical != 0);
final KeyEvent event = _eventFromData(data);
if (data.synthesized && _keyEventsSinceLastMessage.isEmpty) {
// 触发[HardwareKeyboard]中的事件监听回调
_hardwareKeyboard.handleKeyEvent(event);
// 触发[keyMessageHandler]中的事件监听回调
_dispatchKeyMessage(<KeyEvent>[event], null);
} else {
_keyEventsSinceLastMessage.add(event);
}
return false;
}
}
//...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
新的事件系统中不会触发_rawKeyboard
的回调,所以通过RawKeyboard.instance.addListener
监听的键盘事件新系统中不会有响应。_hardwareKeyboard.handleKeyEvent(event)
会触发HardwareKeyboard
里的监听回调,_dispatchKeyMessage
触发keyMessageHandler
事件监听回调,其中keyMessageHandler
会被FocusManager
注册消费。
handleRawKeyMessage
packages/flutter/lib/src/services/hardware_keyboard.dart
//...
Future<Map<String, dynamic>> handleRawKeyMessage(dynamic message) async {
if (_transitMode == null) {
_transitMode = KeyDataTransitMode.rawKeyData;
// 触发_rawKeyboard的监听回调
_rawKeyboard.addListener(_convertRawEventAndStore);
}
final RawKeyEvent rawEvent = RawKeyEvent.fromMessage(message as Map<String, dynamic>);
bool shouldDispatch = true;
// 特殊处理键盘按下和抬起事件
if (rawEvent is RawKeyDownEvent) {
if (!rawEvent.data.shouldDispatchEvent()) {
shouldDispatch = false;
_skippedRawKeysPressed.add(rawEvent.physicalKey);
} else {
_skippedRawKeysPressed.remove(rawEvent.physicalKey);
}
} else if (rawEvent is RawKeyUpEvent) {
if (_skippedRawKeysPressed.contains(rawEvent.physicalKey)) {
_skippedRawKeysPressed.remove(rawEvent.physicalKey);
shouldDispatch = false;
}
}
bool handled = true;
if (shouldDispatch) {
// 触发[RawKeyboard]的监听回调
handled = _rawKeyboard.handleRawKeyEvent(rawEvent);
for (final KeyEvent event in _keyEventsSinceLastMessage) {
// 触发[HardwareKeyboard]事件监听回调
handled = _hardwareKeyboard.handleKeyEvent(event) || handled;
}
//...
// 触发[keyMessageHandler]中的事件监听回调
handled = _dispatchKeyMessage(_keyEventsSinceLastMessage, rawEvent) || handled;
_keyEventsSinceLastMessage.clear();
}
return <String, dynamic>{ 'handled': handled };
}
//...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
两个事件机制处理几乎一样,handled
表示Flutter中是否消费该键盘事件,如果Flutter没有消费,引擎接入层可以根据此判断事件消费规则。
如果我们要在业务中全局监听某个键盘事件怎么办呢,我们可以使用HardwareKeyboard
来做到这一点。
HardwareKeyboard
HardwareKeyboard提供了键盘事件监听以及提供事件按下列表
属性:
- physicalKeysPressed->Set
当前已按下的物理键值 - logicalKeysPressed->Set
: 当前已按下的逻辑键值 - lockModesEnabled->Set
: 当前已按下的lock键(numLock、scrollLock、capsLock)
方法:
- addHandler(KeyEventCallback handler): 添加键盘事件监听
- removeHandler(KeyEventCallback handler): 移除键盘事件监听
如果我们想全局监听一些键盘事件且不想影响其它已有的功能,那么可以用addHandler
方法来进行监听,比如以下例子,实现Ctrl+S保存一段信息
class _MyWidgetState extends State<MyWidget> {
void initState() {
super.initState();
HardwareKeyboard.instance.addHandler(_handleEvent);
}
void dispose() {
super.dispose();
HardwareKeyboard.instance.removeHandler(_handleEvent);
}
bool _handleEvent(event) {
if (HardwareKeyboard.instance.logicalKeysPressed.containsAll(
[LogicalKeyboardKey.controlLeft, LogicalKeyboardKey.keyA])) {
print('Press Ctrl+A');
return true;
}
return false;
}
//...
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
当同时按下Ctrl
和A
时,就会打印上面的Press Ctrl+A
。通过HardwareKeyboard
的addHandler
就可以实现全局对键盘事件的监听。
小结
Flutter中封装处理了键盘事件。Flutter提供了两种事件传递机制,但是当前都还没有完全迁入新的机制中,所以添加了KeyEventManager
来进行统一处理。如果我们需要全局监听键盘事件,可以通过HardwareKeyboard.instance.addHandler
进行监听处理我们需要的键盘事件。
但是我们的界面一般都是很复杂的,比如我们界面有很多按钮,每个按钮都能响应Enter键表示按下,那么当我们按下Enter键时到底应该哪个组件响应呢?这个时候我们就要用到Flutter中的焦点系统来管理。
焦点系统(Focus)
Flutter提供了一系列组件用于焦点的管理,在了解此内容之前,先简单列几个专业术语
- 焦点树(Focus tree): 焦点像Widget树一样是一个树形结构,所有焦点组成了焦点树
- 焦点节点(FocusNode): 焦点树中的节点。它上面有一些属性,比如
hasFocus
可以判断当前是否获得焦点,只有获得焦点才会响应键盘事件。 - 主焦点(primaryFocus): 当前正获得焦点的焦点节点。主焦点节点全局有且只有一个。
- 焦点链(Focus chain): 焦点节点的有序列表。
- 焦点范围(Focus scope): 特殊的焦点节点,用于控制焦点范围。当一组焦点节点被此组件包裹时,焦点就只能在这个组件中移动
- 焦点遍历(Focus traversal): 焦点从一个移动到另一个的过程。一般我们可以通过键盘的
Tab
进行切换焦点。
Focus
Focus组件是Flutter提供的专用于控制组件焦点组件,我们只需要简单的将自定义的组件使用Focus
包起来,就能让我们组件拥有焦点并做一些特定的事情。
Focus提供重要入参如下
- child:子组件
- focusNode: 焦点节点
- parentNode: 父焦点节点
- autofocus: 如果为true,当前节点会默认获取焦点
- onFocusChange: 焦点变化时回调
- onKeyEvent: 键盘事件回调
- onKey: 同上,建议业务中都使用onKeyEvent,如果用新的键盘事件接入,onKey将不会收到事件回调。
- canRequestFocus: 是否支持获取焦点。如果是true,当前节点不会获取焦点,也不能使用
requestFocus
来获取焦点,不过当子节点获取焦点时,它的hasFocus
依然返回true - skipTraversal: 无法通过遍历获取焦点(比如通过按
Tab
键)。与canRequestFocus
的区别是此属性仅影响FocusTraversalPolicy
遍历的方式来获取焦点,我们还是可以通过requestFocus
来获取焦点。 - descendantsAreFocusable: 默认为true,如果为false,可以使该组件的后代节点无法获得焦点(与
canRequestFocus
对应,只是影响对象不一样)。 - descendantsAreTraversable: 默认为true,如果为false,可以使该组件的后代节点无法被遍历(与
skipTraversal
对应,会影响skipTraversal
的结果) - includeSemantics: 默认为true,会在组件中包
Semantics
Focus
组件主要是做以下几件事
- 将
focusNode
与Focus组件关联起来 - 嵌套
_FocusInheritedScope
,然后可以通过Focus.maybeOf
来获取当前组件父组件上最近的FocusNode
节点
Focus
组件和FocusNode
共同组成了一个焦点节点,其中focusNode可以从外面传入,也可以Focus
组件内部生成。
下面再简单介绍一下FocusNode
FocusNode
FocusNode
组件也提供了onKey
、onKeyEvent
、skipTraversal
、canRequestFocus
、descendantsAreFocusable
、descendantsAreTraversable
入参,效果同上面一致,但是如果Focus
组件上设置过,Focus
组件上的优先级更高。
它还提供了一些有用的属性
- canRequestFocus: 当前组件是否能获得焦点。它会依次判断当前组件的
_canRequestFocus
,父组件的canRequestFocus
,以及祖先节点的descendantsAreFocusable
。 - parent: 父焦点节点。记录的是焦点链的父节点,不是Widget组件的父节点,返回的是
FocusNode
- children: 当前节点的子焦点节点。同上,返回的是
Iterable<FocusNode>
。 - traversalChildren: 当前节点的
可遍历
的子焦点节点。根据descendantsAreFocusable
、canRequestFocus
、skipTraversal
综合判断获取的结果。 - descendants: 当前节点的所有后代焦点节点。
- traversalDescendants: 当前节点的所有
可遍历
的后代焦点节点。 - ancestors: 祖先节点们。
- hasFocus: 是否获得焦点,当祖先节点获得焦点,此属性也返回true。
- hasPrimaryFocus: 当前节点是否获得焦点。与
hasFocus
不同的是此属性会与primaryFocus
全匹配。 - nearestScope: 通过查找祖先节点,找到最近的
FocusScopeNode
,包含自己。因为本身肯定不是FocusScopeNode
, 所以默认指向enclosingScope
。 - enclosingScope: 通过查找祖先节点,找到最近的
FocusScopeNode
,不包含自己。 - size: 与此
FocusNode
绑定的Focus
组件的宽高。 - offset: 与此
FocusNode
绑定的Focus
组件的位置。位置经过变换,拿到的是组件相对于全局窗口的位置。
同样也提供了一些方法:
- unfocus: 移除当前节点的焦点,将焦点移动到下一个节点或者上一个节点。
- consumeKeyboardToken: 当节点获得焦点时会获得一个令牌,可以通过此函数来判断是否存在令牌,并消耗令牌。主要用于移动端拉起键盘。
- reparent:将此节点添加到传入的
parent
节点的children
中。 - attach: Widget与此FocusNode建立连接。
Focus
组件也是通过此函数与FocusNode
建立的一对一的关系。 - dispose: Widget与此FocusNode断开连接时调用。
- requestFocus([FocusNode? node]): 请求为当前节点或者传入的
node
节点获取焦点。 - nextFocus: 焦点向下一个节点移动。
- previousFocus: 焦点向上一个节点移动。
- focusInDirection: 通过方向控制焦点的移动。
当然FocusNode
本身使用了ChangeNotifier
,我们可以通过focusNode.addListener
来监听当前节点的焦点变化。
FocusScope & FocusScopeNode
FocusScopeNode
是一种特殊的FocusNode
,它将FocusNode
组成更小的范围。比如下图的例子
当前面三个输入框被FocusScope包裹后,它们就组成了一个内部的焦点范围,此时按Tab键只能在这三个输入框中切换焦点,当我手动将焦点移动到4输入框后,此时焦点会在外面的焦点范围切换,从TextButton
重新切换到FcousScope
范围后,上次离开的焦点会再次获得焦点。
- traversalEdgeBehavior: 用于控制子节点到达最后一个节点后焦点的移动规则。
TraversalEdgeBehavior.closedLoop
表示循环移动,达到最后一个后又从第一个开始TraversalEdgeBehavior.leaveFlutterView
表示当达到最后一个后脱离当前FlutterView。
- focusedChild: 获取当前节点希望获得主焦点的节点。
FocusScope
中会缓存一个焦点移动的列表(_focusedChildren),focusChild
总是取这个列表的最后一个节点。
方法:
- setFirstFocus(FocusScopeNode scope): 将
scope
设置为当前范围内的子焦点,同时让scope
获得焦点。 - autofocus(FocusNode node): 将
node
设置为自动获得焦点。
FocusManager
管理焦点(FocusNode)的类。FocusManager
由BuildOwner
持有并可以通过WidgetsBinding.instance.focusManager
访问。它通过持有根FocusScopeNode
以及管理primaryFocus
来管理主焦点。下面我们详细介绍它的属性以及方法。
属性:
- instance: 指向
WidgetsBinding.instance.focusManager
,所以也可以通过FocusManager.instance
来获取全局FocusManager
- highlightStrategy: 设置当前的焦点交互方式,决定
highlightMode
的返回。 - highlightMode: 决定当前焦点的交互方案。部分组件如
Switch
、DatePicker
、Slider
等只会在FocusHighlightMode.traditional
下才会显示带焦点的高亮,FocusHighlightMode.touch
则不会显示。 - rootScope: 所有
FocusNode
节点的根节点。 - primaryFocus: 当前获得焦点的节点。
方法:
- registerGlobalHandlers: 注册键盘事件监听,在
BuildOwner
创建的时候调用。 - addHighlightModeListener: 添加
highlightMode
变化回调。 - removeHighlightModeListener: 移除
highlightMode
变化回调。 - _markNextFocus(FocusNode node): 由
FocusNode
调用,将node
设置为焦点。
跟FocusNode
一样,FocusManager
也使用了ChangeNotifier
,所以我们可以通过使用FocusManager.instance.addListener
来全局监听焦点的变化。
FocusTraversalGroup & FocusTraversalPolicy
决定当前焦点链的遍历方式的类,可以通过实现sortDescendants
来改变焦点链的顺序从而改变焦点移动的顺序。
FocusTraversalGroup
是一个Flutter Widget,其构造函数入参有
- policy: 决定焦点从一个节点到另一个节点的规则。默认采用
ReadingOrderTraversalPolicy
,将按照阅读习惯,从左往右,从上到下。 - descendantsAreFocusable: 同上
Focus
- descendantsAreTraversable: 同上
Focus
- child: 子组件节点
此组件主要承载
policy
FocusTraversalPolicy
就是决定焦点从一个节点到另一个节点的规则。其有以下方法
- findFirstFocus(FocusNode currentNode, {bool ignoreCurrentFocus = false}): 根据
currentNode
返回当前焦点链中第一个节点 - findLastFocus: 用于返回当前焦点链中最后一个节点
- findFirstFocusInDirection: 通过传入的
direction
找到焦点链中的第一个节点 - invalidateScopeData(FocusScopeNode node): 用于清除
node
- changedScope({FocusNode? node, FocusSCopeNode? oldScope}): 将焦点node从oldScope更换到当前scope
- next(FocusNode currentNode): 将焦点移动到下一个节点
- previous(FocusNode currentNode): 将焦点移动到上一个节点
- inDirection(FocusNode currentNode, TraversalDirection direction): 通过传入的
direction
找到下一个焦点节点,找到了返回true否则返回false - sortDescendants: 调整焦点节点的排序规则
简单说FocusTraversalPolicy
是焦点系统中确认焦点遍历策略的类,我们可以通过实现它来完成焦点遍历规则的指定,Flutter自己也实现了多个规则,如下
- WidgetOrderTraversalPolicy: 依赖焦点节点在Widget中的构建顺序来定义焦点顺序。
- ReadingOrderTraversalPolicy: 它按照阅读(文本)的自然顺序来确定焦点的移动。这个顺序通常取决于使用的语言(例如,从左到右或从右到左,对于从上到下的方向也是如此)。
- OrderedTraversalPolicy: 这个重点说一下,Flutter中本身并没有使用该类的地方,它提供了一个更加灵活的遍历方式,我们可以通过自定义比较函数来定义焦点节点的遍历顺序。以下有个例子
import 'package:flutter/material.dart';
class DemoButton extends StatelessWidget {
const DemoButton({
Key? key,
required this.name,
this.autofocus = false,
required this.order,
}) : super(key: key);
final String name;
final bool autofocus;
final double order;
void _handleOnPressed() {
debugPrint('Button $name pressed.');
debugDumpFocusTree();
}
Widget build(BuildContext context) {
return FocusTraversalOrder(
order: NumericFocusOrder(order),
child: TextButton(
autofocus: autofocus,
onPressed: () => _handleOnPressed(),
child: Text(name),
),
);
}
}
class OrderedTraversalPolicyExample extends StatelessWidget {
const OrderedTraversalPolicyExample({Key? key}) : super(key: key);
Widget build(BuildContext context) {
return FocusTraversalGroup(
policy: OrderedTraversalPolicy(),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
DemoButton(name: 'Six', order: 6),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
DemoButton(name: 'Five', order: 5),
DemoButton(name: 'Four', order: 4),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
DemoButton(name: 'Three', order: 3),
DemoButton(name: 'Two', order: 2),
DemoButton(name: 'One', order: 1, autofocus: true),
],
),
],
),
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
通过父组件使用FocusTraversalGroup
,子组件使用FocusTraversalOrder
配合NumericFocusOrder
,我们就能实现自定义的焦点移动。上面代码执行后如下
通过点按Tab
键,焦点会从地下的One
开始向上移动。
其它关键类
- FocusAttachment: 用于FocusNode与Widget建立绑定关系的类。
- FocusableActionDetector: 用于检测焦点情况,控制UI样式
完整链路梳理
初始化
当Flutter启动时,BuildOwner
组件会初始化FocusManager
,FocusManager
创建时会初始化rootScope
,rootScope
会作为焦点链的根节点。在Navigator
和Route
组件中,都有使用FcousScope
,其中Route
中会初始化一个FocusScopeNode
,通过调用setFirstFocus
与父FocusScopeNode
建立联系,然后TextField
、Switch
、TextButton
组件通过使用Focus
组件的方式绑定了FocusNode
,它们通过调用reparent
的方式与父FocusScopeNode建立绑定。
焦点遍历
为了更好的体验,焦点是可以通过自动往下一个焦点移动,比如移动端的键盘上的下一个
,或者在桌面端上按Tab
键。下面是按下Tab
键键盘的移动流程。
顺序移动焦点
当按下
Tab
键时,会触发NextFocusAction.invoke
(后续再讲这块),invoke
中通过调用当前焦点节点的nextFocus
进行焦点移动,FocusNode
中会调用FocusTraversalPolicy
的next
函数,通过FocusTraversalPolicy
的策略拿到下一个节点,下一个节点会调用自身的requestFocus
,最后通过调用FocusManager
的_markNextFocus
将自身设置为主焦点,最后触发监听界面响应。
反向移动焦点
上图大部分流程上跟顺序移动流程一致,只有黄色部分不一致。最大不一样是
_moveFocus
中会从焦点链中反向找到焦点节点,然后调用该节点的requestFocus
。
方向键移动焦点
除了使用
Tab
以及Tab + Shift
进行焦点移动外,还支持使用方向键进行焦点移动,在这个场景下,一般焦点移动的规则是依赖布局位置,Flutter提供了DirectionalFocusTraversalPolicyMixin
这个mixin来处理方向的焦点移动,它通过实现inDirection
,通过焦点节点提供的rect
属性进行位置比较获取更合适的位置,然后将该焦点设置为主焦点。
触发键盘事件
接上面KeyEventManager
部分,FocusManager
初始化时会调用ServicesBinding.instance.keyEventManager.keyMessageHandler = handleKeyMessage
绑定事件。
handleKeyMessage
中的处理:
packages/flutter/lib/src/widgets/focus_manager.dart
bool handleKeyMessage(KeyMessage message) {
// ...
bool handled = false;
// 主焦点节点以及该节点的祖先节点都会响应事件
for (final FocusNode node in <FocusNode>[
FocusManager.instance.primaryFocus!,
...FocusManager.instance.primaryFocus!.ancestors,
]) {
final List<KeyEventResult> results = <KeyEventResult>[];
// 调用onKeyEvent,触发事件回调
if (node.onKeyEvent != null) {
for (final KeyEvent event in message.events) {
results.add(node.onKeyEvent!(node, event));
}
}
// 调用onKey,触发事件回调
if (node.onKey != null && message.rawEvent != null) {
results.add(node.onKey!(node, message.rawEvent!));
}
final KeyEventResult result = combineKeyEventResults(results);
// 处理事件向上传递规则
switch (result) {
case KeyEventResult.ignored:
continue;
case KeyEventResult.handled:
handled = true;
case KeyEventResult.skipRemainingHandlers:
handled = false;
}
break;
}
if (!handled) {
assert(_focusDebug(() => 'Key event not handled by anyone: $message.'));
}
return handled;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
通过循环主焦点节点以及它的祖先节点,依次触发事件响应。根据返回的KeyEventResult
可以决定事件传递规则
- ignored:忽略当前节点,事件继续向祖先
Focus
节点传递 - handled:事件被处理,终止事件向上传递并向引擎返回事件被处理标记
- skipRemainingHandlers:事件被处理,终止事件向上传递但向引擎返回事件未被处理标记,让引擎处理后续事件
小结
- 焦点系统方便我们把某些事件聚焦到某个组件上而不是全局响应
- 焦点节点是一个树,但是主焦点有且只有一个,所以事件响应可以从主焦点沿着树往上向根节点冒泡,直到事件被接受处理(返回
KeyEventResult.handled
) - 焦点系统有自己的移动规则,我们也可以根据软件特性自定义移动规则
- 在焦点不知道去哪的时候,Flutter提供了一些有用的焦点调试方法,如可以通过设置
debugFocusChanges = true
来输出更多有用的焦点日志,也可以使用debugDumpFocusTree
或debugDescribeFocusTree
来打印当前焦点树的信息。
快捷键(Shortcuts)
焦点系统只能监听原始事件并处理,如果我要定义多个快捷键要处理起来是很不友好,所以Flutter提供了快捷键相关的组件来方便我们定义自己的快捷键。
CallbackShortcuts
CallbackShortcuts
是一个Widget组件,我们可以添加一个binding
来定义键盘事件的响应,如
build(BuildContext context) {
return CallbackShortcuts(
bindings: <ShortcutActivator, VoidCallback>{
const SingleActivator(LogicalKeyboardKey.arrowUp): () {
setState(() => count = count + 1);
},
const SingleActivator(LogicalKeyboardKey.arrowDown): () {
setState(() => count = count - 1);
},
},
child: Focus(
autofocus: true,
child: Column(
children: <Widget>[
Text('count: $count'),
],
),
),
);
}
Widget
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
上面代码组件会自动获得焦点,CallbackShortcuts
的binding上绑定了两个快捷键事件,LogicalKeyboardKey.arrowUp
是方向键上,点击后count
会加1,LogicalKeyboardKey.arrowDown
是方向键下,点击后count
会减1。bindings
是一个Map类型,键是ShortcutActivator
用来定义一组快捷键,值是空函数。
ShortcutActivator
一般快捷键都是多个键组合的,比如我们常用的复制(Ctrl + C)、粘贴(Ctrl + V)、全选(Ctrl + A)等,ShortcutActivator
就是将这些快捷键键组合起来的类,它本身是一个接口类,有以下定义
Iterable
Flutter提供了几种方便的ShortcutActivator
- LogicalKeySet: 支持传入一组
LogicalKeyboardKey
,有且仅有这些键按下时会响应 - SingleActivator: 仅支持传入一个
LogicalKeyboardKey
,但支持设置常用功能键control
、shift
、alt
、meta
是否按下 - CharacterActivator: 类似
SingleActivator
,区别是传入是是一个按键的字符串character
,该character
和RawKeyEvent
下的character
进行匹配。
Shortcuts & Actions
对于简单的快捷键操作上面的ShortcutActivator
就已经很好用了,对于更复杂界面及业务场景,快捷键的意图与操作分离也是必要的。比如我们知道Ctrl + C
是复制,但是在不同组件复制的内容是不一样的,在输入框中复制的输入框里所有文字,下拉框中复制的是当前选中的内容,日期选择中复制的是当前选择的日志格式化形式。为了应对这种场景,Flutter提供了两个组件Shortcuts
和Actions
,Shortcuts
用来定义快捷键的意图;Actions
用来定义快捷键的具体操作。
图片来自https://docs.flutter.dev/ui/interactivity/actions-and-shortcuts
从工作原理来说,分为两个过程,首先Shortcuts
中根据从主焦点节点沿着焦点链向上找到最近的Intent
(依赖Focus能力),然后根据Intent
找到最近的Action
最后执行Action
上的invoke
方法。通过这两个过程完成键盘绑定与操作的解耦。我们可以定义通用的快捷键,子组件自行实现Action,也可以定义通用的Action,子组件中绑定不同的快捷键。
下面用一个通过改写上面CallbackShortcuts
的简单示例看看Shortcuts
和Actions
如何工作的
class ShortcutsExampleApp extends StatelessWidget {
const ShortcutsExampleApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: const Text('Shortcuts Sample')),
body: const Center(
child: ShortcutsExample(),
),
),
);
}
}
class Model with ChangeNotifier {
int count = 0;
void incrementBy(int amount) {
count += amount;
notifyListeners();
}
void decrementBy(int amount) {
count -= amount;
notifyListeners();
}
}
class IncrementIntent extends Intent {
const IncrementIntent(this.amount);
final int amount;
}
class DecrementIntent extends Intent {
const DecrementIntent(this.amount);
final int amount;
}
class IncrementAction extends Action<IncrementIntent> {
IncrementAction(this.model);
final Model model;
void invoke(covariant IncrementIntent intent) {
model.incrementBy(intent.amount);
}
}
class DecrementAction extends Action<DecrementIntent> {
DecrementAction(this.model);
final Model model;
void invoke(covariant DecrementIntent intent) {
model.decrementBy(intent.amount);
}
}
class ShortcutsExample extends StatefulWidget {
const ShortcutsExample({super.key});
State<ShortcutsExample> createState() => _ShortcutsExampleState();
}
class _ShortcutsExampleState extends State<ShortcutsExample> {
Model model = Model();
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
LogicalKeySet(LogicalKeyboardKey.arrowUp): const IncrementIntent(2),
LogicalKeySet(LogicalKeyboardKey.arrowDown): const DecrementIntent(2),
},
child: Actions(
actions: <Type, Action<Intent>>{
IncrementIntent: IncrementAction(model),
DecrementIntent: DecrementAction(model),
},
child: Focus(
autofocus: true,
child: Column(
children: <Widget>[
AnimatedBuilder(
animation: model,
builder: (BuildContext context, Widget? child) {
return Text('count: ${model.count}');
},
),
],
),
),
),
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
此示例跟上面一样,通过键盘的上下键控制数字的增减。Shortcuts
和Actions
都接受一个map,它们通过Intent
进行获得联系,当键盘上触发时,首先根据Shortcuts
组件找到绑定的IncrementIntent
,然后根据Intent
找到IncrementAction
并执行Action。
下面通过源码分析一下Shortcuts
及Actions
工作原理。
Shortcuts
我们看看Shortcuts
部分代码
packages/flutter/lib/src/widgets/shortcuts.dart
class _ShortcutsState extends State<Shortcuts> {
// ...
KeyEventResult _handleOnKey(FocusNode node, RawKeyEvent event) {
if (node.context == null) {
return KeyEventResult.ignored;
}
return manager.handleKeypress(node.context!, event);
}
Widget build(BuildContext context) {
return Focus(
debugLabel: '$Shortcuts',
canRequestFocus: false,
onKey: _handleOnKey,
child: widget.child,
);
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
从这里可以看出Shortcuts
仅仅是使用了Focus
组件,它俩在实现上的是没有耦合的。Shortcuts
很简单,它使用Focus
键盘事件,并持有一个ShortcutManager
,当Focus
的事件触发后,执行ShortcutManager
的handleKeypress
。
在ShortcutManager
中的执行如下
packages/flutter/lib/src/widgets/shortcuts.dart
//...
KeyEventResult handleKeypress(BuildContext context, RawKeyEvent event) {
// 通过持有的shortcuts以及event找到匹配的Intent
final Intent? matchedIntent = _find(event, RawKeyboard.instance);
if (matchedIntent != null) {
final BuildContext? primaryContext = primaryFocus?.context;
if (primaryContext != null) {
// 从主焦点开始向上寻找,找到第一个匹配的Action就返回
final Action<Intent>? action = Actions.maybeFind<Intent>(
primaryContext,
intent: matchedIntent,
);
if (action != null && action.isEnabled(matchedIntent)) {
// 触发action响应
final Object? invokeResult = Actions.of(primaryContext).invokeAction(action, matchedIntent, primaryContext);
return action.toKeyEventResult(matchedIntent, invokeResult);
}
}
}
return modal ? KeyEventResult.skipRemainingHandlers : KeyEventResult.ignored;
}
//...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
触发Action分为几步,首先会先通过event
找到匹配的意图Intent
,然后通过当前组件(也就是Shortcuts
)的context
向上查找,找到最近的Action
,然后触发执行Action
的invoke
以及toKeyEventResult
。
Actions.of
返回的是ActionDispatcher
,它负责Action的invoke
的执行。
Actions
上面触发Actions的过程中使用了多个Actions的方法,实际上,Actions
还提供了更丰富的方法。
find & maybeFind
根据Intent
找到最近的Action。find
与maybeFind
区别是find没找到会报错,maybeFind
没找到会返回null。
Action<SelectAllIntent> selectAllAction = Actions.find<SelectAllIntent>(context);
Action<SelectAllIntent>? selectAll = Actions.maybeFind<SelectAllIntent>(context);
2
invoke & maybeInvoek
通过context及Intent找到最近的Action并执行。区别是invoke
没找到对应的Action会报错,maybeInvoek
不会。
TextButton(
onPressed: Actions.maybeFind<SelectAllIntent>(context) != null ? () {
Actions.invoke<SelectAllIntent>(context, SelectAllIntent(controller: controller));
} : null,
child: Text('SelectAll'),
)
2
3
4
5
6
handler
如果要根据Intent
及context
先找到当前组件最近的Action,先返回一个空函数,没找到会返回null,再次执行函数会触发Action的执行。可以配合在某些操作上,比如点击后触发SelectAll
的Action
。
上面的例子通过handler
改写会简单很多
TextButton(
onPressed: Actions.handler<SelectAllIntent>(
context,
SelectAllIntent(controller: controller),
),
child: const Text('SELECT ALL'),
)
2
3
4
5
6
7
已有的 Intent & Action
Intent
本身几乎没什么含义,但Flutte也定义了很多有多种含义的Intent
及Action
- ContextAction: invoke执行时会带context的特殊Action
- CallbackAction: 可以直接通过传入
onInvke
回调的Action - VoidCallbackIntent & VoidCallbackAction: 直接通过
VoidCallbackAction(() { })
当成Action传入。类似CallbackAction
,不同的是回调中不会带Intent参数。 - DoNotingIntent & DoNothingAction: 不做任何事情的Action,可以用于阻止Action向上传递
- ActivateIntent & ActivateAction: 激活组件。默认会被绑定到
空格键
,在除了Web端上还会绑定到Enter键
。 - ButtonActivateIntent: 在Web端上被绑定到
Enter
键上。 - SelectIntent & SelectAction: 选择组件。
- DismissIntent & DismissAction: 退出,绑定在ESC键上。
- PrioritizedIntents & PrioritizedAction: 可以传入多个
Intent
并按顺序执行直到有Action
响应。可以方便组合多个Intent。
示例
class ShortcutsAndAction extends StatefulWidget {
const ShortcutsAndAction({Key? key}) : super(key: key);
State<ShortcutsAndAction> createState() => _ShortcutsAndActionState();
}
class _ShortcutsAndActionState extends State<ShortcutsAndAction> {
Widget build(BuildContext context) {
return Scaffold(
body: Shortcuts(
shortcuts: {
SingleActivator(LogicalKeyboardKey.keyC,
control: !Platform.isMacOS, meta: Platform.isMacOS): CopyIntent()
},
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_Input(),
_Select(),
],
),
),
);
}
}
class CopyIntent extends Intent {
const CopyIntent();
}
class _InputAction extends Action<CopyIntent> {
final TextEditingController? controller;
_InputAction(this.controller);
void invoke(CopyIntent intent) {
print('copied in input ${controller?.text}');
}
}
class _Input extends StatelessWidget {
_Input({Key? key}) : super(key: key);
final TextEditingController _controller = TextEditingController();
Widget build(BuildContext context) {
return Actions(
actions: {CopyIntent: _InputAction(_controller)},
child: TextField(
controller: _controller,
),
);
}
}
class _SelectAction extends Action<CopyIntent> {
final _SelectState? state;
_SelectAction(this.state);
void invoke(CopyIntent intent) {
print('copied in select ${state?._value}');
}
}
class _Select extends StatefulWidget {
const _Select({Key? key}) : super(key: key);
State<_Select> createState() => _SelectState();
}
class _SelectState extends State<_Select> {
int? _value;
void initState() {
super.initState();
}
Widget build(BuildContext context) {
return Actions(
actions: {
CopyIntent: _SelectAction(this),
},
child: DropdownMenu<int>(
onSelected: (value) => _value = value,
dropdownMenuEntries: List.generate(5, (i) => i)
.map((v) => DropdownMenuEntry(value: v, label: v.toString()))
.toList(),
),
);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
上面我在最顶部包裹了Shortcuts
组件并设置了快捷键,快捷键响应的规则是在MacOS上是Command + C
,其它平台上是Ctrl + C
,并设置了其对应的意图为CopyIntent
,然后在输入框组件_Input
和下拉框组件_Select
中各自实现了CopyIntent
对应的操作,这样就实现了快捷键和操作解耦,在快捷键设置的地方可以自行根据不同平台设置,在组件中可以定义自己的响应规则。
小结
- 可以使用
CallbackShortcuts
很便捷的定义快捷键及响应 Shortcuts
和Action
是为了解耦快捷键和操作的设置,它俩可以配合使用Actions
也可以单独使用,如直接调用Actions.invoke<CopyIntent>(context, DismissIntent())
触发组件关闭
总结
这篇文章主要是探讨了Flutter Framework层的键盘事件传递的原理。首先,我们可以通过使用HardwareKeyboard.instance.addHandler
来全局监听键盘事件,其次可以通过使用Focus
组件来将组件设置为焦点节点,当该组件获得焦点时才响应键盘事件,在这个基础上可以使用CallbackShortcuts
、或者Shortcuts
搭配Actions
来设计一些通用的快捷键及响应规则。