Flutter 組件集錄 | 3.7 新增 - ContextMenu 菜單

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)? contexteditableTextState惰蜜。

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í)鼻听, EditableTextStatecontextMenuButtonItems 是四個(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ò)更新 textEditingValueselection 配置實(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ō) GestureDetectoronSecondaryTapUp 可以監(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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末睁枕,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子沸手,更是在濱河造成了極大的恐慌外遇,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,188評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件契吉,死亡現(xiàn)場(chǎng)離奇詭異跳仿,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)捐晶,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,464評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門菲语,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人惑灵,你說(shuō)我怎么就攤上這事山上。” “怎么了英支?”我有些...
    開封第一講書人閱讀 165,562評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵胶哲,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我潭辈,道長(zhǎng)鸯屿,這世上最難降的妖魔是什么澈吨? 我笑而不...
    開封第一講書人閱讀 58,893評(píng)論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮寄摆,結(jié)果婚禮上谅辣,老公的妹妹穿的比我還像新娘。我一直安慰自己婶恼,他們只是感情好桑阶,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,917評(píng)論 6 392
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著勾邦,像睡著了一般蚣录。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上眷篇,一...
    開封第一講書人閱讀 51,708評(píng)論 1 305
  • 那天萎河,我揣著相機(jī)與錄音,去河邊找鬼蕉饼。 笑死虐杯,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的昧港。 我是一名探鬼主播擎椰,決...
    沈念sama閱讀 40,430評(píng)論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼创肥!你這毒婦竟也來(lái)了达舒?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,342評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤叹侄,失蹤者是張志新(化名)和其女友劉穎巩搏,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體圈膏,經(jīng)...
    沈念sama閱讀 45,801評(píng)論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡塔猾,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,976評(píng)論 3 337
  • 正文 我和宋清朗相戀三年篙骡,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了稽坤。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,115評(píng)論 1 351
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡糯俗,死狀恐怖尿褪,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情得湘,我是刑警寧澤杖玲,帶...
    沈念sama閱讀 35,804評(píng)論 5 346
  • 正文 年R本政府宣布,位于F島的核電站淘正,受9級(jí)特大地震影響摆马,放射性物質(zhì)發(fā)生泄漏臼闻。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,458評(píng)論 3 331
  • 文/蒙蒙 一囤采、第九天 我趴在偏房一處隱蔽的房頂上張望述呐。 院中可真熱鬧,春花似錦蕉毯、人聲如沸乓搬。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,008評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)进肯。三九已至,卻和暖如春棉磨,著一層夾襖步出監(jiān)牢的瞬間江掩,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,135評(píng)論 1 272
  • 我被黑心中介騙來(lái)泰國(guó)打工含蓉, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留频敛,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 48,365評(píng)論 3 373
  • 正文 我出身青樓馅扣,卻偏偏與公主長(zhǎng)得像斟赚,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子差油,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,055評(píng)論 2 355

推薦閱讀更多精彩內(nèi)容