[読書会]アクションとショートカットの利用

 ここでは、物理的なキーボードイベント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 ウィジェットを使うことができます。これにより、シンプルなショートカットシステムが提供され、複雑なアクションの仕組みを使わずに済むという利点があります。

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ウィジェットを使用してIntentActionマッピングします。(例えば、SelectAllIntent 定義して、それを独自の SelectAllAction または CanvasSelectAllAction バインドすることができます。)
  キー・バインディングの仕組みを見てみましょう。

Actionsウィジェットを使用してIntent(SelectAllIntent を定義して)をActionにマッピング(独自の 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 より多様でカスタマイズ可能ショートカットを扱うためのインターフェースです。

SingleActivatorCharacterActivator

  • SingleActivator
     単一のキーと、オプションその前に押す必要のある修飾キー(例: Shift Ctrl を指定してショートカットアクティベートするために使用されます。(例: 「Ctrl + A」などのシンプルなショートカット)
  • CharacterActivator
     キーそのものではなく、そのキーシーケンス生成する「文字」に基づいてショートカットアクティベートします。これは、複数ロケールキーボードレイアウト同じキャラクター:「a」)異なるキーによって生成される可能性があるため、特定の文字を使用するシナリオ有効です。

③ カスタマイズ

  • ShortcutActivator はサブクラス化して、カスタムショートカット定義するための基盤となります。これにより、特定のキーイベントに基づいて独自のショートカットアクティベーション方法作成することが可能です。
    例えば、特定の条件が揃った場合にのみ有効になるショートカットを実装できます。

 ShortcutActivator は、従来の LogicalKeySet よりもショートカットの処理方法が柔軟で、さまざまなショートカットアクティベーションシナリオに対応できる設計になっています。SingleActivatorCharacterActivator のような具象クラスを使うことで、ユーザーの入力やキーイベントに対して柔軟にショートカットの動作を設定できます。

(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 返された場合にそのイベントコンテキストログとして出力するです。

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)に基づいて、実行する動作を定義します。

Dart
//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() を実行するだけのシンプルな実装がされています。

Dart
//クラスを作成せずに、シンプルなアクションを定義できる。
CallbackAction(onInvoke: (intent) => model.selectAll());

 アクションができたら、Actions ウィジェットを使ってアプリケーションに登録します。

Dart
@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)インテントに関連するアクションの検索

 例えば、あるインテントに関連するアクションを見つけるには、次のようにします。

Dart
//SelectAllIntentに関連付けられたActionを検索する
Action<SelectAllIntent>? selectAll =
    Actions.maybeFind<SelectAllIntent>(context);
    
//Actions.maybeFind: maybeFind は、指定された Intent に関連するアクションが
//現在のコンテキスト内に存在するかどうかを確認します。
//見つかればその Action を返し、存在しない場合は null を返します。

 指定されたコンテキストで SelectAllIntent タイプが利用可能な場合、それに関連付けられた Action を返します。
利用可能なものがない場合はnullを返す。 関連するActionが常に利用可能であるべきなら、一致するIntentタイプが見つからない時に例外を投げるmaybeFindの代わりにfindを使います。

(2-2)アクションの呼び出し

 アクションを呼び出すには(存在すれば)、次の様にします。

Dart
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 を返します。 これにより、コンテキストにマッチする有効なアクションがない場合、ボタンを無効にすることができます。

Dart
@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)参照)

Dart
//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を作成することができます。

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('全て選択'),
      ),
    ),
  );
}

 これは、実行されたすべてのアクションを次のようにログに記録します。

Dart
flutter: Action invoked: SelectAllAction#906fc(SelectAllIntent#a98e3) from Builder(dependencies: _[ActionsMarker])

4.まとめ

 アクションとショートカットの組み合わせは強力です。ウィジェットレベルで特定のアクションに対応する一般的なインテントを定義できます。ここに、上記のコンセプトを説明するシンプルなアプリがあります。ここに、上記のコンセプトを説明するシンプルなアプリがあります。ボタンはアクションを呼び出して、その仕事を遂行します。呼び出されたすべてのアクションとショートカットが記録されます。

コメントを残す