ここでは、物理的なキーボードイベントをUIのアクションにバインドする方法(アプリケーションでキーボード・ショートカットを定義する、等)について触れています。
1.概要
(1)アクション(Actions)
GUIアプリケーションが何かをする為には、アクションが必要です。
アクションは多くの場合、アクションを直接実行する単純な関数です(値の設定やファイルの保存など)。 Actionは単純なコールバック(CallbackActionの場合)であることもあれば、(例えば)undo/redoアーキテクチャ全体や他のロジックと統合するような複雑なものであることもあります。
(アクションを呼び出す為のコードとアクション自体のコードは異なる場所にある必要があるかもしれません。ショートカット(キーバインディング)は、それが呼び出すアクションについて何も知らないレベルで定義する必要があるかもしれません。)
(2)インテント(Intent)
そこでFlutterのアクションとショートカットシステムの出番です。これによって、開発者は、それらにバインドされたインテントを満たすアクションを定義することができます。この文脈では、インテントとはユーザーが実行したい一般的なアクションのことであり、IntentクラスのインスタンスはFlutterでこれらのユーザーインテントを表します。インテントは汎用的なものであり、さまざまな文脈におけるさまざまな行動によって実現されます。
(3)ショートカット(Shortcuts)
ショートカットは、キーまたはキーの組み合わせを押すことで有効になるキーバインドです。キーの組み合わせは、バインドされたインテントと共にテーブルに存在する。 ショートカットウィジェットがそれらを呼び出すと、マッチするインテントをアクションサブシステムに送り、実行させます。
アクションとショートカットの概念を説明するために、この記事では、ユーザーがボタンとショートカットの両方を使用してテキストフィールド内のテキストを選択し、コピーできるようにする簡単なアプリを作成します。
(4)アクションとインテントを分離する理由について
なぜキーの組み合わせを直接アクションにマッピングしないのか?
なぜインテントを持つのか?
これは、キーマッピングの定義がある場所(多くの場合、高レベル)とアクションの定義がある場所(多くの場合、低レベル)の間で関心事を分離することが有用であるためであり、また、1つのキーの組み合わせがアプリの意図する操作にマッピングされ、フォーカスされたコンテキストでその意図する操作を満たすアクションに自動的に適応させることができることが重要である為です。
例えば、FlutterにはActivateIntentウィジェットがあり、各タイプのコントロールを(対応するバージョンの)ActivateActionにマッピングします。(そして、そのコードを実行し、コントロールをアクティブにする。)
このコードはしばしば、処理する為にかなりプライベートなアクセスを必要とします。
もしインテント(間接的なレイヤー)がなかった場合、アクションの定義を、ショートカットが直接参照できる高い位置(アプリ全体の設定レベルなど)に配置する必要が出てきます。つまり、ショートカットウィジェットはアクションの詳細な内容や状態に直接アクセスできる場所に置かなければなりません。これによって、ショートカットウィジェットは「どのアクションを実行するか」という情報に加えて、アクションが持つ内部の状態やロジックにもアクセスしなければならないため、不必要にアプリの構造や状態について知りすぎてしまうことになります。
一方で、インテントを使うことで、ショートカットとアクションの間に一つの仲介役が入るため、ショートカットはアクションの詳細を知らずに済みます。これにより、ショートカットは必要最低限の情報だけにアクセスし、アクションの定義や状態に余計な知識を持たなくて済むようになります。これにより、コードはショートカットとアクションを分離でき、より独立したものにすることができます。
同じアクションが複数の用途に使えるように、インテントはアクションを構成します。この例はDirectionalFocusIntentで、フォーカスを移動させる方向を受け取り、DirectionalFocusActionがフォーカスを移動させる方向を知ることができます。ただ注意してほしいのは、アクションのすべての呼び出しに適用されるステートをIntentに渡さないことだ。そのようなステートはアクションのコンストラクタに渡すべきで、Intentが多くのことを知る必要がなくなります。
(5)コールバックを使用しない理由について
Actionオブジェクトの代わりにコールバックを使えばいいのでは?
Actions とは、アプリケーションが実行すべき特定の操作を表現するオブジェクトです。Flutterでは、アクションとショートカットのシステムを使うことで、キー操作やショートカットからアクションをトリガーする仕組みを提供しています。
主な理由は、以下の通りです。
① isEnabled メソッドの活用:
Actions では isEnabled というメソッドを使って、現在そのアクションが有効かどうかを決定できます。
(例えば、アプリの状態によって特定のアクションを無効にしたいことがあり、このメソッドを使うとそれが簡単に実現できます。)
一方で、コールバック では単に関数を呼び出すだけなので、その関数が実行可能かどうかを調べたり制御する仕組みが標準では提供されません。
② キー割り当てと実装の分離:
Actions を使うと、ショートカットキーの定義(ショートカットウィジェット)とその実際のアクション(アクションオブジェクト)の実装を異なる場所に分けて管理できます。これにより、アプリの設計がよりモジュール化され、保守がし易くなります。
(6)CallbackShortcuts の使いどころ
もし、アクションやショートカットシステムを使うほどの柔軟性が不要で、単純にキー押下時にコールバックを呼び出すだけで十分な場合は、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('上矢印キーを押してカウンターに追加します:'),
const Text('下矢印キーを押してカウンターから減算します:'),
Text('カウント: $count'),
],
),
),
);
}
2.ショートカット
後述するように、アクションはそれ自体でも便利だが、最も一般的な使用例としては、キーボードショートカットにバインドすることが挙げられます。これがショートカットウィジェットの目的です。
(1)LogicalKeySet の利用
ウィジェット階層に挿入され、そのキーの組み合わせが押された時のユーザの意図を表すキーの組み合わせを定義します。
キーの組み合わせに意図された目的を具体的なアクションに変換するために、Actionsウィジェットを使用してIntentをActionにマッピングします。(例えば、SelectAllIntent を定義して、それを独自の SelectAllAction または CanvasSelectAllAction にバインドすることができます。)
キー・バインディングの仕組みを見てみましょう。
@override
Widget build(BuildContext context) {
//Shortcutsウィジェット では、キーの組み合わせ Ctrl + A が
//SelectAllIntent に対応しています。これは「全選択」の意図を示しています。
return Shortcuts(
//キーの組合せ(LogicalKeySet)をユーザの意図(Intent)にマッピングします。
shortcuts: <LogicalKeySet, Intent>{
//キーの組合せ
LogicalKeySet(
//ショートカット(Ctrl+A)指定
LogicalKeyboardKey.control, LogicalKeyboardKey.keyA
): const SelectAllIntent(),//Ctrl+A押下時 SelectAllIntent発生
},
//Actionsウィジェット では、SelectAllIntent が発生した際に実行される
//SelectAllAction をマッピングしています。
child: Actions(
dispatcher: LoggingActionDispatcher(),
actions: <Type, Action<Intent>>{
//SelectAllIntent に対応するアクションを指定
SelectAllIntent: SelectAllAction(model),
},
//さらに、TextButton が表示され、ボタンをクリックしても同じ
//SelectAllIntent が発生します。これにより、
//ボタンのクリックとキーボードショートカットの両方で
//同じアクションがトリガーされます。
child: Builder(
builder: (context) => TextButton(
onPressed: Actions.handler<SelectAllIntent>(
context,
//ボタンのクリック時 SelectAllIntent発生
const SelectAllIntent(),
),
child: const Text('全選択'),
),
),
),
);
}
//この構造を使うと、ショートカットキーの定義(Shortcutsウィジェット)と、そのショートカットが実行するアクションの実装(Actionsウィジェット)を分離することができます。これにより、アプリケーションの設計がより柔軟になり、アクションとショートカットが異なる場所で定義されていても、意図に基づいたアクションを実行することが可能です。
//Shortcuts は、キーの組み合わせを意図にマッピングし、Actions はその意図に基づいて具体的なアクションを実行します。この仕組みを使うことで、ユーザーインタラクションをよりモジュール化し、キー操作やボタン操作に対して一貫したアクションを提供できます。
Shortcuts ウィジェットに指定されたマップは、LogicalKeySet (または ShortcutActivator、下記の注記を参照) を Intent インスタンスにマップします。論理キーセットは1つまたは複数のキーのセットを定義し、インテントはキー押下の意図された目的を示します。Shortcutsウィジェットは、マップ内のキー押下を検索してIntentインスタンスを見つけ、それをアクションのinvoke()メソッドに渡します。
(2)ShortcutActivator の利用
Flutterにおける ShortcutActivator について詳しく解説しています。特に、ShortcutActivator が LogicalKeySet の置き換えとしてより柔軟で正確なショートカットのアクティベーションを可能にする点に焦点を当てています。
① LogicalKeySet との違い
- LogicalKeySet は( ShortcutActivator の一部ですが)複数のキーをセットとして扱い、特定のキーの組み合わせでショートカットをアクティベートします。
- これに対して、ShortcutActivator はより多様でカスタマイズ可能なショートカットを扱うためのインターフェースです。
② SingleActivator と CharacterActivator
- SingleActivator:
単一のキーと、オプションでその前に押す必要のある修飾キー(例: Shift や Ctrl )を指定してショートカットをアクティベートするために使用されます。(例: 「Ctrl + A」などのシンプルなショートカット) - CharacterActivator:
キーそのものではなく、そのキーシーケンスが生成する「文字」に基づいてショートカットをアクティベートします。これは、複数のロケールやキーボードレイアウトで同じキャラクター(例:「a」)が異なるキーによって生成される可能性があるため、特定の文字を使用するシナリオに有効です。
③ カスタマイズ
- ShortcutActivator はサブクラス化して、カスタムショートカットを定義するための基盤となります。これにより、特定のキーイベントに基づいて独自のショートカットアクティベーションの方法を作成することが可能です。
例えば、特定の条件が揃った場合にのみ有効になるショートカットを実装できます。
ShortcutActivator は、従来の LogicalKeySet よりもショートカットの処理方法が柔軟で、さまざまなショートカットのアクティベーションシナリオに対応できる設計になっています。SingleActivator や CharacterActivator のような具象クラスを使うことで、ユーザーの入力やキーイベントに対して柔軟にショートカットの動作を設定できます。
(3)ショートカット・マネージャ
ここではShortcutManager の役割とそのカスタマイズ方法について触れています。
(3-1)ShortcutManager の基本機能
- ShortcutManager は(キーボードショートカット処理を一時的に担当する Shortcuts ウィジェットと比較して)長期間に渡りアプリ全体のキーイベントを一貫して管理する役割を担うオブジェクトです。
- その主な役割は、ショートカットのキーマッピングを保持し、受け取ったキーイベントを他のショートカットマッピングを探してツリーを遡って処理することです。これは、ショートカットを処理する際のロジックが含まれます。
(3-2)カスタマイズ可能
- (Shortcuts ウィジェットのデフォルト動作でも一般的に十分なのですが)ショートカット処理ロジックをより詳細にカスタマイズしたい場合には、ShortcutManager をサブクラス化して機能を追加することができます。
(3-3)カスタムのショートカットマネージャーの例
- 説明の中では、キーが処理されるたびにログを出力する LoggingShortcutManager の例が紹介されています。
このクラスは ShortcutManager を拡張し、handleKeypress メソッドをオーバーライドして、キーイベントが処理された場合にそのイベントを出力します。 - 具体的には、super.handleKeypress メソッドを呼んで、ショートカットが処理されたかどうかを確認し、KeyEventResult.handled が返された場合にそのイベントとコンテキストをログとして出力しています。
(3-4)実装の意味
このようなカスタマイズにより、アプリケーションのショートカットイベントのトラッキングが容易になり、デバッグや問題のトラブルシューティングに役立ちます。例えば、特定のショートカットが期待どおりに動作していない場合、このようなログ出力を追加することで、どのイベントがどのコンテキストで処理されているのかを追跡できるようになります。
以下は、super.handleKeypress メソッドを呼んで、ショートカットが処理されたかどうかを確認し、KeyEventResult.handled が返された場合にそのイベントとコンテキストをログとして出力する例です。
class LoggingShortcutManager extends ShortcutManager {
//ShortcutManagerクラスのhandleKeypressメソッドをオーバーライド
@override
KeyEventResult handleKeypress(BuildContext context, KeyEvent event) {
//親クラスのhandleKeypressメソッドを呼び出し、結果を取得
final KeyEventResult result = super.handleKeypress(context, event);
//ショートカットが処理された場合(handledならば)
if (result == KeyEventResult.handled) {
//キーイベントとそのコンテキストをログに出力
print('Handled shortcut $event in $context');
}
//ショートカットの処理結果を返す
return result;
}
}
このようにして、ショートカットの処理が行われるたびに、どのキーイベントが処理され、どのコンテキストで発生したかを追跡することができます。デバッグや動作確認に非常に便利です。
3.アクション(Actions)
① Intent との連携:
アクション(Actions)は Intent によってトリガーされ、Intent のインスタンスがアクションに渡されます。
このように、アクションはどの操作を実行するかに加えて、その操作がどのようなIntentによって引き起こされたかも知ることができ、 Intent を基にして動作を変えることができます。
② 有効・無効の切替が可能:
Actionsクラスには、isEnabledメソッドがあり、アクションが実行可能かどうかを動的に制御することができます。
これにより、特定の条件下でアクションを無効化し、ユーザーがその操作を実行できないようにすることが可能です。
(1)アクションの定義
Actionクラスをサブクラス化し、その中で invoke()メソッドを実装することでアクションを定義します。invoke()メソッドは、ユーザーのインタラクションによってトリガーされ、特定の処理を行います。このActionは、アプリ内のユーザーの意図(Intent)に基づいて、実行する動作を定義します。
//SelectAllActionクラスはAction<Intent>を継承している。
//Intent に応じて実行するアクションを定義するクラス
//つまり、このクラスは SelectAllIntent という特定の Intent
//に対応したアクションを実装しています。
class SelectAllAction extends Action<SelectAllIntent> {
//Modelクラスのインスタンスを受け取るコンストラクタ
//この model は、このクラスの操作対象となるオブジェクトです。
SelectAllAction(this.model);
//Modelクラスのインスタンス
final Model model;
//invoke()メソッドは、特定のIntent 発生時に呼び出される。
//covariantは、このメソッドがSelectAllIntent型のIntentを受取る事を意味する。
@override
void invoke(covariant SelectAllIntent intent) => model.selectAll();
}
CallbackAction クラスを定義するのが面倒な場合は、CallbackAction を使うことで、単純なアクションをすばやく定義できます。
ここでは、onInvoke コールバックで model.selectAll() を実行するだけのシンプルな実装がされています。
//クラスを作成せずに、シンプルなアクションを定義できる。
CallbackAction(onInvoke: (intent) => model.selectAll());
アクションができたら、Actions ウィジェットを使ってアプリケーションに登録します。
@override
Widget build(BuildContext context) {
//Actions ウィジェットは、Intent の型を特定のアクションにマッピングします。
//この例では、SelectAllIntent が発生したときに、SelectAllAction
//が実行されるように設定されています。
return Actions(
//Intentの型と対応するActionをマッピングしている。
actions: <Type, Action<Intent>>{
SelectAllIntent: SelectAllAction(model),
},
//アクションを適用するウィジェットの子要素
child: child,
);
}
Shortcuts ウィジェットは、Focus ウィジェットのコンテキストと Actions.invoke を使用して、呼び出すアクションを見つけます。Shortcutsウィジェットが最初に遭遇したActionsウィジェットで一致するインテント・タイプが見つからない場合、次の祖先のActionsウィジェットを検討し、ウィジェット・ツリーのルートに達するまで、または一致するインテント・タイプを見つけて対応するアクションを呼び出すまで続けます。
(2)アクションの呼出
Actions システムでは、ショートカットを使用せずにアクションを呼び出す方法がいくつか存在します。
アクションシステムにはアクションを呼び出す方法がいくつかあります。
最も一般的な方法は、前のセクションで説明したショートカットウィジェットを使うことですが、アクションサブシステムに問い合わせてアクションを呼び出す方法は他にもあります。キーにバインドされていないアクションを呼び出すことも可能です。
(2-1)インテントに関連するアクションの検索
例えば、あるインテントに関連するアクションを見つけるには、次のようにします。
//SelectAllIntentに関連付けられたActionを検索する
Action<SelectAllIntent>? selectAll =
Actions.maybeFind<SelectAllIntent>(context);
//Actions.maybeFind: maybeFind は、指定された Intent に関連するアクションが
//現在のコンテキスト内に存在するかどうかを確認します。
//見つかればその Action を返し、存在しない場合は null を返します。
指定されたコンテキストで SelectAllIntent タイプが利用可能な場合、それに関連付けられた Action を返します。
利用可能なものがない場合はnullを返す。 関連するActionが常に利用可能であるべきなら、一致するIntentタイプが見つからない時に例外を投げるmaybeFindの代わりにfindを使います。
(2-2)アクションの呼び出し
アクションを呼び出すには(存在すれば)、次の様にします。
Object? result;
if (selectAll != null) {
result =
Actions.of(context).invokeAction(selectAll, const SelectAllIntent());
}
//Actions.of(context).invokeAction:
//selectAll が null でない場合、invokeAction メソッドでアクションを呼出します。
//この方法では Intent と関連付けられたアクションを実行します。
(2-3)アクションの一括呼び出し
次の様にアクションを一括して呼び出せます。
Object? result =
Actions.maybeInvoke<SelectAllIntent>(context, const SelectAllIntent());
//Actions.maybeInvoke: maybeInvoke
//は、アクションを検索して見つかった場合に
//その場で実行するショートカット的なメソッドです。
//アクションの検索と呼び出しを1行で行います。
(2-4)ボタンを使ったアクションの呼び出し
ボタンや他のコントロールを操作することでアクションを起動したい場合があります。これは Actions.handler 関数で行うことができます。インテントが有効なアクションにマッピングされている場合、Actions.handler 関数はハンドラークロージャーを作成します。しかし、マッピングがない場合は null を返します。 これにより、コンテキストにマッチする有効なアクションがない場合、ボタンを無効にすることができます。
@override
Widget build(BuildContext context) {
return Actions(
//IntentとActionのマッピング
actions: <Type, Action<Intent>>{
SelectAllIntent: SelectAllAction(model),
},
child: Builder(
//Builderウィジェットを使用して、コンテキストを適切に扱う
//Actions.handler や Actions.invoke などのメソッドは、
//指定されたコンテキストからアクションを探すため、Builder
//を使用して適切なコンテキストでウィジェットツリーを構築しています。
builder: (context) => TextButton(
//Actions.handlerを使用してアクションのハンドラーを取得
//Actions.handler メソッドは、特定の Intent に関連付けられたアクションが
//存在し、かつ有効であれば、そのアクションを実行するクロージャを作成します。
//もしアクションが無効であれば null を返し、ボタンの無効化等に利用できます。
onPressed: Actions.handler<SelectAllIntent>(
context,
SelectAllIntent(controller: controller),
),
child: const Text('SELECT ALL'),
),
),
);
}
(2-5)ActionDispatcher によるアクションの呼び出し(詳細は以降の(3)参照)
//ActionDispatcher はアクションの実行を管理するためのクラスで、
//Actions.of(context) を使って取得します。アクションが有効かどうかを確認した後、
//invokeAction メソッドを使ってアクションを実行します。
ActionDispatcher dispatcher = Actions.of(context);
if (selectAllAction.isEnabled) {
dispatcher.invokeAction(selectAllAction, SelectAllIntent());
}
アクションウィジェットは、isEnabled(Intent intent) が true を返したときだけアクションを起動します。
アクションが有効でない場合、Actionsウィジェットは、ウィジェット階層の上位にある別の有効なアクション(存在する場合)に実行のチャンスを与えます。
Actions.handlerとActions.invoke(例えば)は、提供されたコンテキスト内のアクションを見つけるだけなので、前の例ではビルダーを使用しています。ビルダーを使うことで、フレームワークは同じビルド関数で定義されたアクションを見つけることができます。
BuildContextを使わなくてもアクションを呼び出すことはできますが、Actionsウィジェットは呼び出す有効なアクションを見つけるためにコンテキストを必要とするので、独自のActionインスタンスを作成するか、Actions.findで適切なコンテキストにあるActionインスタンスを見つけるかして、コンテキストを提供する必要があります。
アクションを呼び出すには、ActionDispatcherのinvokeメソッドにアクションを渡します。ActionDispatcherは、自分で作成したものか、Actions.of(context)メソッドを使って既存のActionsウィジェットから取得したものです。 invokeを呼び出す前に、アクションが有効になっているかどうかを確認してください。もちろん、アクション自体にIntentを渡してinvokeを呼び出すこともできるが、その場合、アクション・ディスパッチャが提供するかもしれないサービス(ロギング、アンドゥ/リドゥなど)から外れてしまう。
(3)アクションディスパッチャ
たいていの場合、アクションを呼び出して実行させ、そのまま忘れてしまいたいものだ。 しかし、実行されたアクションのログを取りたいこともあるでしょう。
そこで、デフォルトの ActionDispatcher をカスタムディスパッチャに置き換えてみましょう。
ActionDispatcherをActionsウィジェットに渡すと、ActionDispatcherを設定していない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('全て選択'),
),
),
);
}
これは、実行されたすべてのアクションを次のようにログに記録します。
flutter: Action invoked: SelectAllAction#906fc(SelectAllIntent#a98e3) from Builder(dependencies: _[ActionsMarker])
4.まとめ
アクションとショートカットの組み合わせは強力です。ウィジェットレベルで特定のアクションに対応する一般的なインテントを定義できます。ここに、上記のコンセプトを説明するシンプルなアプリがあります。ここに、上記のコンセプトを説明するシンプルなアプリがあります。ボタンはアクションを呼び出して、その仕事を遂行します。呼び出されたすべてのアクションとショートカットが記録されます。