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

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)? 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)建邏輯中,通過 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)有文字選中時, EditableTextStatecontextMenuButtonItems 是四個值,此時按鈕條目分別是剪切、拷貝、粘貼、全選:

也就是說,這個幾個工具是 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 方法處理:

最后,全選通過更新 textEditingValueselection 配置實(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ā)的,對于桌面端來說 GestureDetectoronSecondaryTapUp 可以監(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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容