FlutterDartFlutter事件

键盘操作是计算机与人交互非常重要的一环,大部分优秀的软件都集成很多优秀的快捷键,甚至有些软件能做到使用键盘不使用鼠标就能完成整个软件的操作及使用,所以掌握软件中的键盘事件原理也是一个软件开发者的必修课,Flutter中提供了完善的事件传递及处理的方法,下面带大家学习一下。本篇幅较长,如果不想看细节和原理分析,可以直接看中间各个小结及总结部分

键盘事件类型

Flutter中事件有两种类型PhysicalKeyboardKeyLogicalKeyboardKey

  • 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"键)使玩家向左移动,你应该查看物理键,以确保无论该键产生什么字符,都能识别出键盘上那个位置的键。

如果要比较键值,我们可以通过PhysicalKeyboardKeyLogicalKeyboardKey上的静态属性来和按下的键进行比较,比如判断我当前按下了Q,可以通过event.logiclKey == LogicalKeyboardKey.keyQ判断。

键盘事件入口及监听方法

packages/flutter/lib/src/services/binding.dart

platformDispatcher.onKeyData = _keyEventManager.handleKeyData;
SystemChannels.keyEvent.setMessageHandler(_keyEventManager.handleRawKeyMessage);
1
2

键盘事件系统当前有两种触发入口,一个是onKeyData一个是SystemChannels.keyEvent。原因解释可以看这几个官方解释

简单解释是为了更好适配不同平台主要是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;
    }
  }
//...
1
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 };
  }
//...
1
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;
  }
  //...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

当同时按下CtrlA时,就会打印上面的Press Ctrl+A。通过HardwareKeyboardaddHandler就可以实现全局对键盘事件的监听。

小结

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组件主要是做以下几件事

  1. focusNode与Focus组件关联起来
  2. 嵌套_FocusInheritedScope,然后可以通过Focus.maybeOf来获取当前组件父组件上最近的FocusNode节点

Focus组件和FocusNode共同组成了一个焦点节点,其中focusNode可以从外面传入,也可以Focus组件内部生成。

下面再简单介绍一下FocusNode

FocusNode

FocusNode组件也提供了onKeyonKeyEventskipTraversalcanRequestFocusdescendantsAreFocusabledescendantsAreTraversable入参,效果同上面一致,但是如果Focus组件上设置过,Focus组件上的优先级更高。

它还提供了一些有用的属性

  • canRequestFocus: 当前组件是否能获得焦点。它会依次判断当前组件的_canRequestFocus,父组件的canRequestFocus,以及祖先节点的descendantsAreFocusable
  • parent: 父焦点节点。记录的是焦点链的父节点,不是Widget组件的父节点,返回的是FocusNode
  • children: 当前节点的子焦点节点。同上,返回的是Iterable<FocusNode>
  • traversalChildren: 当前节点的可遍历的子焦点节点。根据descendantsAreFocusablecanRequestFocusskipTraversal综合判断获取的结果。
  • 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)的类。FocusManagerBuildOwner持有并可以通过WidgetsBinding.instance.focusManager访问。它通过持有根FocusScopeNode以及管理primaryFocus来管理主焦点。下面我们详细介绍它的属性以及方法。

属性:

  • instance: 指向WidgetsBinding.instance.focusManager,所以也可以通过FocusManager.instance来获取全局FocusManager
  • highlightStrategy: 设置当前的焦点交互方式,决定highlightMode的返回。
  • highlightMode: 决定当前焦点的交互方案。部分组件如SwitchDatePickerSlider等只会在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),
            ],
          ),
        ],
      ),
    );
  }
}
1
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样式

完整链路梳理

初始化

Focus初始化

当Flutter启动时,BuildOwner组件会初始化FocusManager,FocusManager创建时会初始化rootScoperootScope会作为焦点链的根节点。在NavigatorRoute组件中,都有使用FcousScope,其中Route中会初始化一个FocusScopeNode,通过调用setFirstFocus与父FocusScopeNode建立联系,然后TextFieldSwitchTextButton组件通过使用Focus组件的方式绑定了FocusNode,它们通过调用reparent的方式与父FocusScopeNode建立绑定。

焦点遍历

为了更好的体验,焦点是可以通过自动往下一个焦点移动,比如移动端的键盘上的下一个,或者在桌面端上按Tab键。下面是按下Tab键键盘的移动流程。

顺序移动焦点

当按下Tab键时,会触发NextFocusAction.invoke(后续再讲这块),invoke中通过调用当前焦点节点的nextFocus进行焦点移动,FocusNode中会调用FocusTraversalPolicynext函数,通过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;
  }
1
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来输出更多有用的焦点日志,也可以使用debugDumpFocusTreedebugDescribeFocusTree来打印当前焦点树的信息。

快捷键(Shortcuts)

焦点系统只能监听原始事件并处理,如果我要定义多个快捷键要处理起来是很不友好,所以Flutter提供了快捷键相关的组件来方便我们定义自己的快捷键。

CallbackShortcuts

CallbackShortcuts是一个Widget组件,我们可以添加一个binding来定义键盘事件的响应,如


Widget 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'),
        ],
      ),
    ),
  );
}
1
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? get triggers: 按键组合列表 bool accepts(RawKeyEvent event, RawKeyboard state): 是否接受事件。一般快捷方式是更复杂的,比如复制(Ctrl + C),需要判断C按下的情况下有且仅有Ctrl也是按下状态才会响应。

Flutter提供了几种方便的ShortcutActivator

  • LogicalKeySet: 支持传入一组LogicalKeyboardKey,有且仅有这些键按下时会响应
  • SingleActivator: 仅支持传入一个LogicalKeyboardKey,但支持设置常用功能键controlshiftaltmeta是否按下
  • CharacterActivator: 类似SingleActivator,区别是传入是是一个按键的字符串character,该characterRawKeyEvent下的character进行匹配。

Shortcuts & Actions

对于简单的快捷键操作上面的ShortcutActivator就已经很好用了,对于更复杂界面及业务场景,快捷键的意图与操作分离也是必要的。比如我们知道Ctrl + C是复制,但是在不同组件复制的内容是不一样的,在输入框中复制的输入框里所有文字,下拉框中复制的是当前选中的内容,日期选择中复制的是当前选择的日志格式化形式。为了应对这种场景,Flutter提供了两个组件ShortcutsActionsShortcuts用来定义快捷键的意图;Actions用来定义快捷键的具体操作。

https://docs.flutter.dev/ui/interactivity/actions-and-shortcuts

图片来自https://docs.flutter.dev/ui/interactivity/actions-and-shortcuts

从工作原理来说,分为两个过程,首先Shortcuts中根据从主焦点节点沿着焦点链向上找到最近的Intent(依赖Focus能力),然后根据Intent找到最近的Action最后执行Action上的invoke方法。通过这两个过程完成键盘绑定与操作的解耦。我们可以定义通用的快捷键,子组件自行实现Action,也可以定义通用的Action,子组件中绑定不同的快捷键。

下面用一个通过改写上面CallbackShortcuts的简单示例看看ShortcutsActions如何工作的

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}');
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}
1
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

此示例跟上面一样,通过键盘的上下键控制数字的增减。ShortcutsActions都接受一个map,它们通过Intent进行获得联系,当键盘上触发时,首先根据Shortcuts组件找到绑定的IncrementIntent,然后根据Intent找到IncrementAction并执行Action。

下面通过源码分析一下ShortcutsActions工作原理。

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,
    );
  }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

从这里可以看出Shortcuts仅仅是使用了Focus组件,它俩在实现上的是没有耦合的。Shortcuts很简单,它使用Focus键盘事件,并持有一个ShortcutManager,当Focus的事件触发后,执行ShortcutManagerhandleKeypress

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;
  }
//...
1
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,然后触发执行Actioninvoke以及toKeyEventResult

Actions.of返回的是ActionDispatcher,它负责Action的invoke的执行。

Actions

上面触发Actions的过程中使用了多个Actions的方法,实际上,Actions还提供了更丰富的方法。

find & maybeFind

根据Intent找到最近的Action。findmaybeFind区别是find没找到会报错,maybeFind没找到会返回null。

Action<SelectAllIntent> selectAllAction = Actions.find<SelectAllIntent>(context);
Action<SelectAllIntent>? selectAll = Actions.maybeFind<SelectAllIntent>(context);
1
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'),
)
1
2
3
4
5
6

handler

如果要根据Intentcontext先找到当前组件最近的Action,先返回一个空函数,没找到会返回null,再次执行函数会触发Action的执行。可以配合在某些操作上,比如点击后触发SelectAllAction

上面的例子通过handler改写会简单很多

TextButton(
  onPressed: Actions.handler<SelectAllIntent>(
    context,
    SelectAllIntent(controller: controller),
  ),
  child: const Text('SELECT ALL'),
)
1
2
3
4
5
6
7

已有的 Intent & Action

Intent本身几乎没什么含义,但Flutte也定义了很多有多种含义的IntentAction

  • 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(),
      ),
    );
  }
}
1
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很便捷的定义快捷键及响应
  • ShortcutsAction是为了解耦快捷键和操作的设置,它俩可以配合使用
  • Actions也可以单独使用,如直接调用Actions.invoke<CopyIntent>(context, DismissIntent())触发组件关闭

总结

这篇文章主要是探讨了Flutter Framework层的键盘事件传递的原理。首先,我们可以通过使用HardwareKeyboard.instance.addHandler来全局监听键盘事件,其次可以通过使用Focus组件来将组件设置为焦点节点,当该组件获得焦点时才响应键盘事件,在这个基础上可以使用CallbackShortcuts、或者Shortcuts搭配Actions来设计一些通用的快捷键及响应规则。

参考

Last Updated: 10/26/2024, 2:26:21 PM