📋

【Flutter】テキスト選択時に表示されるContextMenuをcontextMenuBuilderでカスタマイズする

に公開

はじめに

テキスト選択時に表示されるメニュー(ContextMenu)をcontextMenuBuilderでカスタマイズしました。
ContextMenuとは、テキストを長押しした時に表示される「コピー」「すべて選択」などのメニューのことです。

Flutterでは、AdaptiveTextSelectionToolbarがこのメニュー機能を提供しています。

この記事では、Xのように「ミュート」といったアプリ独自のボタンをContextMenuに追加する方法、表示アイテムをカスタマイズする方法をご紹介します。

contextMenuBuilderとは

https://api.flutter.dev/flutter/material/TextField/contextMenuBuilder.html
contextMenuBuilderは、TextFieldなどのWidgetでコンテキストメニューの中身を構築するためのコールバック関数です。 この関数の中でAdaptiveTextSelectionToolbarを返すことで、プラットフォーム(iOS/Android)に応じた適切なデザインのメニューを表示できます。コンテキストメニューを表示できる以下のWidgetで利用できます。

  • EditableText
  • TextField
  • CupertinoTextField
  • SelectionArea
  • SelectableText

packages/flutter/lib/src/material/adaptive_text_selection_toolbar.dart#L24

独自のボタンをContextMenuに追加する

contextMenuBuilderを使ってカスタマイズします。
AdaptiveTextSelectionToolbarが提供するコンテキストメニューのボタンリストList<ContextMenuButtonItem>を取得し、そのリストに独自のボタンを追加することで実現できます。

コンテキストメニューの最後に独自のボタンを追加する

buttonItems.addを使うことで、コンテキストメニューの最後にボタンが追加されます。

custom_context_menu.dart
import 'package:flutter/material.dart';

class CustomContextMenu extends StatelessWidget {
  const CustomContextMenu({super.key});

  @override
  Widget build(BuildContext context) {
    return SelectableText(
      '長押しして選択してください',
      contextMenuBuilder: (context, editableTextState) {
        // コンテキストメニューボタンリストを取得
        final List<ContextMenuButtonItem> buttonItems = editableTextState.contextMenuButtonItems;
        
        // 選択されたテキストを取得
        final selectedText = editableTextState.textEditingValue.selection
            .textInside(editableTextState.textEditingValue.text);
        
        // テキストが選択されている場合のみカスタムボタンを追加
        if (selectedText.isNotEmpty) {
          // カスタムボタンを追加
          buttonItems.add(
            ContextMenuButtonItem(
              onPressed: () {
                print('選択されたテキスト: $selectedText');
                // コンテキストメニューを閉じる
                editableTextState.hideToolbar();
              },
              label: 'メモに追加',
            ),
          );
        }
        
        return AdaptiveTextSelectionToolbar.buttonItems(
          anchors: editableTextState.contextMenuAnchors,
          buttonItems: buttonItems,
        );
      },
    );
  }
}

任意の場所に独自のボタンを入れる

buttonItems.insertを使うことで、任意の場所にボタンを入れることができます。
例えば、「コピー」の後ろに入れたい時は以下のようにします。

custom_context_menu.dart
  contextMenuBuilder: (context, editableTextState) {
    // ネイティブのコンテキストメニューボタンリストを取得
    final List<ContextMenuButtonItem> buttonItems =
        editableTextState.contextMenuButtonItems;
    // 選択されたテキストを取得
    final selectedText = editableTextState.textEditingValue.selection
        .textInside(editableTextState.textEditingValue.text);

    // テキストが選択されている場合のみカスタムボタンを追加
    if (selectedText.isNotEmpty) {
      // 「コピー」ボタンのインデックスを検索
      final copyIndex = buttonItems.indexWhere(
        (item) => item.type == ContextMenuButtonType.copy,
      );

      // コピーの次に挿入(見つからない場合は先頭に挿入)
      final insertIndex = copyIndex >= 0 ? copyIndex + 1 : 0;

      buttonItems.insert(
        insertIndex,
        ContextMenuButtonItem(
          onPressed: () {
            print('選択されたテキスト: $selectedText');
            editableTextState.hideToolbar();
          },
          label: 'メモに追加',
        ),
      );
    }

    return AdaptiveTextSelectionToolbar.buttonItems(
      anchors: editableTextState.contextMenuAnchors,
      buttonItems: buttonItems,
    );
  },      

indexWhereを使い、ContextMenuButtonTypeからネイティブのボタンの位置を調べて任意の位置に挿入します。
ContextMenuButtonTypeは以下の種類があるので、任意のネイティブメニューの後ろや前に追加することが可能です。この後説明しますが、Androidではデフォルトで表示しないメニューもあるため注意が必要です。
https://github.com/flutter/flutter/blob/f6ff1529fd6d8af5f706051d9251ac9231c83407/packages/flutter/lib/src/widgets/context_menu_button_item.dart#L19-L53

デフォルトのContextMenuのボタンを削除する

AdaptiveTextSelectionToolbarが提供するContextMenuButtonTypeにあるボタンを削除することができます。
例えば「すべて選択」を削除したい場合、以下のようにして指定したContextMenuButtonTypeのボタンを削除できます。

custom_context_menu.dart
contextMenuBuilder: (context, editableTextState) {
  final buttonItems = editableTextState.contextMenuButtonItems;
  
  // 「すべて選択」ボタンを削除
  buttonItems.removeWhere(
    (item) => item.type == ContextMenuButtonType.selectAll,
  );
  
  return AdaptiveTextSelectionToolbar.buttonItems(
    anchors: editableTextState.contextMenuAnchors,
    buttonItems: buttonItems,
  );
}

デフォルトのContextMenuのボタンの機能を拡張する

同様に、ContextMenuButtonTypeにあるボタンの機能を拡張することもできます。
例えば「コピー」ボタンを押した時に何らかのアクションを行いたい場合は以下のようにします。

custom_context_menu.dart
contextMenuBuilder: (context, editableTextState) {
  final buttonItems = editableTextState.contextMenuButtonItems;
  
  // コピーボタンのインデックスを検索
  final copyIndex = buttonItems.indexWhere(
    (item) => item.type == ContextMenuButtonType.copy,
  );
  
  if (copyIndex >= 0) {
    // 既存のコピーボタンを置き換え
    buttonItems[copyIndex] = ContextMenuButtonItem(
      onPressed: () {
        // 任意の処理を追加
        print('テキストがコピーされました');
        // 元の動作を実行
        editableTextState.copySelection(SelectionChangedCause.toolbar);
        editableTextState.hideToolbar();
      },
      type: ContextMenuButtonType.copy,
    );
  }
  return AdaptiveTextSelectionToolbar.buttonItems(
    anchors: editableTextState.contextMenuAnchors,
    buttonItems: buttonItems,
  );
}

プラットフォーム固有のContextMenuButton

任意の場所に独自のボタンを入れるセクションで

Androidではデフォルトで表示しないメニューもあるため注意が必要です。

と記載の通り、List<ContextMenuButtonItem>のそれぞれのボタンは利用可否が設定されています。

Flutter本体の実装packages/flutter/lib/src/widgets/editable_text.dartを確認してみると、それぞれ~Enabledで判定されています。

packages/flutter/lib/src/widgets/editable_text.dart
List<ContextMenuButtonItem> get contextMenuButtonItems {
    return buttonItemsForToolbarOptions() ??
          EditableText.getEditableButtonItems(
            clipboardStatus: clipboardStatus.value,
            onCopy: copyEnabled
                ? () => copySelection(SelectionChangedCause.toolbar)
                : null,
            onCut: cutEnabled
                ? () => cutSelection(SelectionChangedCause.toolbar)
                : null,
            onPaste: pasteEnabled
                ? () => pasteText(SelectionChangedCause.toolbar)
                : null,
            onSelectAll: selectAllEnabled
                ? () => selectAll(SelectionChangedCause.toolbar)
                : null,
            onLookUp: lookUpEnabled
                ? () => lookUpSelection(SelectionChangedCause.toolbar)
                : null,
            onSearchWeb: searchWebEnabled
                ? () => searchWebForSelection(SelectionChangedCause.toolbar)
                : null,
            onShare: shareEnabled
                ? () => shareSelection(SelectionChangedCause.toolbar)
                : null,
            onLiveTextInput: liveTextInputEnabled
                ? () => _startLiveTextInput(SelectionChangedCause.toolbar)
                : null,
          )
      ..addAll(_textProcessingActionButtonItems);
  }

例えば「ウェブを検索」が利用可能かどうかのsearchWebEnabledはiOS以外デフォルトでfalseを返しているため、Androidでは「ウェブを検索」は表示されません。
その理由は、onSearchWebで返しているsearchWebForSelectionがiOSでのみ利用可能なものであるためです。

packages/flutter/lib/src/widgets/editable_text.dart
@override
  bool get searchWebEnabled {
    if (defaultTargetPlatform != TargetPlatform.iOS) {
      return false;
    }

    return !widget.obscureText &&
        !textEditingValue.selection.isCollapsed &&
        textEditingValue.selection.textInside(textEditingValue.text).trim() !=
            '';
  }

  // 省略
  
  /// Launch a web search on the current selection,
  /// as in the "Search Web" edit menu button on iOS.
  ///
  /// Currently this is only implemented for iOS.
  ///
  /// When 'obscureText' is true or the selection is empty,
  /// this function will not do anything
  Future<void> searchWebForSelection(SelectionChangedCause cause) async {
    assert(!widget.obscureText);
    if (widget.obscureText) {
      return;
    }

    final String text = textEditingValue.selection.textInside(
      textEditingValue.text,
    );
    if (text.isNotEmpty) {
      await SystemChannels.platform.invokeMethod('SearchWeb.invoke', text);
    }
  }
  

ほかにも、「調べる」(lookUp)はiOS固有の機能のためAndroidでは使えません。「共有」(share)もiOS, Androidでのみ利用可能で、プラットフォームによる挙動差異に注意が必要です。

カスタマイズしたContextMenuがこちら🧑‍🍳

ここまで紹介したボタンの追加、削除、拡張を利用し、

  • 要約本文を閲覧できる画面で自分の学びや気づきをメモ・シェアすることのできる「学びメモ」にコピーした本文を引用して追記するための機能を追加する
  • 「すべて選択」機能を削除する
  • 「コピー」を押された際にアナリティクスイベントを送信する

を実装することができました🎉

プラットフォーム
iOS
Android -

ちなみに今回の実装をするまで、私の入社前から存在している「学びメモに追記」機能自体は独自のコンテキストメニューで提供していました。

要約内の難しい単語について調べたい!という声を聞き、ネイティブが用意している「調べる」や「ウェブを検索」を利用できれば解決できると思い実装しました。Androidでは調べる機能を利用できませんが、ネイティブに近い体験にすることができました🎉

動作確認で引用した要約はこちらです📖
https://www.flierinc.com/summary/4095

SystemContextMenuの存在

FlutterではAdaptiveTextSelectionToolbarというネイティブ風独自UIのコンテキストメニューを提供していますが、iOS16以上では、ネイティブのメニューを表示するSystemContextMenuを利用することもできます。
SystemContextMenuでも独自のボタンを追加することができますが、編集可能なテキストを扱うWidgetでのみ利用可能なことに注意してください。

この問題はFlutterのissue #169001にもあがっており、こちらのPull Requestで解決されたようですが、flutter 3.38.1-stableで動作確認してエラーになったため、実は未解決のようでした。stable v3.35.2で直っていないというコメントもついています。

おわりに

UIUX改善たのしいですね〜
これでiOSユーザーは難しい単語をいつでも調べることができます!ハッピー!
最後までお読みいただき、ありがとうございました。

参考記事

https://docs.flutter.dev/release/breaking-changes/context-menus#:~:text=Description of change,-%23&text=Context menus are now built,dart on GitHub.&text=A large number of examples,warning "Use contextMenuBuilder instead."

https://api.flutter.dev/flutter/material/AdaptiveTextSelectionToolbar-class.html

https://tech.youtrust.co.jp/entry/app-text-ux-improvements

https://iret.media/116593

株式会社フライヤー Tech Blog

Discussion