本页介绍如何将物理键盘事件绑定到用户界面中的动作。例如,若要在应用程序中定义键盘快捷方式,本页将为您提供帮助。

概述

#

一个 GUI 应用程序要执行任何操作,都必须有动作:用户希望告诉应用程序执行某些操作。动作通常是直接执行操作(例如设置值或保存文件)的简单函数。然而,在大型应用程序中,事情更为复杂:调用动作的代码和动作本身的代码可能需要位于不同的位置。快捷方式(按键绑定)可能需要在完全不了解其调用动作的层级进行定义。

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

Using Shortcuts Diagram

Shortcuts 是通过按下单个按键或按键组合激活的按键绑定。按键组合及其绑定的意图存储在一个表中。当 Shortcuts 小部件调用它们时,它会将匹配的意图发送到动作子系统进行实现。

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

为什么要将动作与意图分离?

#

你可能会问:为什么不直接将按键组合映射到动作?为什么要用意图呢?这是因为将按键映射定义(通常在高级别)与动作定义(通常在低级别)进行职责分离非常有用,而且能够将单个按键组合映射到应用程序中的预期操作,并使其根据当前焦点上下文自动适应哪个动作来完成该预期操作,这一点很重要。

例如,Flutter 有一个 ActivateIntent 小部件,它将每种类型的控件映射到其对应的 ActivateAction 版本(并执行激活控件的代码)。此代码通常需要相当私有的访问权限才能完成其工作。如果 Intent 提供的额外间接层不存在,则需要将动作的定义提升到 Shortcuts 小部件的定义实例可见的位置,这将导致快捷方式对调用哪个动作有不必要的了解,并访问或提供它原本不需要的状态。这允许您的代码将这两个关注点分离,使其更加独立。

意图配置一个动作,以便同一个动作可以服务于多种用途。一个例子是 DirectionalFocusIntent,它接受一个移动焦点的方向,允许 DirectionalFocusAction 知道要朝哪个方向移动焦点。请注意:不要在 Intent 中传递适用于 Action 所有调用的状态:这类状态应该传递给 Action 本身的构造函数,以避免 Intent 知道太多信息。

为什么不使用回调?

#

你可能还会问:为什么不直接使用回调而不是 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'),
        ],
      ),
    ),
  );
}

快捷方式

#

正如您将在下面看到的,动作本身很有用,但最常见的用例是将其绑定到键盘快捷方式。这就是 Shortcuts 小部件的作用。

它被插入到小部件树中,用于定义当按下特定按键组合时代表用户意图的按键组合。为了将按键组合的预期目的转换为具体的动作,使用 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 小部件的映射将 LogicalKeySet(或 ShortcutActivator,参见下文注释)映射到一个 Intent 实例。逻辑按键集定义了一个或多个按键的集合,而意图则指示了按键操作的预期目的。Shortcuts 小部件在映射中查找按键操作,以找到一个 Intent 实例,然后将其提供给动作的 invoke() 方法。

快捷方式管理器

#

快捷方式管理器是一个比 Shortcuts 小部件生命周期更长的对象,它在接收到按键事件时将其传递下去。它包含决定如何处理按键的逻辑、向上遍历树以查找其他快捷方式映射的逻辑,并维护一个按键组合到意图的映射。

虽然 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 调用来执行的操作。动作可以启用或禁用,并接收调用它们的意图实例作为参数,从而允许通过意图进行配置。

定义动作

#

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

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

有了动作后,您可以使用 Actions 小部件将其添加到应用程序中,该小部件接受一个从 Intent 类型到 Action 的映射

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

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

调用动作

#

动作系统有多种调用动作的方式。迄今为止,最常见的方式是使用上一节中介绍的 Shortcuts 小部件,但还有其他方法可以查询动作子系统并调用动作。可以调用未绑定到按键的动作。

例如,要查找与意图关联的动作,您可以使用

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

如果给定 context 中有一个与 SelectAllIntent 类型关联的 Action,则此方法返回该 Action。如果不可用,则返回 null。如果关联的 Action 应该始终可用,则使用 find 而不是 maybeFind,因为当找不到匹配的 Intent 类型时,find 会抛出异常。

要调用动作(如果存在),请调用

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 在适当的上下文中找到一个。

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

动作调度器

#

大多数时候,您只想调用一个动作,让它完成任务,然后就忘了它。然而,有时您可能希望记录已执行的动作。

这时,就需要用自定义调度器替换默认的 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());