跳到主内容

使用 Actions 和 Shortcuts

如何在 Flutter 应用中使用 Actions 和 Shortcuts。

本页介绍了如何将物理键盘事件绑定到用户界面中的操作(Action)。例如,如果你需要在应用中定义键盘快捷键,请参阅本页。

概述

#

对于 GUI 应用程序来说,要执行任何操作都必须有 Action:用户希望告诉应用程序去“做”某事。Action 通常是直接执行操作(例如设置值或保存文件)的简单函数。然而,在大型应用程序中,情况更为复杂:调用 Action 的代码和 Action 本身的代码可能需要位于不同的地方。快捷键(键绑定)的定义层面可能并不了解它们所调用的 Action。

这就是 Flutter 的 Action 和 Shortcut 系统发挥作用的地方。它允许开发者定义满足绑定到其上的 Intent 的 Action。在此上下文中,Intent 是用户希望执行的通用操作,而 Intent 类实例代表了 Flutter 中的这些用户意图。一个 Intent 可以是通用的,在不同的上下文中由不同的 Action 来实现。一个 Action 可以是一个简单的回调(如 CallbackAction 的情况),也可以是与整个撤销/重做架构或其他逻辑集成的更复杂的内容。

Using Shortcuts Diagram

Shortcuts 是通过按下一个键或组合键来激活的键绑定。键组合及其绑定的 Intent 位于一个表中。当 Shortcuts 小部件调用它们时,它会将匹配的 Intent 发送到 Action 子系统以进行执行。

为了说明 Action 和 Shortcut 中的概念,本文创建了一个简单的应用程序,允许用户使用按钮和快捷键在文本字段中选择并复制文本。

为什么要将 Actions 和 Intents 分开?

#

你可能会好奇:为什么不直接将组合键映射到 Action?为什么还要有 Intent?这是因为在键映射定义所在的位置(通常在较高层级)与 Action 定义所在的位置(通常在较低层级)之间实现关注点分离非常有用。此外,能够让单个组合键映射到应用程序中的预期操作,并让它自动适应当前聚焦上下文中实现该操作的任何 Action,这一点非常重要。

例如,Flutter 有一个 ActivateIntent 小部件,它将每种类型的控件映射到其对应的 ActivateAction 版本(并执行激活控件的代码)。这些代码通常需要相当私有的访问权限才能完成工作。如果 Intent 提供的间接层不存在,则必须将 Action 的定义提升到 Shortcuts 小部件定义实例可以“看见”的位置,这会导致快捷键了解过多关于调用哪个 Action 的信息,并可能需要访问或提供它本来不需要的状态。这使得你的代码可以将这两个关注点分离开来,从而更加独立。

Intent 对 Action 进行配置,使得同一个 Action 可以服务于多种用途。一个例子是 DirectionalFocusIntent,它携带一个移动焦点的方向,从而允许 DirectionalFocusAction 知道向哪个方向移动焦点。但请注意:不要在 Intent 中传递适用于 Action 所有调用的状态:这种状态应该传递给 Action 本身的构造函数,以避免 Intent 需要了解过多信息。

为什么不使用回调?

#

你可能还会好奇:为什么不直接使用回调而不是 Action 对象?主要原因是,通过实现 isEnabled 来决定 Action 是否启用是非常有用的。此外,将键绑定和这些绑定的实现放在不同的地方通常也有所帮助。

如果你只需要回调,而不需要 ActionsShortcuts 的灵活性,可以使用 CallbackShortcuts 小部件。

dart
@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

#

正如你在下面看到的,Action 本身很有用,但最常见的用例是将它们绑定到键盘快捷键。这就是 Shortcuts 小部件的作用。

它被插入到小部件层级中,用于定义在按下键组合时代表用户意图的键组合。为了将键组合的预期目的转换为具体的 Action,Actions 小部件用于将 Intent 映射到 Action。例如,你可以定义一个 SelectAllIntent,并将其绑定到你自己的 SelectAllActionCanvasSelectAllAction。通过这一个键绑定,系统会根据应用程序的哪一部分拥有焦点来调用其中一个。让我们看看键绑定部分是如何工作的。

dart
@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 小部件的 map 将 LogicalKeySet(或 ShortcutActivator,见下文说明)映射到 Intent 实例。逻辑键集定义了一组或多个键,Intent 指示了按键的预期目的。Shortcuts 小部件在 map 中查找按键,以找到一个 Intent 实例,并将其提供给 Action 的 invoke() 方法。

ShortcutManager

#

快捷键管理器(Shortcut Manager)是一个比 Shortcuts 小部件寿命更长的对象,它在接收到键事件时将其传递出去。它包含决定如何处理键的逻辑、向上遍历树以查找其他快捷键映射的逻辑,并维护一个键组合到 Intent 的 map。

虽然 ShortcutManager 的默认行为通常很理想,但 Shortcuts 小部件接受一个 ShortcutManager,你可以通过继承它来自定义其功能。

例如,如果你想记录 Shortcuts 小部件处理的每个按键,你可以创建一个 LoggingShortcutManager

dart
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 调用它们来执行的操作。Action 可以被启用或禁用,并接收调用它们的 Intent 实例作为参数,以允许通过 Intent 进行配置。

定义 Action

#

Action 在最简单的形式中,只是 Action<Intent> 的子类,带有一个 invoke() 方法。这是一个简单的 Action,它仅仅在提供的模型上调用一个函数。

dart
class SelectAllAction extends Action<SelectAllIntent> {
  SelectAllAction(this.model);

  final Model model;

  @override
  void invoke(covariant SelectAllIntent intent) => model.selectAll();
}

或者,如果创建一个新类太麻烦,可以使用 CallbackAction

dart
CallbackAction(onInvoke: (intent) => model.selectAll());

一旦有了 Action,就可以使用 Actions 小部件将其添加到应用程序中,该小部件接受一个 Intent 类型到 Action 的 map。

dart
@override
Widget build(BuildContext context) {
  return Actions(
    actions: <Type, Action<Intent>>{SelectAllIntent: SelectAllAction(model)},
    child: child,
  );
}

Shortcuts 小部件使用 Focus 小部件的上下文和 Actions.invoke 来查找要调用的 Action。如果 Shortcuts 小部件在遇到的第一个 Actions 小部件中没有找到匹配的 Intent 类型,它会考虑下一个祖先 Actions 小部件,依此类推,直到到达小部件树的根部,或找到匹配的 Intent 类型并调用相应的 Action。

调用 Action

#

Action 系统有多种调用 Action 的方式。最常见的方式是通过上一节介绍的 Shortcuts 小部件,但还有其他方法可以查询 Action 子系统并调用 Action。也可以调用未绑定到按键的 Action。

例如,要查找与 Intent 关联的 Action,你可以使用:

dart
Action<SelectAllIntent>? selectAll = Actions.maybeFind<SelectAllIntent>(
  context,
);

如果在给定的 context 中可用,这将返回与 SelectAllIntent 类型关联的 Action。如果不可用,它将返回 null。如果关联的 Action 应该始终可用,那么使用 find 而不是 maybeFind,后者在找不到匹配的 Intent 类型时会抛出异常。

要调用 Action(如果存在),请调用:

dart
Object? result;
if (selectAll != null) {
  result = Actions.of(
    context,
  ).invokeAction(selectAll, const SelectAllIntent());
}

将它们合并为一次调用:

dart
Object? result = Actions.maybeInvoke<SelectAllIntent>(
  context,
  const SelectAllIntent(),
);

有时你希望在按下按钮或其他控件时调用 Action。你可以使用 Actions.handler 函数来做到这一点。如果 Intent 有一个到已启用 Action 的映射,Actions.handler 函数会创建一个处理程序闭包。但是,如果它没有映射,则返回 null。这使得按钮可以在上下文中没有匹配的已启用 Action 时被禁用。

dart
@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 时调用 Action,允许 Action 决定分发器是否应考虑调用它。如果 Action 未启用,则 Actions 小部件会给小部件层级中更高层的另一个已启用 Action(如果存在)执行的机会。

前面的示例使用了 Builder,因为 Actions.handlerActions.invoke(例如)仅查找所提供 context 中的 Action。如果示例传递了 build 函数给出的 context,框架会从当前小部件的“上方”开始查找。使用 Builder 允许框架找到在同一个 build 函数中定义的 Action。

你可以在不需要 BuildContext 的情况下调用 Action,但由于 Actions 小部件需要上下文来查找要调用的已启用 Action,因此你需要提供一个上下文,可以通过创建自己的 Action 实例,或者通过使用 Actions.find 在适当的上下文中查找一个。

要调用 Action,请将 Action 传递给 ActionDispatcher 上的 invoke 方法(可以是你自己创建的,也可以是使用 Actions.of(context) 方法从现有 Actions 小部件检索到的)。在调用 invoke 之前,请检查 Action 是否已启用。当然,你也可以直接在 Action 本身上调用 invoke 并传递一个 Intent,但这样你就放弃了 Action 分发器可能提供的任何服务(如日志记录、撤销/重做等)。

Action 分发器 (Action dispatchers)

#

大多数时候,你只是想调用一个 Action,让它执行任务,然后就不再关心它了。然而,有时你可能想记录已执行的 Action。

这就是用自定义分发器替换默认 ActionDispatcher 的作用所在。你将 ActionDispatcher 传递给 Actions 小部件,它会从下方任何未设置自己的分发器的 Actions 小部件中调用 Action。

Actions 在调用 Action 时做的第一件事就是查找 ActionDispatcher 并将其传递给它以进行调用。如果没有分发器,它会创建一个仅调用该 Action 的默认 ActionDispatcher

如果你想要记录所有已调用的 Action,可以创建一个自己的 LoggingActionDispatcher 来完成这项工作:

dart
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 小部件:

dart
@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'),
      ),
    ),
  );
}

这会记录每个执行的 Action,如下所示:

flutter: Action invoked: SelectAllAction#906fc(SelectAllIntent#a98e3) from Builder(dependencies: _[ActionsMarker])

整合起来

#

ActionsShortcuts 的组合非常强大:你可以在小部件级别定义映射到特定 Action 的通用 Intent。下面是一个简单的应用程序,说明了上述概念。该应用程序创建了一个文本字段,旁边还有“全选”和“复制到剪贴板”按钮。这些按钮调用 Action 来完成它们的工作。所有被调用的 Action 和快捷键都会被记录。

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());