Skip to main content

使用操作和快捷键

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

概述

#

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

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

使用快捷键图

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

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

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

#

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

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

意图配置操作,以便相同的操作可以服务于多种用途。一个例子是 DirectionalFocusIntent,它需要一个方向来移动焦点,允许 DirectionalFocusAction 知道将焦点移动到哪个方向。请注意:不要在适用于所有 Action 调用的 Intent 中传递状态:这种状态应该传递给 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('按向上箭头键可添加到计数器'),
          const Text('按向下箭头键可从计数器中减去'),
          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('全选'),
        ),
      ),
    ),
  );
}

提供给 Shortcuts 小部件的映射将 LogicalKeySet(或 ShortcutActivator,见下面的注释)映射到 Intent 实例。逻辑键集定义了一组一个或多个键,而意图表示按键的预期用途。Shortcuts 小部件在映射中查找按键,以查找 Intent 实例,然后将其提供给 action 的 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('在 $context 中处理快捷键 $event');
    }
    return result;
  }
}

现在,每次 Shortcuts 小部件处理快捷键时,它都会打印出按键事件和相关上下文。

操作

#

Actions 允许通过使用 Intent 调用它们来定义应用程序可以执行的操作。操作可以启用或禁用,并接收作为参数调用它们的意图实例,以便通过意图进行配置。

定义操作

#

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

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 类型关联的操作,则返回该操作。如果不存在,则返回 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('全选'),
      ),
    ),
  );
}

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,但是那样您就会放弃操作调度程序可能提供的任何服务(如日志记录、撤消/重做等)。

操作调度程序

#

大多数情况下,您只需调用一个操作,让它执行其操作,然后忘记它即可。但是,有时您可能想要记录已执行的操作。

这就是用自定义调度程序替换默认 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($intent) 来自 $context');
    super.invokeAction(action, intent, context);

    return null;
  }

  @override
  (bool, Object?) invokeActionIfEnabled(
    covariant Action<Intent> action,
    covariant Intent intent, [
    BuildContext? context,
  ]) {
    print('已调用操作:$action($intent) 来自 $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('全选'),
      ),
    ),
  );
}

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

flutter: 已调用操作:SelectAllAction#906fc(SelectAllIntent#a98e3) 来自 Builder(dependencies: _[ActionsMarker])

整合

#

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

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

/// 一个文本字段,它还有一个按钮来全选文本并将选定的文本复制到剪贴板。
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(),
              ],
            ),
          ),
        );
      }),
    );
  }
}

/// 一个记录其处理的所有键的 ShortcutManager。
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;
  }
}

/// 一个记录其调用的所有操作的 ActionDispatcher。
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;
  }
}

/// 一个绑定到 ClearAction 的意图,以便清除其 TextEditingController。
class ClearIntent extends Intent {
  const ClearIntent();
}

/// 一个绑定到 ClearIntent 的操作,它清除其 TextEditingController。
class ClearAction extends Action<ClearIntent> {
  ClearAction(this.controller);

  final TextEditingController controller;

  @override
  Object? invoke(covariant ClearIntent intent) {
    controller.clear();

    return null;
  }
}

/// 一个绑定到 CopyAction 的意图,用于从其 TextEditingController 复制。
class CopyIntent extends Intent {
  const CopyIntent();
}

/// 一个绑定到 CopyIntent 的操作,它将 TextEditingController 中的文本复制到剪贴板。
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;
  }
}

/// 一个绑定到 SelectAllAction 的意图,用于在其控制器中全选文本。
class SelectAllIntent extends Intent {
  const SelectAllIntent();
}

/// 一个绑定到 SelectAllAction 的操作,它在其 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;
  }
}

/// 顶级应用程序类。
///
/// 此处定义的快捷键对整个应用程序都有效,
/// 尽管不同的窗口小部件可能以不同的方式实现它们。
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  static const String title = '快捷键和操作演示';

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