1. 什么是 ContextMenu 菜單
Context 菜單算是對彈出框的一個特性支持,特別對于桌面端來說,讓 右鍵彈出工具框 的處理更加簡便。比如下方所示,是 AndroidStudio 中右鍵時彈出的工具:

嚴(yán)格來說,ContextMenu 不是一個單獨(dú)的組件,而是一個彈出浮層菜單項小體系。對于移動端來說,輸入框 TextFiled 組件長按文字時彈出的工具菜單也屬于一種 ContextMenu :

從本質(zhì)上來說 ContextMenu 也不是什么新東西,只不過是對 Overlay 浮層的一層封裝而已。通過 ContextMenuController 控制器方便地添加和移除浮層。

這樣對于任何組件,都可以方便地彈出浮層菜單進(jìn)行操作:
2. 輸入框與 ContextMenu 菜單
在 Flutter 3.7 中 TextFiled 組件增加了 contextMenuBuilder 回調(diào)構(gòu)建方法。允許用戶自定義 彈出的工具菜單,這樣極大方便了文字選擇的可操作性。如下是官方的案例:
選擇文字中存在郵箱時,多添加一個 Send email 菜單。

可以按需構(gòu)建工具菜單,讓應(yīng)用在操作上更加靈活,比如可以添加保存、分享、搜索等按鈕。在桌面端中,右鍵可以彈出工具菜單欄:

從源碼中可以看出 TextFiled#contextMenuBuilder 構(gòu)造器是一個 EditableTextContextMenuBuilder 函數(shù)對象,返回 Widget 用于構(gòu)建菜單內(nèi)容?;卣{(diào)在有兩個入?yún)? context 和 editableTextState。

typedef EditableTextContextMenuBuilder = Widget Function(
BuildContext context,
EditableTextState editableTextState,
);
下面看一下官方輸入框彈出工具欄的代碼實(shí)現(xiàn), 下面代碼中核心在于 TextField 中增加了 contextMenuBuilder 回調(diào)用于構(gòu)建菜單組件:
class EmailButtonPage extends StatelessWidget {
EmailButtonPage({super.key});
final TextEditingController _controller = TextEditingController(
text: 'Select the email address and open the menu: me@example.com',
);
@override
Widget build(BuildContext context) {
return SizedBox(
width: 300.0,
child: TextField(
maxLines: 2,
controller: _controller,
contextMenuBuilder: _buildContextMenu,
),
);
}
在構(gòu)建邏輯中,通過 isValidEmail 校驗選中的文本是否包含郵箱,如果包含則在 buttonItems 的首位添加 Send email 的按鈕:
Widget _buildContextMenu(BuildContext context,EditableTextState state){
final TextEditingValue value = state.textEditingValue;
final List<ContextMenuButtonItem> buttonItems = state.contextMenuButtonItems;
String selectValue = value.selection.textInside(value.text);
if (isValidEmail(selectValue)) {
buttonItems.insert(0,
ContextMenuButtonItem(
label: 'Send email',
onPressed: () =>onSendEmail(selectValue),
));
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: state.contextMenuAnchors,
buttonItems: buttonItems,
);
}
/// Returns true if the given String is a valid email address.
bool isValidEmail(String text) {
return RegExp(
r'(?<name>[a-zA-Z0-9]+)'
r'@'
r'(?<domain>[a-zA-Z0-9]+)'
r'.'
r'(?<topLevelDomain>[a-zA-Z0-9]+)',
).hasMatch(text);
}
3. 輸入框默認(rèn)菜單源碼簡看
通過調(diào)試不難發(fā)現(xiàn),當(dāng)有文字選中時, EditableTextState 的 contextMenuButtonItems 是四個值,此時按鈕條目分別是剪切、拷貝、粘貼、全選:

也就是說,這個幾個工具是 Flutter 源碼中默認(rèn)提供的,可以簡單瞄一下其中的邏輯。如下所示,是 EditableTextState 獲取 contextMenuButtonItems 的邏輯。很容易可以看出,它會根據(jù)輸入框狀態(tài)信息,提供不同的菜單按鈕。
其中 buttonItemsForToolbarOptions 是根據(jù) toolbarOptions 成員構(gòu)建菜單的方法,不過隨著 contextMenuBuilder 的支持,這個屬性已經(jīng)過時了,也不建議使用。所以這里的默認(rèn)菜單項是由 EditableText#getEditableButtonItems 靜態(tài)方法創(chuàng)建的:

創(chuàng)建的邏輯也很簡單,根據(jù)回調(diào)是否為空,在返回的 ContextMenuButtonItem 中添加對應(yīng)類型的菜單項:

另外,從源碼中還能學(xué)到一些小東西的處理邏輯,比如如何復(fù)制粘貼,如何剪切和全選內(nèi)容。下面來稍微瞄一眼,復(fù)制方法通過 Clipboard.setData 靜態(tài)方法,傳入 ClipboardData 數(shù)據(jù):

粘貼使用 Clipboard.getData 靜態(tài)方法:

剪切和復(fù)制類似,都是通過 Clipboard.setData 將字符數(shù)據(jù)放入剪切板。只不過需要將選擇的文字移除,使用如下的 _replaceText 方法處理:

最后,全選通過更新 textEditingValue 的 selection 配置實(shí)現(xiàn),從 0 開始到字符串長度為止,表示全選。

4. 認(rèn)識一下 AdaptiveTextSelectionToolbar 組件
嚴(yán)格來說 ContextMenuButtonItem 只是一個配置數(shù)據(jù),并非 Widget 組件。

這里浮層菜單工具的界面是由 AdaptiveTextSelectionToolbar 組件決定的,ContextMenuButtonItem 只是其中的數(shù)據(jù)項。從上面可以看出,不同平臺有不同的菜單界面。比如 Android 中是橫排,Windows 中是豎排:
| Android 中 | Windows 中 |
|---|---|
![]() |
![]() |
這就表示,在 AdaptiveTextSelectionToolbar 組件的 build 構(gòu)建邏輯中,必然會對不同平臺進(jìn)行區(qū)分對待。如下是其構(gòu)建邏輯的源碼,確實(shí)如此,分為四種工具欄組件,根據(jù)不同平臺進(jìn)行構(gòu)建。這也是平臺間組件適配的常見方式。

另外可以看出 getAdaptiveButtons 靜態(tài)方法會將ContextMenuButtonItem 列表 buttonItems 數(shù)據(jù),轉(zhuǎn)化成 Widget 組件列表。其中,也是根據(jù)不同平臺組件,映射出不同的組件列表:

到這里可以知道 AdaptiveTextSelectionToolbar 只是一個簡單的適配,并不能靈活自定義菜單項的展示效果。這感覺還是有些遺憾的,雖然能用,但不是太好用。如果在需求中期望自定義菜單項,比如圖標(biāo)、快捷鍵說明、分割線、激活效果等,可以根據(jù) AdaptiveTextSelectionToolbar 來自己寫個組件來處理:

5. 自定義 ContextMenu 菜單: ContextMenuController
上面展示浮層菜單是 TextFiled 組件內(nèi)部提供的 contextMenuBuilder 回調(diào),那如何讓 任何組件 都支持浮層菜單呢?Flutter 中提供了 ContextMenuController 控制器來管理,下面先通過圖片的浮層菜單來認(rèn)識一下控制器的使用:
首先,浮層的顯示/消失是手勢事件觸發(fā)的,對于桌面端來說 GestureDetector 的 onSecondaryTapUp 可以監(jiān)聽鼠標(biāo)的點(diǎn)擊事件。也就是說,在 _onSecondaryTapUp 中通過 _contextMenuController 顯示浮層:
class ImageContextMenu extends StatefulWidget {
const ImageContextMenu({Key? key}) : super(key: key);
@override
State<ImageContextMenu> createState() => _ImageContextMenuState();
}
class _ImageContextMenuState extends State<ImageContextMenu> {
final ContextMenuController _contextMenuController = ContextMenuController();
@override
Widget build(BuildContext context) {
return GestureDetector(
onSecondaryTapUp: _onSecondaryTapUp,
onTap: _onTap,
child: Image.asset(
'assets/images/sabar.webp',
height: 400,
),
);
}
浮層的顯示核心是 _contextMenuController.show 方法,其中需要傳入 contextMenuBuilder 回調(diào)構(gòu)建組件進(jìn)行顯示。菜單組件的構(gòu)建依然通過 AdaptiveTextSelectionToolbar 來完成,其中 anchors 作為錨點(diǎn)確定浮層的位置。
void _onSecondaryTapUp(TapUpDetails details) {
_show(details.globalPosition);
}
void _show(Offset position) {
_contextMenuController.show(
context: context,
contextMenuBuilder: (ctx) => _buildContent(ctx, position),
);
}
Widget _buildContent(BuildContext context, Offset offset) {
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: TextSelectionToolbarAnchors(
primaryAnchor: offset,
),
buttonItems: ['保存圖片','分享圖片','編輯圖片'].map((label) => ContextMenuButtonItem(
onPressed: () {
ContextMenuController.removeAny();
},
label: label,
)).toList()
);
}
浮層的消失通過 _contextMenuController.remove 即可:
void _onTap() {
if (!_contextMenuController.isShown) {
return;
}
_hide();
}
void _hide() {
_contextMenuController.remove();
}
這就是一個最簡單的通過 ContextMenuController 展示/隱藏浮層菜單的使用方式。對于移動端來說,可以監(jiān)聽長按事件來彈出菜單。菜單隨手勢的行為邏輯是基本上固定的,不同使用場景中只是菜單內(nèi)容組件的差異,所以可以封裝一個組件處理行為邏輯,讓外界提供菜單界面的組件構(gòu)建。
其實(shí)這和 TextFiled 的 contextMenuBuilder 是異曲同工的,官方在案例中給出了 context_menu_region 進(jìn)行簡單封裝,來簡化使用。如下所示,直接使用 ContextMenuRegion 進(jìn)行處理,通過 contextMenuBuilder 回調(diào)讓使用者提供組件。也能完成相同的功能:

class ImageContextMenuV2 extends StatelessWidget{
const ImageContextMenuV2({super.key});
@override
Widget build(BuildContext context) {
return ContextMenuRegion(
contextMenuBuilder: _buildContent,
child: Image.asset(
'assets/images/sabar.webp',
height: 400,
),
);
}
Widget _buildContent(BuildContext context, Offset offset) {
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: TextSelectionToolbarAnchors(
primaryAnchor: offset,
),
buttonItems: ['保存圖片','分享圖片','編輯圖片'].map((label) => ContextMenuButtonItem(
onPressed: () {
ContextMenuController.removeAny();
},
label: label,
)).toList()
);
}
}
另外注意一點(diǎn),目前 ContextMenuRegion 并非 Flutter 原生組件,是自定義封裝的,代碼見文尾。后面可以研究一下 AdaptiveTextSelectionToolbar 組件不同平臺的具體組件實(shí)現(xiàn)細(xì)節(jié),來自定義一些樣式。那本文就到這里,謝謝觀看 ~
typedef ContextMenuBuilder = Widget Function(
BuildContext context, Offset offset);
/// Shows and hides the context menu based on user gestures.
///
/// By default, shows the menu on right clicks and long presses.
class ContextMenuRegion extends StatefulWidget {
/// Creates an instance of [ContextMenuRegion].
const ContextMenuRegion({
super.key,
required this.child,
required this.contextMenuBuilder,
});
/// Builds the context menu.
final ContextMenuBuilder contextMenuBuilder;
/// The child widget that will be listened to for gestures.
final Widget child;
@override
State<ContextMenuRegion> createState() => _ContextMenuRegionState();
}
class _ContextMenuRegionState extends State<ContextMenuRegion> {
Offset? _longPressOffset;
final ContextMenuController _contextMenuController = ContextMenuController();
static bool get _longPressEnabled {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
return true;
case TargetPlatform.macOS:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
return false;
}
}
void _onSecondaryTapUp(TapUpDetails details) {
_show(details.globalPosition);
}
void _onTap() {
if (!_contextMenuController.isShown) {
return;
}
_hide();
}
void _onLongPressStart(LongPressStartDetails details) {
_longPressOffset = details.globalPosition;
}
void _onLongPress() {
assert(_longPressOffset != null);
_show(_longPressOffset!);
_longPressOffset = null;
}
void _show(Offset position) {
_contextMenuController.show(
context: context,
contextMenuBuilder: (context) {
return widget.contextMenuBuilder(context, position);
},
);
}
void _hide() {
_contextMenuController.remove();
}
@override
void dispose() {
_hide();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onSecondaryTapUp: _onSecondaryTapUp,
onTap: _onTap,
onLongPress: _longPressEnabled ? _onLongPress : null,
onLongPressStart: _longPressEnabled ? _onLongPressStart : null,
child: widget.child,
);
}
}
作者:張風(fēng)捷特烈
鏈接:https://juejin.cn/post/7193504151467196472

