跳到主内容

使用动作和快捷键

如何在 Flutter 应用中使用动作和快捷键。

本页描述了如何将物理键盘事件绑定到用户界面中的动作。例如,如果你想在你的应用程序中定义键盘快捷键,那么本页就是为你准备的。

概述

#

为了让 GUI 应用程序执行任何操作,它必须具有动作:用户希望告诉应用程序某事。动作通常是简单的函数,直接执行该动作(例如设置一个值或保存一个文件)。然而,在更大的应用程序中,事情会变得更加复杂:调用动作的代码和动作本身的代码可能需要在不同的地方。快捷键(键绑定)可能需要在不知道它们调用的动作的层级上定义。

这就是 Flutter 的动作和快捷键系统发挥作用的地方。它允许开发者定义动作,这些动作满足与其绑定的意图。在这种情况下,意图是用户希望执行的通用操作,而 Flutter 中的 Intent 类实例代表这些用户意图。一个 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,并将其绑定到你自己的 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 实例。逻辑键集定义了一组一个或多个键,并且意图指示键按下的预期目的。Shortcuts 组件在映射中查找按键,以找到一个 Intent 实例,并将其传递给动作的 invoke() 方法。

ShortcutManager

#

快捷键管理器是一个比 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 调用它们来执行的操作。动作可以启用或禁用,并且接收调用它们的 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 类型时会引发异常。

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

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,则框架将从当前小部件之上开始查找。

你可以调用一个动作而不需要 BuildContext,但由于 Actions 组件需要一个上下文来查找要调用的已启用动作,因此你需要提供一个,要么通过创建你自己的 Action 实例,要么通过使用 Actions.find 在适当的上下文中找到一个。

要调用该动作,请将该动作传递给 ActionDispatcher 上的 invoke 方法,要么是你自己创建的一个,要么是从现有的 Actions 组件使用 Actions.of(context) 方法检索的一个。在调用 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();
  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());