使用操作和快捷方式

此页面介绍如何将物理键盘事件绑定到用户界面中的操作。例如,要在应用程序中定义键盘快捷方式,此页面适合你。

概述

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

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

Using Shortcuts Diagram

快捷键是通过按一个键或组合键来激活的键绑定。键组合位于一个表中,其中包含其绑定的意图。当快捷键小部件调用它们时,它会将它们的匹配意图发送到操作子系统以供实现。

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

为什么将操作与意图分开?

您可能会想:为什么不直接将键组合映射到操作?为什么要有意图?这是因为在键映射定义所在的位置(通常在高层)和操作定义所在的位置(通常在低层)之间进行关注点分离很有用,并且能够让单个键组合映射到应用程序中预期操作非常重要,并且能够自动适应为焦点上下文实现预期操作的任何操作。

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

意图配置操作,以便同一个操作可以服务于多个用途。一个例子是DirectionalFocusIntent,它采用一个方向来移动焦点,允许DirectionalFocusAction 知道向哪个方向移动焦点。但要小心:不要在Intent中传递适用于Action的所有调用的状态:这种状态应该传递给Action本身的构造函数,以防止Intent需要了解太多信息。

为什么不使用回调?

您还可能会想:为什么不直接使用回调而不是Action对象?主要原因是操作可以通过实现isEnabled来决定是否启用它们非常有用。此外,键绑定及其绑定的实现位于不同位置通常很有帮助。

如果您只需要回调,而不需要 ActionsShortcuts 的灵活性,可以使用 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,并将其绑定到您自己的 SelectAllActionCanvasSelectAllAction,并且从该一个键绑定开始,系统会调用其中一个,具体取决于应用程序的哪一部分具有焦点。让我们看看键绑定部分是如何工作的

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

定义操作

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

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

仅当 isEnabled(Intent intent) 返回 true 时,Actions 小部件才会调用操作,从而允许操作决定调度程序是否应考虑调用它。如果操作未启用,则 Actions 小部件会给小部件层次结构中较高的另一个已启用操作(如果存在)执行机会。

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

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

要调用操作,请将操作传递给 ActionDispatcher 上的 invoke 方法,您可以自己创建一个,也可以使用 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])

将它们组合在一起

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