使用 Actions 和 Shortcuts
如何在 Flutter 應用中使用 Actions 和 Shortcuts。
本頁介紹瞭如何將物理鍵盤事件繫結到使用者介面中的操作(Action)。例如,如果你需要在應用中定義鍵盤快捷鍵,請參閱本頁。
概述
#對於 GUI 應用程式來說,要執行任何操作都必須有 Action:使用者希望告訴應用程式去“做”某事。Action 通常是直接執行操作(例如設定值或儲存檔案)的簡單函式。然而,在大型應用程式中,情況更為複雜:呼叫 Action 的程式碼和 Action 本身的程式碼可能需要位於不同的地方。快捷鍵(鍵繫結)的定義層面可能並不瞭解它們所呼叫的 Action。
這就是 Flutter 的 Action 和 Shortcut 系統發揮作用的地方。它允許開發者定義滿足繫結到其上的 Intent 的 Action。在此上下文中,Intent 是使用者希望執行的通用操作,而 Intent 類例項代表了 Flutter 中的這些使用者意圖。一個 Intent 可以是通用的,在不同的上下文中由不同的 Action 來實現。一個 Action 可以是一個簡單的回撥(如 CallbackAction 的情況),也可以是與整個撤銷/重做架構或其他邏輯整合的更復雜的內容。
Shortcuts 是透過按下一個鍵或組合鍵來啟用的鍵繫結。鍵組合及其繫結的 Intent 位於一個表中。當 Shortcuts 小部件呼叫它們時,它會將匹配的 Intent 傳送到 Action 子系統以進行執行。
為了說明 Action 和 Shortcut 中的概念,本文建立了一個簡單的應用程式,允許使用者使用按鈕和快捷鍵在文字欄位中選擇並複製文字。
為什麼要將 Actions 和 Intents 分開?
#你可能會好奇:為什麼不直接將組合鍵對映到 Action?為什麼還要有 Intent?這是因為在鍵對映定義所在的位置(通常在較高層級)與 Action 定義所在的位置(通常在較低層級)之間實現關注點分離非常有用。此外,能夠讓單個組合鍵對映到應用程式中的預期操作,並讓它自動適應當前聚焦上下文中實現該操作的任何 Action,這一點非常重要。
例如,Flutter 有一個 ActivateIntent 小部件,它將每種型別的控制元件對映到其對應的 ActivateAction 版本(並執行啟用控制元件的程式碼)。這些程式碼通常需要相當私有的訪問許可權才能完成工作。如果 Intent 提供的間接層不存在,則必須將 Action 的定義提升到 Shortcuts 小部件定義例項可以“看見”的位置,這會導致快捷鍵瞭解過多關於呼叫哪個 Action 的資訊,並可能需要訪問或提供它本來不需要的狀態。這使得你的程式碼可以將這兩個關注點分離開來,從而更加獨立。
Intent 對 Action 進行配置,使得同一個 Action 可以服務於多種用途。一個例子是 DirectionalFocusIntent,它攜帶一個移動焦點的方向,從而允許 DirectionalFocusAction 知道向哪個方向移動焦點。但請注意:不要在 Intent 中傳遞適用於 Action 所有呼叫的狀態:這種狀態應該傳遞給 Action 本身的建構函式,以避免 Intent 需要了解過多資訊。
為什麼不使用回撥?
#你可能還會好奇:為什麼不直接使用回撥而不是 Action 物件?主要原因是,透過實現 isEnabled 來決定 Action 是否啟用是非常有用的。此外,將鍵繫結和這些繫結的實現放在不同的地方通常也有所幫助。
如果你只需要回撥,而不需要 Actions 和 Shortcuts 的靈活性,可以使用 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
#正如你在下面看到的,Action 本身很有用,但最常見的用例是將它們繫結到鍵盤快捷鍵。這就是 Shortcuts 小部件的作用。
它被插入到小部件層級中,用於定義在按下鍵組合時代表使用者意圖的鍵組合。為了將鍵組合的預期目的轉換為具體的 Action,Actions 小部件用於將 Intent 對映到 Action。例如,你可以定義一個 SelectAllIntent,並將其繫結到你自己的 SelectAllAction 或 CanvasSelectAllAction。透過這一個鍵繫結,系統會根據應用程式的哪一部分擁有焦點來呼叫其中一個。讓我們看看鍵繫結部分是如何工作的。
@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 小部件的 map 將 LogicalKeySet(或 ShortcutActivator,見下文說明)對映到 Intent 例項。邏輯鍵集定義了一組或多個鍵,Intent 指示了按鍵的預期目的。Shortcuts 小部件在 map 中查詢按鍵,以找到一個 Intent 例項,並將其提供給 Action 的 invoke() 方法。
ShortcutManager
#快捷鍵管理器(Shortcut Manager)是一個比 Shortcuts 小部件壽命更長的物件,它在接收到鍵事件時將其傳遞出去。它包含決定如何處理鍵的邏輯、向上遍歷樹以查詢其他快捷鍵對映的邏輯,並維護一個鍵組合到 Intent 的 map。
雖然 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 例項作為引數,以允許透過 Intent 進行配置。
定義 Action
#Action 在最簡單的形式中,只是 Action<Intent> 的子類,帶有一個 invoke() 方法。這是一個簡單的 Action,它僅僅在提供的模型上呼叫一個函式。
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());
一旦有了 Action,就可以使用 Actions 小部件將其新增到應用程式中,該小部件接受一個 Intent 型別到 Action 的 map。
@override
Widget build(BuildContext context) {
return Actions(
actions: <Type, Action<Intent>>{SelectAllIntent: SelectAllAction(model)},
child: child,
);
}
Shortcuts 小部件使用 Focus 小部件的上下文和 Actions.invoke 來查詢要呼叫的 Action。如果 Shortcuts 小部件在遇到的第一個 Actions 小部件中沒有找到匹配的 Intent 型別,它會考慮下一個祖先 Actions 小部件,依此類推,直到到達小部件樹的根部,或找到匹配的 Intent 型別並呼叫相應的 Action。
呼叫 Action
#Action 系統有多種呼叫 Action 的方式。最常見的方式是透過上一節介紹的 Shortcuts 小部件,但還有其他方法可以查詢 Action 子系統並呼叫 Action。也可以呼叫未繫結到按鍵的 Action。
例如,要查詢與 Intent 關聯的 Action,你可以使用:
Action<SelectAllIntent>? selectAll = Actions.maybeFind<SelectAllIntent>(
context,
);
如果在給定的 context 中可用,這將返回與 SelectAllIntent 型別關聯的 Action。如果不可用,它將返回 null。如果關聯的 Action 應該始終可用,那麼使用 find 而不是 maybeFind,後者在找不到匹配的 Intent 型別時會丟擲異常。
要呼叫 Action(如果存在),請呼叫:
Object? result;
if (selectAll != null) {
result = Actions.of(
context,
).invokeAction(selectAll, const SelectAllIntent());
}
將它們合併為一次呼叫:
Object? result = Actions.maybeInvoke<SelectAllIntent>(
context,
const SelectAllIntent(),
);
有時你希望在按下按鈕或其他控制元件時呼叫 Action。你可以使用 Actions.handler 函式來做到這一點。如果 Intent 有一個到已啟用 Action 的對映,Actions.handler 函式會建立一個處理程式閉包。但是,如果它沒有對映,則返回 null。這使得按鈕可以在上下文中沒有匹配的已啟用 Action 時被停用。
@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 時呼叫 Action,允許 Action 決定分發器是否應考慮呼叫它。如果 Action 未啟用,則 Actions 小部件會給小部件層級中更高層的另一個已啟用 Action(如果存在)執行的機會。
前面的示例使用了 Builder,因為 Actions.handler 和 Actions.invoke(例如)僅查詢所提供 context 中的 Action。如果示例傳遞了 build 函式給出的 context,框架會從當前小部件的“上方”開始查詢。使用 Builder 允許框架找到在同一個 build 函式中定義的 Action。
你可以在不需要 BuildContext 的情況下呼叫 Action,但由於 Actions 小部件需要上下文來查詢要呼叫的已啟用 Action,因此你需要提供一個上下文,可以透過建立自己的 Action 例項,或者透過使用 Actions.find 在適當的上下文中查詢一個。
要呼叫 Action,請將 Action 傳遞給 ActionDispatcher 上的 invoke 方法(可以是你自己建立的,也可以是使用 Actions.of(context) 方法從現有 Actions 小部件檢索到的)。在呼叫 invoke 之前,請檢查 Action 是否已啟用。當然,你也可以直接在 Action 本身上呼叫 invoke 並傳遞一個 Intent,但這樣你就放棄了 Action 分發器可能提供的任何服務(如日誌記錄、撤銷/重做等)。
Action 分發器 (Action dispatchers)
#大多數時候,你只是想呼叫一個 Action,讓它執行任務,然後就不再關心它了。然而,有時你可能想記錄已執行的 Action。
這就是用自定義分發器替換預設 ActionDispatcher 的作用所在。你將 ActionDispatcher 傳遞給 Actions 小部件,它會從下方任何未設定自己的分發器的 Actions 小部件中呼叫 Action。
Actions 在呼叫 Action 時做的第一件事就是查詢 ActionDispatcher 並將其傳遞給它以進行呼叫。如果沒有分發器,它會建立一個僅呼叫該 Action 的預設 ActionDispatcher。
如果你想要記錄所有已呼叫的 Action,可以建立一個自己的 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'),
),
),
);
}
這會記錄每個執行的 Action,如下所示:
flutter: Action invoked: SelectAllAction#906fc(SelectAllIntent#a98e3) from Builder(dependencies: _[ActionsMarker])
整合起來
#Actions 和 Shortcuts 的組合非常強大:你可以在小部件級別定義對映到特定 Action 的通用 Intent。下面是一個簡單的應用程式,說明了上述概念。該應用程式建立了一個文字欄位,旁邊還有“全選”和“複製到剪貼簿”按鈕。這些按鈕呼叫 Action 來完成它們的工作。所有被呼叫的 Action 和快捷鍵都會被記錄。
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());