1. 什么是 ContextMenu 菜單
Context 菜單算是對(duì)彈出框的一個(gè)特性支持报账,特別對(duì)于桌面端來(lái)說(shuō)悬包,讓 右鍵彈出工具框 的處理更加簡(jiǎn)便医咨。比如下方所示,是 AndroidStudio 中右鍵時(shí)彈出的工具:
嚴(yán)格來(lái)說(shuō)狭握,ContextMenu
不是一個(gè)單獨(dú)的組件,而是一個(gè)彈出浮層菜單項(xiàng)小體系疯溺。對(duì)于移動(dòng)端來(lái)說(shuō)论颅,輸入框 TextFiled 組件長(zhǎng)按文字時(shí)彈出的工具菜單也屬于一種 ContextMenu :
從本質(zhì)上來(lái)說(shuō) ContextMenu 也不是什么新東西,只不過(guò)是對(duì) Overlay
浮層的一層封裝而已囱嫩。通過(guò) ContextMenuController
控制器方便地添加和移除浮層恃疯。
這樣對(duì)于任何組件,都可以方便地彈出浮層菜單進(jìn)行操作:
2. 輸入框與 ContextMenu 菜單
在 Flutter 3.7 中 TextFiled 組件增加了 contextMenuBuilder
回調(diào)構(gòu)建方法墨闲。允許用戶自定義 彈出的工具菜單
今妄,這樣極大方便了文字選擇的可操作性。如下是官方的案例:
選擇文字中存在郵箱時(shí)鸳碧,多添加一個(gè) Send email 菜單盾鳞。
可以按需構(gòu)建工具菜單,讓應(yīng)用在操作上更加靈活瞻离,比如可以添加保存腾仅、分享、搜索等按鈕套利。在桌面端中推励,右鍵可以彈出工具菜單欄:
從源碼中可以看出 TextFiled#contextMenuBuilder
構(gòu)造器是一個(gè) EditableTextContextMenuBuilder
函數(shù)對(duì)象鹤耍,返回 Widget 用于構(gòu)建菜單內(nèi)容〈低В回調(diào)在有兩個(gè)入?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)建邏輯中受神,通過(guò) isValidEmail
校驗(yàn)選中的文本是否包含郵箱抛猖,如果包含則在 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)菜單源碼簡(jiǎn)看
通過(guò)調(diào)試不難發(fā)現(xiàn),當(dāng)有文字選中時(shí)鼻听, EditableTextState
的 contextMenuButtonItems
是四個(gè)值财著,此時(shí)按鈕條目分別是剪切、拷貝撑碴、粘貼撑教、全選:
也就是說(shuō),這個(gè)幾個(gè)工具是 Flutter 源碼中默認(rèn)提供的醉拓,可以簡(jiǎn)單瞄一下其中的邏輯伟姐。如下所示,是 EditableTextState
獲取 contextMenuButtonItems
的邏輯亿卤。很容易可以看出愤兵,它會(huì)根據(jù)輸入框狀態(tài)信息,提供不同的菜單按鈕排吴。
其中 buttonItemsForToolbarOptions
是根據(jù) toolbarOptions
成員構(gòu)建菜單的方法秆乳,不過(guò)隨著 contextMenuBuilder
的支持,這個(gè)屬性已經(jīng)過(guò)時(shí)了钻哩,也不建議使用屹堰。所以這里的默認(rèn)菜單項(xiàng)是由 EditableText#getEditableButtonItems
靜態(tài)方法創(chuàng)建的:
創(chuàng)建的邏輯也很簡(jiǎn)單,根據(jù)回調(diào)是否為空街氢,在返回的 ContextMenuButtonItem
中添加對(duì)應(yīng)類型的菜單項(xiàng):
另外扯键,從源碼中還能學(xué)到一些小東西的處理邏輯,比如如何復(fù)制粘貼珊肃,如何剪切和全選內(nèi)容忧陪。下面來(lái)稍微瞄一眼,復(fù)制方法通過(guò) Clipboard.setData
靜態(tài)方法近范,傳入 ClipboardData
數(shù)據(jù):
粘貼使用 Clipboard.getData
靜態(tài)方法:
剪切和復(fù)制類似嘶摊,都是通過(guò) Clipboard.setData
將字符數(shù)據(jù)放入剪切板。只不過(guò)需要將選擇的文字移除评矩,使用如下的 _replaceText
方法處理:
最后叶堆,全選通過(guò)更新 textEditingValue
的 selection
配置實(shí)現(xiàn),從 0 開始到字符串長(zhǎng)度為止斥杜,表示全選虱颗。
4. 認(rèn)識(shí)一下 AdaptiveTextSelectionToolbar 組件
嚴(yán)格來(lái)說(shuō) ContextMenuButtonItem
只是一個(gè)配置數(shù)據(jù)沥匈,并非 Widget 組件。
這里浮層菜單工具的界面是由 AdaptiveTextSelectionToolbar
組件決定的忘渔,ContextMenuButtonItem
只是其中的數(shù)據(jù)項(xiàng)高帖。從上面可以看出,不同平臺(tái)有不同的菜單界面畦粮。比如 Android 中是橫排散址,Windows 中是豎排:
Android 中 | Windows 中 |
---|---|
這就表示,在 AdaptiveTextSelectionToolbar
組件的 build
構(gòu)建邏輯中宣赔,必然會(huì)對(duì)不同平臺(tái)進(jìn)行區(qū)分對(duì)待预麸。如下是其構(gòu)建邏輯的源碼,確實(shí)如此儒将,分為四種工具欄組件吏祸,根據(jù)不同平臺(tái)進(jìn)行構(gòu)建。這也是平臺(tái)間組件適配的常見方式钩蚊。
另外可以看出 getAdaptiveButtons
靜態(tài)方法會(huì)將ContextMenuButtonItem
列表 buttonItems
數(shù)據(jù)贡翘,轉(zhuǎn)化成 Widget 組件列表。其中砰逻,也是根據(jù)不同平臺(tái)組件鸣驱,映射出不同的組件列表:
到這里可以知道 AdaptiveTextSelectionToolbar
只是一個(gè)簡(jiǎn)單的適配,并不能靈活自定義菜單項(xiàng)的展示效果诱渤。這感覺還是有些遺憾的,雖然能用谈况,但不是太好用勺美。如果在需求中期望自定義菜單項(xiàng),比如圖標(biāo)碑韵、快捷鍵說(shuō)明赡茸、分割線、激活效果等祝闻,可以根據(jù) AdaptiveTextSelectionToolbar
來(lái)自己寫個(gè)組件來(lái)處理:
5. 自定義 ContextMenu 菜單: ContextMenuController
上面展示浮層菜單是 TextFiled 組件內(nèi)部提供的 contextMenuBuilder
回調(diào)占卧,那如何讓 任何組件 都支持浮層菜單呢?Flutter 中提供了 ContextMenuController 控制器來(lái)管理联喘,下面先通過(guò)圖片的浮層菜單來(lái)認(rèn)識(shí)一下控制器的使用:
首先华蜒,浮層的顯示/消失是手勢(shì)事件觸發(fā)的,對(duì)于桌面端來(lái)說(shuō) GestureDetector
的 onSecondaryTapUp
可以監(jiān)聽鼠標(biāo)的點(diǎn)擊事件豁遭。也就是說(shuō)叭喜,在 _onSecondaryTapUp
中通過(guò) _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)建依然通過(guò) AdaptiveTextSelectionToolbar
來(lái)完成捂蕴,其中 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()
);
}
浮層的消失通過(guò) _contextMenuController.remove
即可:
void _onTap() {
if (!_contextMenuController.isShown) {
return;
}
_hide();
}
void _hide() {
_contextMenuController.remove();
}
這就是一個(gè)最簡(jiǎn)單的通過(guò) ContextMenuController
展示/隱藏浮層菜單的使用方式。對(duì)于移動(dòng)端來(lái)說(shuō)啥辨,可以監(jiān)聽長(zhǎng)按事件來(lái)彈出菜單涡匀。菜單隨手勢(shì)的行為邏輯是基本上固定的,不同使用場(chǎng)景中只是菜單內(nèi)容組件的差異溉知,所以可以封裝一個(gè)組件處理行為邏輯陨瘩,讓外界提供菜單界面的組件構(gòu)建。
其實(shí)這和 TextFiled 的 contextMenuBuilder
是異曲同工的着倾,官方在案例中給出了 context_menu_region
進(jìn)行簡(jiǎn)單封裝拾酝,來(lái)簡(jiǎn)化使用。如下所示卡者,直接使用 ContextMenuRegion
進(jìn)行處理蒿囤,通過(guò) 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 組件不同平臺(tái)的具體組件實(shí)現(xiàn)細(xì)節(jié)脸侥,來(lái)自定義一些樣式。那本文就到這里盈厘,謝謝觀看 ~
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