跳到主內容

使用動作和快捷鍵

如何在 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());