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

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

contextMenuBuilderとは
contextMenuBuilderは、TextFieldなどのWidgetでコンテキストメニューの中身を構築するためのコールバック関数です。 この関数の中でAdaptiveTextSelectionToolbarを返すことで、プラットフォーム(iOS/Android)に応じた適切なデザインのメニューを表示できます。コンテキストメニューを表示できる以下のWidgetで利用できます。
EditableTextTextFieldCupertinoTextFieldSelectionAreaSelectableText
packages/flutter/lib/src/material/adaptive_text_selection_toolbar.dart#L24
独自のボタンをContextMenuに追加する
contextMenuBuilderを使ってカスタマイズします。
AdaptiveTextSelectionToolbarが提供するコンテキストメニューのボタンリストList<ContextMenuButtonItem>を取得し、そのリストに独自のボタンを追加することで実現できます。
コンテキストメニューの最後に独自のボタンを追加する
buttonItems.addを使うことで、コンテキストメニューの最後にボタンが追加されます。
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を使うことで、任意の場所にボタンを入れることができます。
例えば、「コピー」の後ろに入れたい時は以下のようにします。
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ではデフォルトで表示しないメニューもあるため注意が必要です。
デフォルトのContextMenuのボタンを削除する
AdaptiveTextSelectionToolbarが提供するContextMenuButtonTypeにあるボタンを削除することができます。
例えば「すべて選択」を削除したい場合、以下のようにして指定したContextMenuButtonTypeのボタンを削除できます。
contextMenuBuilder: (context, editableTextState) {
final buttonItems = editableTextState.contextMenuButtonItems;
// 「すべて選択」ボタンを削除
buttonItems.removeWhere(
(item) => item.type == ContextMenuButtonType.selectAll,
);
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: buttonItems,
);
}
デフォルトのContextMenuのボタンの機能を拡張する
同様に、ContextMenuButtonTypeにあるボタンの機能を拡張することもできます。
例えば「コピー」ボタンを押した時に何らかのアクションを行いたい場合は以下のようにします。
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で判定されています。
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でのみ利用可能なものであるためです。
@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では調べる機能を利用できませんが、ネイティブに近い体験にすることができました🎉
動作確認で引用した要約はこちらです📖
SystemContextMenuの存在
FlutterではAdaptiveTextSelectionToolbarというネイティブ風独自UIのコンテキストメニューを提供していますが、iOS16以上では、ネイティブのメニューを表示するSystemContextMenuを利用することもできます。
SystemContextMenuでも独自のボタンを追加することができますが、編集可能なテキストを扱うWidgetでのみ利用可能なことに注意してください。

この問題はFlutterのissue #169001にもあがっており、こちらのPull Requestで解決されたようですが、flutter 3.38.1-stableで動作確認してエラーになったため、実は未解決のようでした。stable v3.35.2で直っていないというコメントもついています。
おわりに
UIUX改善たのしいですね〜
これでiOSユーザーは難しい単語をいつでも調べることができます!ハッピー!
最後までお読みいただき、ありがとうございました。
参考記事



Discussion