使用動作和快捷鍵
如何在 Flutter 應用中使用動作和快捷鍵。
本頁描述瞭如何將物理鍵盤事件繫結到使用者介面中的動作。例如,如果你想在你的應用程式中定義鍵盤快捷鍵,那麼本頁就是為你準備的。
概述
#為了讓 GUI 應用程式執行任何操作,它必須具有動作:使用者希望告訴應用程式做某事。動作通常是簡單的函式,直接執行該動作(例如設定一個值或儲存一個檔案)。然而,在更大的應用程式中,事情會變得更加複雜:呼叫動作的程式碼和動作本身的程式碼可能需要在不同的地方。快捷鍵(鍵繫結)可能需要在不知道它們呼叫的動作的層級上定義。
這就是 Flutter 的動作和快捷鍵系統發揮作用的地方。它允許開發者定義動作,這些動作滿足與其繫結的意圖。在這種情況下,意圖是使用者希望執行的通用操作,而 Flutter 中的 Intent 類例項代表這些使用者意圖。一個 Intent 可以是通用的,由不同上下文中的不同動作滿足。一個 Action 可以是一個簡單的回撥(如 CallbackAction 的情況),也可以是與整個撤銷/重做架構(例如)或其他邏輯整合的更復雜的東西。
Shortcuts 是透過按下鍵或鍵組合來啟用的鍵繫結。這些鍵組合位於一個表中,與它們繫結的意圖一起。當 Shortcuts 元件呼叫它們時,它會將匹配的意圖傳送到動作子系統以供滿足。
為了說明動作和快捷鍵的概念,本文建立了一個簡單的應用程式,允許使用者使用按鈕和快捷鍵在文字欄位中選擇和複製文字。
為什麼將動作與意圖分開?
#你可能會想:為什麼不直接將鍵組合對映到動作?為什麼需要意圖?這是因為在鍵對映定義的位置(通常在高層級)和動作定義的位置(通常在低層級)之間進行關注點分離是有用的,並且重要的是能夠讓一個鍵組合對映到應用程式中的預期操作,並自動適應執行該預期操作的焦點上下文。
例如,Flutter 有一個 ActivateIntent 元件,它將每種型別的控制元件對映到其對應的 ActivateAction 版本(並執行啟用該控制元件的程式碼)。這段程式碼通常需要相當私有的訪問許可權才能完成其工作。如果 Intent 提供的額外間接層不存在,那麼將動作的定義提升到定義 Shortcuts 元件的例項可以看到它們的位置是必要的,導致快捷鍵比必要的知道更多關於要呼叫哪個動作,並且需要訪問或提供它不一定擁有或需要的狀態。這允許你的程式碼將這兩個關注點分開,使其更加獨立。
意圖配置一個動作,以便同一個動作可以服務於多種用途。一個例子是 DirectionalFocusIntent,它獲取一個移動焦點的方向,允許 DirectionalFocusAction 知道移動焦點的方向。但要小心:不要在 Intent 中傳遞適用於 Action 所有呼叫的狀態:這種狀態應該傳遞給 Action 本身的建構函式,以防止 Intent 需要知道太多。
為什麼不使用回撥函式?
#你可能還會想:為什麼不直接使用回撥函式而不是 Action 物件?主要原因是,動作可以透過實現 isEnabled 來決定它們是否啟用。此外,鍵繫結和這些繫結的實現位於不同的位置通常很有幫助。
如果你只需要回撥函式而不需要 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 元件的作用。
它被插入到小部件層次結構中,以定義代表使用者在按下該鍵組合時意圖的鍵組合。為了將鍵組合的預期目的轉換為具體的動作,使用了 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 元件的對映將一個 LogicalKeySet(或一個 ShortcutActivator,請參閱下面的註釋)對映到一個 Intent 例項。邏輯鍵集定義了一組一個或多個鍵,並且意圖指示鍵按下的預期目的。Shortcuts 元件在對映中查詢按鍵,以找到一個 Intent 例項,並將其傳遞給動作的 invoke() 方法。
ShortcutManager
#快捷鍵管理器是一個比 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 呼叫它們來執行的操作。動作可以啟用或停用,並且接收呼叫它們的 Intent 例項作為引數,以允許意圖進行配置。
定義動作
#動作,以最簡單的形式,只是具有 invoke() 方法的 Action<Intent> 的子類。這是一個簡單的動作,它只是在提供的模型上呼叫一個函式
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'),
),
),
);
}
Actions 元件僅在 isEnabled(Intent intent) 返回 true 時才呼叫動作,從而允許動作決定排程器是否應考慮呼叫它。如果該動作未啟用,則 Actions 元件會給更高層級的小部件樹中的另一個已啟用的動作(如果存在)一個執行的機會。
前面的示例使用了一個 Builder,因為 Actions.handler 和 Actions.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 來完成這項工作
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])
整合起來
#Actions 和 Shortcuts 的結合非常強大:你可以在元件級別定義通用的意圖,並將它們對映到特定的動作。這裡有一個簡單的應用程式,它說明了上述概念。該應用程式建立一個文字欄位,旁邊還有“全選”和“複製到剪貼簿”按鈕。這些按鈕呼叫動作來完成它們的工作。所有呼叫的動作和快捷鍵都會被記錄。
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());