使用动作和快捷键
如何在 Flutter 应用中使用动作和快捷键。
本页描述了如何将物理键盘事件绑定到用户界面中的动作。例如,如果你想在你的应用程序中定义键盘快捷键,那么本页就是为你准备的。
概述
#为了让 GUI 应用程序执行任何操作,它必须具有动作:用户希望告诉应用程序做某事。动作通常是简单的函数,直接执行该动作(例如设置一个值或保存一个文件)。然而,在更大的应用程序中,事情会变得更加复杂:调用动作的代码和动作本身的代码可能需要在不同的地方。快捷键(键绑定)可能需要在不知道它们调用的动作的层级上定义。
这就是 Flutter 的动作和快捷键系统发挥作用的地方。它允许开发者定义动作,这些动作满足与其绑定的意图。在这种情况下,意图是用户希望执行的通用操作,而 Flutter 中的 Intent 类实例代表这些用户意图。一个 Intent 可以是通用的,由不同上下文中的不同动作满足。一个 Action 可以是一个简单的回调(如 CallbackAction 的情况),也可以是与整个撤销/重做架构(例如)或其他逻辑集成的更复杂的东西。
Shortcuts 是通过按下键或键组合来激活的键绑定。这些键组合位于一个表中,与它们绑定的意图一起。当 Shortcuts 组件调用它们时,它会将匹配的意图发送到动作子系统以供满足。
为了说明动作和快捷键的概念,本文创建了一个简单的应用程序,允许用户使用按钮和快捷键在文本字段中选择和复制文本。
为什么将动作与意图分开?
#你可能会想:为什么不直接将键组合映射到动作?为什么需要意图?这是因为在键映射定义的位置(通常在高层级)和动作定义的位置(通常在低层级)之间进行关注点分离是有用的,并且重要的是能够让一个键组合映射到应用程序中的预期操作,并自动适应执行该预期操作的焦点上下文。
例如,Flutter 有一个 ActivateIntent 组件,它将每种类型的控件映射到其对应的 ActivateAction 版本(并执行激活该控件的代码)。这段代码通常需要相当私有的访问权限才能完成其工作。如果 Intent 提供的额外间接层不存在,那么将动作的定义提升到定义 Shortcuts 组件的实例可以看到它们的位置是必要的,导致快捷键比必要的知道更多关于要调用哪个动作,并且需要访问或提供它不一定拥有或需要的状态。这允许你的代码将这两个关注点分开,使其更加独立。
意图配置一个动作,以便同一个动作可以服务于多种用途。一个例子是 DirectionalFocusIntent,它获取一个移动焦点的方向,允许 DirectionalFocusAction 知道移动焦点的方向。但要小心:不要在 Intent 中传递适用于 Action 所有调用的状态:这种状态应该传递给 Action 本身的构造函数,以防止 Intent 需要知道太多。
为什么不使用回调函数?
#你可能还会想:为什么不直接使用回调函数而不是 Action 对象?主要原因是,动作可以通过实现 isEnabled 来决定它们是否启用。此外,键绑定和这些绑定的实现位于不同的位置通常很有帮助。
如果你只需要回调函数而不需要 Actions 和 Shortcuts 的灵活性,你可以使用 CallbackShortcuts 组件
@override
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>[
const Text('Press the up arrow key to add to the counter'),
const Text('Press the down arrow key to subtract from the counter'),
Text('count: $count'),
],
),
),
);
}
快捷键
#正如你将在下面看到的,动作本身是有用的,但最常见的用例是将它们绑定到键盘快捷键。这就是 Shortcuts 组件的作用。
它被插入到小部件层次结构中,以定义代表用户在按下该键组合时意图的键组合。为了将键组合的预期目的转换为具体的动作,使用了 Actions 组件,用于将 Intent 映射到 Action。例如,你可以定义一个 SelectAllIntent,并将其绑定到你自己的 SelectAllAction 或你的 CanvasSelectAllAction,并且从一个键绑定,系统会调用其中一个,具体取决于你的应用程序的哪个部分具有焦点。让我们看看键绑定部分是如何工作的
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA):
const SelectAllIntent(),
},
child: Actions(
dispatcher: LoggingActionDispatcher(),
actions: <Type, Action<Intent>>{
SelectAllIntent: SelectAllAction(model),
},
child: Builder(
builder: (context) => TextButton(
onPressed: Actions.handler<SelectAllIntent>(
context,
const SelectAllIntent(),
),
child: const Text('SELECT ALL'),
),
),
),
);
}
传递给 Shortcuts 组件的映射将一个 LogicalKeySet(或一个 ShortcutActivator,请参阅下面的注释)映射到一个 Intent 实例。逻辑键集定义了一组一个或多个键,并且意图指示键按下的预期目的。Shortcuts 组件在映射中查找按键,以找到一个 Intent 实例,并将其传递给动作的 invoke() 方法。
ShortcutManager
#快捷键管理器是一个比 Shortcuts 组件更持久的对象,它在收到按键事件时会传递它们。它包含决定如何处理按键的逻辑、遍历树以查找其他快捷键映射的逻辑,并维护一个键组合到意图的映射。
虽然 ShortcutManager 的默认行为通常是理想的,但 Shortcuts 组件接受一个你可以子类化的 ShortcutManager,以自定义其功能。
例如,如果你想记录 Shortcuts 组件处理的每个键,你可以创建一个 LoggingShortcutManager
class LoggingShortcutManager extends ShortcutManager {
@override
KeyEventResult handleKeypress(BuildContext context, KeyEvent event) {
final KeyEventResult result = super.handleKeypress(context, event);
if (result == KeyEventResult.handled) {
print('Handled shortcut $event in $context');
}
return result;
}
}
现在,每次 Shortcuts 组件处理快捷键时,它都会打印出按键事件和相关上下文。
动作
#
Actions 允许定义应用程序可以通过使用 Intent 调用它们来执行的操作。动作可以启用或禁用,并且接收调用它们的 Intent 实例作为参数,以允许意图进行配置。
定义动作
#动作,以最简单的形式,只是具有 invoke() 方法的 Action<Intent> 的子类。这是一个简单的动作,它只是在提供的模型上调用一个函数
class SelectAllAction extends Action<SelectAllIntent> {
SelectAllAction(this.model);
final Model model;
@override
void invoke(covariant SelectAllIntent intent) => model.selectAll();
}
或者,如果创建新类太麻烦,可以使用 CallbackAction
CallbackAction(onInvoke: (intent) => model.selectAll());
一旦你有了动作,你就可以使用 Actions 组件将其添加到你的应用程序中,该组件接受一个 Intent 类型到 Action 的映射
@override
Widget build(BuildContext context) {
return Actions(
actions: <Type, Action<Intent>>{SelectAllIntent: SelectAllAction(model)},
child: child,
);
}
Shortcuts 组件使用 Focus 组件的上下文和 Actions.invoke 来查找要调用的动作。如果 Shortcuts 组件在遇到的第一个 Actions 组件中没有找到匹配的意图类型,它会考虑下一个祖先 Actions 组件,依此类推,直到到达小部件树的根,或者找到匹配的意图类型并调用相应的动作。让我们看看键绑定部分是如何工作的
调用动作
#动作系统有几种方法来调用动作。到目前为止,最常见的方法是通过上一节中介绍的 Shortcuts 组件,但还有其他方法来查询动作子系统并调用一个动作。可以调用未绑定到键的动作。
例如,要查找与意图关联的动作,可以使用
Action<SelectAllIntent>? selectAll = Actions.maybeFind<SelectAllIntent>(
context,
);
如果给定 context 中存在与 SelectAllIntent 类型关联的 Action,则返回该 Action。如果不存在,则返回 null。如果应始终提供关联的 Action,则使用 find 而不是 maybeFind,后者在找不到匹配的 Intent 类型时会引发异常。
要调用该动作(如果存在),请调用
Object? result;
if (selectAll != null) {
result = Actions.of(
context,
).invokeAction(selectAll, const SelectAllIntent());
}
将这些组合成一个调用:
Object? result = Actions.maybeInvoke<SelectAllIntent>(
context,
const SelectAllIntent(),
);
有时你希望将动作作为按下按钮或其他控件的结果来调用。你可以使用 Actions.handler 函数来做到这一点。如果意图具有映射到启用的动作,则 Actions.handler 函数会创建一个处理程序闭包。但是,如果没有映射,则返回 null。这允许在没有匹配的已启用动作的情况下禁用该按钮。
@override
Widget build(BuildContext context) {
return Actions(
actions: <Type, Action<Intent>>{SelectAllIntent: SelectAllAction(model)},
child: Builder(
builder: (context) => TextButton(
onPressed: Actions.handler<SelectAllIntent>(
context,
SelectAllIntent(controller: controller),
),
child: const Text('SELECT ALL'),
),
),
);
}
Actions 组件仅在 isEnabled(Intent intent) 返回 true 时才调用动作,从而允许动作决定调度器是否应考虑调用它。如果该动作未启用,则 Actions 组件会给更高层级的小部件树中的另一个已启用的动作(如果存在)一个执行的机会。
前面的示例使用了一个 Builder,因为 Actions.handler 和 Actions.invoke(例如)仅在提供的 context 中查找动作,如果示例传递了 build 函数给定的 context,则框架将从当前小部件之上开始查找。
你可以调用一个动作而不需要 BuildContext,但由于 Actions 组件需要一个上下文来查找要调用的已启用动作,因此你需要提供一个,要么通过创建你自己的 Action 实例,要么通过使用 Actions.find 在适当的上下文中找到一个。
要调用该动作,请将该动作传递给 ActionDispatcher 上的 invoke 方法,要么是你自己创建的一个,要么是从现有的 Actions 组件使用 Actions.of(context) 方法检索的一个。在调用 invoke 之前,请检查该动作是否已启用。当然,你也可以直接在动作本身上调用 invoke,传递一个 Intent,但然后你将放弃动作调度器可能提供的任何服务(例如日志记录、撤销/重做等)。
动作调度器
#大多数时候,你只想调用一个动作,让它完成它的事情,然后忘记它。但是,有时你可能希望记录执行的动作。
这就是用自定义调度器替换默认 ActionDispatcher 的地方。你将你的 ActionDispatcher 传递给 Actions 组件,它会从任何位于该组件下方的 Actions 组件调用动作,而这些组件没有设置自己的调度器。
Actions 在调用动作时首先要做的是查找 ActionDispatcher 并将该动作传递给它进行调用。如果没有,它会创建一个默认的 ActionDispatcher,该调度器只是调用该动作。
如果你想要记录所有调用的动作,你可以创建一个自定义的 LoggingActionDispatcher 来完成这项工作
class LoggingActionDispatcher extends ActionDispatcher {
@override
Object? invokeAction(
covariant Action<Intent> action,
covariant Intent intent, [
BuildContext? context,
]) {
print('Action invoked: $action($intent) from $context');
super.invokeAction(action, intent, context);
return null;
}
@override
(bool, Object?) invokeActionIfEnabled(
covariant Action<Intent> action,
covariant Intent intent, [
BuildContext? context,
]) {
print('Action invoked: $action($intent) from $context');
return super.invokeActionIfEnabled(action, intent, context);
}
}
然后将该值传递给你的顶级 Actions 组件
@override
Widget build(BuildContext context) {
return Actions(
dispatcher: LoggingActionDispatcher(),
actions: <Type, Action<Intent>>{SelectAllIntent: SelectAllAction(model)},
child: Builder(
builder: (context) => TextButton(
onPressed: Actions.handler<SelectAllIntent>(
context,
const SelectAllIntent(),
),
child: const Text('SELECT ALL'),
),
),
);
}
这会记录每个动作的执行情况,如下所示
flutter: Action invoked: SelectAllAction#906fc(SelectAllIntent#a98e3) from Builder(dependencies: _[ActionsMarker])
整合起来
#Actions 和 Shortcuts 的结合非常强大:你可以在组件级别定义通用的意图,并将它们映射到特定的动作。这里有一个简单的应用程序,它说明了上述概念。该应用程序创建一个文本字段,旁边还有“全选”和“复制到剪贴板”按钮。这些按钮调用动作来完成它们的工作。所有调用的动作和快捷键都会被记录。
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// A text field that also has buttons to select all the text and copy the
/// selected text to the clipboard.
class CopyableTextField extends StatefulWidget {
const CopyableTextField({super.key, required this.title});
final String title;
@override
State<CopyableTextField> createState() => _CopyableTextFieldState();
}
class _CopyableTextFieldState extends State<CopyableTextField> {
late final TextEditingController controller = TextEditingController();
late final FocusNode focusNode = FocusNode();
@override
void dispose() {
controller.dispose();
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Actions(
dispatcher: LoggingActionDispatcher(),
actions: <Type, Action<Intent>>{
ClearIntent: ClearAction(controller),
CopyIntent: CopyAction(controller),
SelectAllIntent: SelectAllAction(controller, focusNode),
},
child: Builder(
builder: (context) {
return Scaffold(
body: Center(
child: Row(
children: <Widget>[
const Spacer(),
Expanded(
child: TextField(
controller: controller,
focusNode: focusNode,
),
),
IconButton(
icon: const Icon(Icons.copy),
onPressed: Actions.handler<CopyIntent>(
context,
const CopyIntent(),
),
),
IconButton(
icon: const Icon(Icons.select_all),
onPressed: Actions.handler<SelectAllIntent>(
context,
const SelectAllIntent(),
),
),
const Spacer(),
],
),
),
);
},
),
);
}
}
/// A ShortcutManager that logs all keys that it handles.
class LoggingShortcutManager extends ShortcutManager {
@override
KeyEventResult handleKeypress(BuildContext context, KeyEvent event) {
final KeyEventResult result = super.handleKeypress(context, event);
if (result == KeyEventResult.handled) {
print('Handled shortcut $event in $context');
}
return result;
}
}
/// An ActionDispatcher that logs all the actions that it invokes.
class LoggingActionDispatcher extends ActionDispatcher {
@override
Object? invokeAction(
covariant Action<Intent> action,
covariant Intent intent, [
BuildContext? context,
]) {
print('Action invoked: $action($intent) from $context');
super.invokeAction(action, intent, context);
return null;
}
}
/// An intent that is bound to ClearAction in order to clear its
/// TextEditingController.
class ClearIntent extends Intent {
const ClearIntent();
}
/// An action that is bound to ClearIntent that clears its
/// TextEditingController.
class ClearAction extends Action<ClearIntent> {
ClearAction(this.controller);
final TextEditingController controller;
@override
Object? invoke(covariant ClearIntent intent) {
controller.clear();
return null;
}
}
/// An intent that is bound to CopyAction to copy from its
/// TextEditingController.
class CopyIntent extends Intent {
const CopyIntent();
}
/// An action that is bound to CopyIntent that copies the text in its
/// TextEditingController to the clipboard.
class CopyAction extends Action<CopyIntent> {
CopyAction(this.controller);
final TextEditingController controller;
@override
Object? invoke(covariant CopyIntent intent) {
final String selectedString = controller.text.substring(
controller.selection.baseOffset,
controller.selection.extentOffset,
);
Clipboard.setData(ClipboardData(text: selectedString));
return null;
}
}
/// An intent that is bound to SelectAllAction to select all the text in its
/// controller.
class SelectAllIntent extends Intent {
const SelectAllIntent();
}
/// An action that is bound to SelectAllAction that selects all text in its
/// TextEditingController.
class SelectAllAction extends Action<SelectAllIntent> {
SelectAllAction(this.controller, this.focusNode);
final TextEditingController controller;
final FocusNode focusNode;
@override
Object? invoke(covariant SelectAllIntent intent) {
controller.selection = controller.selection.copyWith(
baseOffset: 0,
extentOffset: controller.text.length,
affinity: controller.selection.affinity,
);
focusNode.requestFocus();
return null;
}
}
/// The top level application class.
///
/// Shortcuts defined here are in effect for the whole app,
/// although different widgets may fulfill them differently.
class MyApp extends StatelessWidget {
const MyApp({super.key});
static const String title = 'Shortcuts and Actions Demo';
@override
Widget build(BuildContext context) {
return MaterialApp(
title: title,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
),
home: Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.escape): const ClearIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyC):
const CopyIntent(),
LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyA):
const SelectAllIntent(),
},
child: const CopyableTextField(title: title),
),
);
}
}
void main() => runApp(const MyApp());