跳至主要内容

使用 Action 和快捷键

本页面介绍如何将物理键盘事件绑定到用户界面中的 Action。例如,要定义应用程序中的键盘快捷键,此页面适合您。

概述

#

对于 GUI 应用程序来说,要执行任何操作,它都必须具有 Action:用户希望告诉应用程序执行某些操作。Action 通常是直接执行操作的简单函数(例如设置值或保存文件)。但是,在较大的应用程序中,情况会更加复杂:调用 Action 的代码和 Action 本身的代码可能需要位于不同的位置。快捷键(按键绑定)可能需要在不知道其调用的 Action 的级别进行定义。

这就是 Flutter 的 Action 和快捷键系统发挥作用的地方。它允许开发人员定义能够满足与其绑定的 Intent 的 Action。在此上下文中,Intent 是用户希望执行的通用操作,并且Intent 类实例在 Flutter 中表示这些用户 Intent。Intent 可以是通用的,在不同的上下文中由不同的 Action 满足。一个Action 可以是一个简单的回调(例如CallbackAction 的情况)或更复杂的东西,可以与整个撤消/重做架构(例如)或其他逻辑集成。

Using Shortcuts Diagram

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

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

为什么将 Action 与 Intent 分开?

#

您可能想知道:为什么不直接将按键组合映射到 Action?为什么要使用 Intent?这是因为在按键映射定义(通常位于高级别)和 Action 定义(通常位于低级别)的位置之间进行关注点分离非常有用,并且能够让单个按键组合映射到应用程序中的预期操作,并自动适应满足该预期操作的任何 Action 对于焦点上下文至关重要。

例如,Flutter 有一个 ActivateIntent 小部件,它将每种类型的控件映射到其相应的 ActivateAction 版本(并执行激活控件的代码)。此代码通常需要相当私有的访问权限才能完成其工作。如果 Intent 提供的额外间接层不存在,则需要将 Action 的定义提升到 Shortcuts 小部件的定义实例可以查看它们的位置,从而导致快捷键比必要时拥有更多关于要调用的 Action 的知识,并且可以访问或提供它在其他情况下不一定拥有或需要的状态。这允许您的代码将这两个关注点分离,使其更加独立。

Intent 配置 Action,以便相同的 Action 可以服务于多种用途。一个例子是 DirectionalFocusIntent,它采用要将焦点移动到的方向,允许 DirectionalFocusAction 知道要将焦点移动到哪个方向。请注意:不要在 Intent 中传递适用于所有 Action 调用的状态:这种状态应该传递到 Action 本身的构造函数中,以防止 Intent 需要知道太多。

为什么不使用回调?

#

您可能还会想知道:为什么不直接使用回调而不是 Action 对象?主要原因是 Action 通过实现 isEnabled 来确定自身是否启用非常有用。此外,如果按键绑定和这些绑定的实现位于不同位置,通常会很有帮助。

如果您只需要回调而不需要 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'),
        ],
      ),
    ),
  );
}

快捷键

#

如下所示,Action 本身很有用,但最常见的用例涉及将其绑定到键盘快捷键。这就是 Shortcuts 小部件的作用。

它被插入到小部件层次结构中以定义表示用户按下该按键组合时的意图的按键组合。为了将按键组合的预期用途转换为具体的 Action,Actions 小部件用于将 Intent 映射到 Action。例如,您可以定义 SelectAllIntent,并将其绑定到您自己的 SelectAllAction 或您的 CanvasSelectAllAction,并且从一个按键绑定中,系统会调用其中一个,具体取决于应用程序的哪个部分具有焦点。让我们看看按键绑定部分是如何工作的

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 小部件的映射将 LogicalKeySet(或 ShortcutActivator,请参见下面的注释)映射到 Intent 实例。逻辑键集定义了一组一个或多个键,并且 Intent 指示按键的预期用途。Shortcuts 小部件在映射中查找按键,以查找 Intent 实例,然后将其提供给 Action 的 invoke() 方法。

ShortcutManager

#

快捷键管理器(比 Shortcuts 小部件生命周期更长的对象)在收到键盘事件时将其传递。它包含用于决定如何处理键的逻辑、用于遍历树以查找其他快捷键映射的逻辑,并维护按键组合到 Intent 的映射。

虽然 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 小部件处理快捷键时,它都会打印出键盘事件和相关上下文。

Action

#

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

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,则此方法会返回该 Action。如果没有可用,则返回 null。如果始终应该存在关联的 Action,则使用 find 而不是 maybeFind,当它找不到匹配的 Intent 类型时,find 会抛出异常。

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

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

使用以下方法将其合并到一个调用中

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

有时您希望在按下按钮或其他控件后触发某个操作。您可以使用Actions.handler函数来实现这一点。如果意图映射到启用的操作,则Actions.handler函数会创建一个处理程序闭包。但是,如果它没有映射,则返回null。这允许在上下文中没有匹配的启用操作时禁用按钮。

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时才会调用操作,从而允许操作决定调度程序是否应考虑调用它。如果操作未启用,则Actions小部件会让小部件层次结构中更高位置的另一个启用操作(如果存在)有机会执行。

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

您可以调用操作而无需BuildContext,但由于Actions小部件需要上下文才能找到要调用的启用操作,因此您需要提供一个,方法是创建自己的Action实例,或使用Actions.find在适当的上下文中找到一个。

要调用操作,请将操作传递给ActionDispatcher上的invoke方法,无论是您自己创建的,还是使用Actions.of(context)方法从现有的Actions小部件中检索到的。在调用invoke之前检查操作是否已启用。当然,您也可以直接在操作本身上调用invoke,并传递一个Intent,但这样您将选择退出操作调度程序可能提供的任何服务(如日志记录、撤消/重做等)。

Action 分发器

#

大多数时候,您只需要调用一个操作,让它完成其工作,然后忘记它。但是,有时您可能希望记录执行的操作。

这就是用自定义调度程序替换默认ActionDispatcher发挥作用的地方。您将您的ActionDispatcher传递给Actions小部件,它将调用任何在该小部件下且未设置自身调度程序的Actions小部件中的操作。

Actions在调用操作时首先执行的操作是查找ActionDispatcher并将其传递给它以进行调用。如果没有,它将创建一个默认的ActionDispatcher,该调度程序只需调用操作。

但是,如果您希望记录所有调用的操作,则可以创建自己的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'),
      ),
    ),
  );
}

这将在每个操作执行时记录它,如下所示。

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

整合在一起

#

ActionsShortcuts的组合功能强大:您可以在小部件级别定义将映射到特定操作的通用意图。这是一个简单的应用程序,它说明了上面描述的概念。该应用程序创建一个文本字段,并在其旁边还具有“全选”和“复制到剪贴板”按钮。这些按钮调用操作来完成其工作。所有调用的操作和快捷方式都将被记录。

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