如何优雅地解决flutter桌面开发中的方向快捷键的失焦问题 ?在flutter的桌面端开发中,键盘事件监听器RawKe
在flutter的桌面端开发中,键盘事件监听器RawKeyboardListener
可以用来实现对键盘事件的监听,广泛用于处理快捷键。
然而键盘事件监听器为方向键与屏幕上的焦点Focus
的变化配置了默认行为,这会导致我们监听的方向键触发事件被打断,无法获得预期表现。
本文将简要介绍Flutter的键盘监听机制、其对方向快捷键做了哪些默认的行为配置,以及如何覆盖默认的焦点变化事件,来解决业务中遇到的失焦问题。
1.业务场景
在flutter桌面端的业务开发中,为了提升用户的输入体验,通常要对各类快捷键进行细致的处理,比如在搜索选项表中,需要对搜索下拉菜单添加上下方向键来控制选择的项目,效果如下图所示:
预想的实现非常简单,只需要通过键盘事件监听器
RawKeyboardListener
监听上下方向键的监听就可以完成此功能,示例代码如下:
child: RawKeyboardListener(
focusNode: FocusNode(),
onKey: (event) {
if (event.runtimeType.toString() ==
'RawKeyDownEvent') {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
handleKeyDown();
}
if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
handleKeyUp();
}
}
},
)
但实际上在flutter中,对方向键的触发做了焦点查找的默认行为:我们的页面有多个封装后的输入框,这时按下方向键其会去寻找其他方向上可用的焦点,预期是触发我们的监听事件,但在此之前当前的输入框已经失焦,因此看不到预期效果,表现如下图所示:
由此我们需要知道Flutter的键盘监听过程是怎样的,又做了哪些默认的行为处理,以解决我们需要保持焦点的问题。
2.RawKeyBoardListener监听流程
在flutter的src目录下文件raw_keyboard_listener.dart里,我们可以看到如下代码,通过_handleRawKeyEvent
来调用我们可以定义的监听回调onKey,_attachKeyboardIfDetached
通过RawKeyboard
的addListenner
将其注册监听。
class _RawKeyboardListenerState extends State<RawKeyboardListener> {
...
void _attachKeyboardIfDetached() {
if (_listening)
return;
RawKeyboard.instance.addListener(_handleRawKeyEvent);
_listening = true;
}
...
void _handleRawKeyEvent(RawKeyEvent event) {
widget.onKey?.call(event);
}
...
}
然后在raw_keyboard.dart中,addlistener
将参数中的事件全部加入_listsener
,当handleRawKeyEvent
被调用时(键盘事件回调),就会分发执行注册的listener
。
最终经过层层转换将keyEvent作为一个message事件来进行监听工作,注册和回调流程如图下图所示,感兴趣的同学可以自行到对应的文件中阅读源码。
3.键盘方向键的默认行为
由上面的过程我们可以知道,我们的监听事件是在RawKeyboardListener
装载时被注册的,但是在Flutter的入口文件app.dart
中,我们发现如果我们没有配置快捷键Widgetshortcuts
,会有默认的WidgetsApp.defaultShortcuts
快捷键规则:
class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
...
return RootRestorationScope(
restorationId: widget.restorationScopeId,
child: Shortcuts(
debugLabel: '<Default WidgetsApp Shortcuts>',
shortcuts: widget.shortcuts ?? WidgetsApp.defaultShortcuts, // 这里配置快捷键
// DefaultTextEditingShortcuts is nested inside Shortcuts so that it can
// fall through to the defaultShortcuts.
child: DefaultTextEditingShortcuts(
child: Actions(
actions: widget.actions ?? WidgetsApp.defaultActions,
child: DefaultTextEditingActions(
child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: child,
),
),
),
),
),
);
...
}
这个规则会根据当前的设备进行分发,平台有android
、iOS
、macOS
、linux
、windows
等。
此处我们开发的是mac平台的应用,因此查看macOS
的默认事件,发现四个方向键均配置有DirectionlFocusIntent
也就是说默认情况下,方向键会触发这个焦点变化的函数。
// Default shortcuts for the macOS platform.
static const Map<ShortcutActivator, Intent> _defaultAppleOsShortcuts = <ShortcutActivator, Intent>{
...
SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent(TraversalDirection.left),
SingleActivator(LogicalKeyboardKey.arrowRight): DirectionalFocusIntent(TraversalDirection.right),
SingleActivator(LogicalKeyboardKey.arrowDown): DirectionalFocusIntent(TraversalDirection.down),
SingleActivator(LogicalKeyboardKey.arrowUp): DirectionalFocusIntent(TraversalDirection.up),
// Scrolling
...
焦点控制的核心部分是focus_traversal.dart
文件中的inDirection
方法,感兴趣的同学可以去自行查阅源码,这里我们简单总结一下该函数寻找焦点的逻辑:
- 如果有记录的最近的可用焦点,找到方向最近的第一个(按照各方向的按键来寻找),找到焦点后需要保证焦点可见,即滚动位置让聚焦的
focusNode
在可视区域内。 - 如果记录中没有找临近的,则寻找位置上相近的焦点。例如下方向键⬇️,先寻找焦点的中心Y方向上坐标小于当前焦点Y坐标的所有焦点,然后在这些有效焦点中,选出Y方向坐标最大,且X方向上差值最小的焦点,将焦点控制权转移至该焦点。
经过这一番探寻,我们终于找到了为什么焦点会向下移动:是因为我们采用键盘事件监听器RawKeyboardListener
对键盘事件进行处理,没有配置Shortcut
快捷键,所以flutter为我们配置了默认的快捷键。
4.保持焦点
4.1 常规做法
在flutter2.5中更新了有关快捷键支持的问题,DefaultTextEditingShortcuts
类包含每个平台上受支持的键盘快捷键列表,如果开发者想覆盖任何内容,可以使用 Flutter 的现有 Shortcuts
将任何快捷方式重新映射到现有或自定义意图。于是我们可以用Shortcuts
部件包裹textfield
,在对应的属性传入自定义的快捷键声明,并用Actions
定义具体的快捷键和对应的动作。于是我们只需要定义arrowDown
和arrowUp
的对应操作,就可以覆盖原有的默认操作,从而将焦点保持在当前输入框:
Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.arrowDown):
IncrementIndexIntent(),
SingleActivator(LogicalKeyboardKey.arrowUp):
DecrementIndexIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
IncrementIndexIntent:
CallbackAction<IncrementIndexIntent>(
onInvoke: (IncrementIndexIntent intent) {
testIndex.value++;
},
),
DecrementIndexIntent:
CallbackAction<DecrementIndexIntent>(
onInvoke: (DecrementIndexIntent intent) {
testIndex.value--;
},
),
},
child: Textfield(,,,),
),
)
4.2 特殊处理
然而对于我们的业务需求来说,事情没有这么简单。
在本需求中,输入框并非Flutter提供的原生输入框,由于要满足Tag输入、富文本等特殊场景,我们引入了extend_text_field
,这个第三方库中很多快捷键并非使用ShortCuts
实现,因此我们在外层定义ShortCuts
,会使其内部的事件监听失效,导致该组件无法正常工作,由于该库面向移动端设计,对于桌面端相关的表现关注较少,短时间内难以修复此种异常,这种情况在Flutter桌面端开发中相当普遍。
权衡之下,我们决定暂时先不采取覆盖ShortCuts
这一常见做法,这里我们介绍一种使用RawKeyboardListener
让焦点重新回归的方案。
当按下上下键的时候,如我们在第一节所述,会先后调用keyDown和keyUp的onkey回调事件,具体流程如下图所示。
由于默认的焦点变化事件DirectionalFocusIntent()
会在keyDown
的时候执行,找到下一个可用焦点后会让当前widget失去焦点,新widget获得焦点。完成后keyUp
触发。于是我们找回焦点的关键就是在旧widget失去焦点的时候监听到该事件并且使用requestFocus
找回焦点。
具体做法是:
- 在旧widget的
focus
上绑定焦点监听事件listenFocus
,并设置widgetHasFocus
标记当前widget是否有焦点,isKeydown
标记keyDown事件是否发生,当keydown
动作触发时将其记为true。 - 在焦点监听到变化时,如果
keyDown
为true,并且widgetHasFocus
为true,而焦点的.hasFocus
获取的结果为false,说明已经进行完unfocus
操作,此时让当前焦点使用requestFocus
抢回焦点,并在keyUp
时将标记位还原,从而能够实现焦点的回归。
5.总结
经过以上的探索过程,相信读者已经对Flutter中桌面端的键盘事件机制及焦点查找行为有了一定了解,上面提到的源码文件中也有关于其他各平台的键盘监听机制的定义,希望这条完整的探索路径可以为遇到类似问题的同学带来启发。
转载自:https://juejin.cn/post/7270082131962822715