使用动作和快捷方式
本页介绍如何将物理键盘事件绑定到用户界面中的动作。例如,若要在应用程序中定义键盘快捷方式,本页将为您提供帮助。
概述
#一个 GUI 应用程序要执行任何操作,都必须有动作:用户希望告诉应用程序执行某些操作。动作通常是直接执行操作(例如设置值或保存文件)的简单函数。然而,在大型应用程序中,事情更为复杂:调用动作的代码和动作本身的代码可能需要位于不同的位置。快捷方式(按键绑定)可能需要在完全不了解其调用动作的层级进行定义。
这就是 Flutter 动作和快捷方式系统发挥作用的地方。它允许开发者定义满足绑定到它们的意图的动作。在此上下文中,意图是用户希望执行的通用操作,而 Intent
类实例在 Flutter 中代表这些用户意图。一个 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()
方法。
快捷方式管理器
#快捷方式管理器是一个比 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
调用来执行的操作。动作可以启用或禁用,并接收调用它们的意图实例作为参数,从而允许通过意图进行配置。
定义动作
#动作,以其最简单的形式,只是带有 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
类型时,find
会抛出异常。
要调用动作(如果存在),请调用
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
,框架将从当前小部件的上方开始查找。使用 Builder
允许框架查找在同一个 build
函数中定义的动作。
您可以无需 BuildContext
即可调用动作,但由于 Actions
小部件需要一个上下文来查找要调用的已启用动作,因此您需要提供一个上下文,可以通过创建自己的 Action
实例,或者通过使用 Actions.find
在适当的上下文中找到一个。
要调用动作,请将动作传递给 ActionDispatcher
的 invoke
方法,这个 ActionDispatcher
可以是您自己创建的,也可以是使用 Actions.of(context)
方法从现有 Actions
小部件中检索到的。在调用 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();
@override
void dispose() {
controller.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),
},
child: Builder(
builder: (context) {
return Scaffold(
body: Center(
child: Row(
children: <Widget>[
const Spacer(),
Expanded(child: TextField(controller: controller)),
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);
final TextEditingController controller;
@override
Object? invoke(covariant SelectAllIntent intent) {
controller.selection = controller.selection.copyWith(
baseOffset: 0,
extentOffset: controller.text.length,
affinity: controller.selection.affinity,
);
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());