Flutter 组件集录 | MenuAnchor 与多级菜单

前言

多级菜单在桌面端应用中非常常见,是很多应用程序中不可缺少的一环。它的价值在于:

将大量的交互操作事件进行归类,

通过弹框的形式,以极小的空间占用,实现大量功能。

那 Flutter 既然支持桌面端,那自然少不了对多级菜单的支持,菜单按钮的事件也往往伴随着快捷键的使用。本文就来介绍一下基于 MenuAnchor 组件,如何实现弹出多级菜单,以及快捷键的使用:


MenuAnchor 是一个 Flutter 内置的 StatefulWidget,它可以将子组件视为 "锚点",以锚点为基础展开浮层菜单。显示显示

先通过一个最简单的案例了解一下 MenuAnchor 组件的使用。下面点击 文件 区域时,通过 MenuAnchor 在下方展示 新建打开 两个按钮:

MenuAnchor 组件最重要的是两个参数:

  • builder 回调中构建展示的按钮视图,也就是上面的 文件 按钮。
  • menuChildren 是组件列表,是弹出菜单的展示内容。
dart 复制代码
@override
Widget build(BuildContext context) {
  return Center(
    child: MenuAnchor(
      builder: _buildView,
      menuChildren: _buildMenus,
    ),
  );
}

其中 builder 回调中可以访问 MenuController对象,可以用于打开和关闭菜单。其中返回的组件可以自定义构建,此处是一个蓝框加上文字:

dart 复制代码
Widget _buildView(_, MenuController controller, Widget? child) {
  return GestureDetector(
      onTap: controller.open,
      child: ColoredBox(
        color: Colors.blue,
        child: Padding(
          padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
          child: Text(
            "文件",
            style: TextStyle(color: Colors.white),
          ),
        ),
      ));
}

展开的菜单面板可以是任何组件列表,Flutter 中提供了 MenuItemButton 组件,便于构建菜单按钮。这里展示了新建打开 两个按钮,并在对应的 onPressed 回调中打印信息。此时点击菜单条目时,菜单会隐藏,并且触发点击事件:

dart 复制代码
List<Widget> get _buildMenus => [
      MenuItemButton(
        child: Text('新建'),
        onPressed: () {
          print("======新建==========");
        },
      ),
      MenuItemButton(
        child: Text('打开'),
        onPressed: () {
          print("======打开==========");
        },
      ),
    ];

在菜单条目列表中,可以通过 SubmenuButton 容纳多个子菜单项,效果如下:

dart 复制代码
SubmenuButton(
  menuChildren: [
    MenuItemButton(
      child: Text('导出 PNG'),
      onPressed: () {
        print("======导出 PNG==========");
      },
    ),
    MenuItemButton(
      child: Text('导出 SVG'),
      onPressed: () {
        print("======导出 SVG==========");
      },
    ),
  ],
  child: Text("导出"),
)

MenuItemButton 在构造函数中可以传入 shortcut 参数设置菜单项的快捷键。

如下所示,为打开菜单条目设置 Ctrl+O 快捷键,指定 SingleActivator 对象进行配置。MenuItemButton 在设置快捷键后会在右侧展示:

dart 复制代码
MenuItemButton(
  child: Text('打开'),
  shortcut: SingleActivator(LogicalKeyboardKey.keyO, control: true),
  onPressed: () {
    print("======打开==========");
  },
),

只是在 MenuItemButton 声明使用了该快捷键,并不能使快捷键生效。需要在通过 ShortcutRegistry 来注册快捷键和事件的映射关系。如下所示,在状态类的 didChangeDependencies 回调中调用 _shortcutRegistry 进行注册:

其中 key 值是 SingleActivator 对象,也就是快捷键的信息描述,值是 Intent 表示触发的事件,这里设置为 VoidCallbackIntent 表示无参数的回调事件。此时只要按下 Ctrl+O 就可以触发其中的回调:

dart 复制代码
ShortcutRegistryEntry? _shortcutsEntry;
@override
void didChangeDependencies() {
  super.didChangeDependencies();
  _shortcutRegistry();
}

void _shortcutRegistry() {
  _shortcutsEntry?.dispose();
  final Map<ShortcutActivator, Intent> shortcuts = {};
  shortcuts[SingleActivator(LogicalKeyboardKey.keyO, control: true)] =  VoidCallbackIntent((){
    print("打开事件---快捷键");
  });
  _shortcutsEntry = ShortcutRegistry.of(context).addAll(shortcuts);
}

4. 封装按钮入口节点

如果按照普通的方式来写堆砌菜单按钮,那么随着菜单增加,代码将会非常复杂。并且每个按钮处理自己的事件,非常零散。而且注册快捷键的代码和按钮的回调相对割裂。

pix_editor 项目中,将每个菜单项封装为 MenuEntry 对象,其中

  • 可以包含若干个节点,也就是说将其定义为树形结构。
  • 每个菜单节点可以指定快捷键以及 MenuAction 事件类型
dart 复制代码
class MenuEntry {
  const MenuEntry({
    required this.label,
    this.action,
    this.tail,
    this.shortcut,
    this.menuChildren,
  });
  
final String label;
final String? tail;
final MenuAction? action;
final MenuSerializableShortcut? shortcut;
final List<MenuEntry>? menuChildren;

MenuAction 枚举表示菜单的动作事件,便于统一由外界根据菜单的事件类型,处理回调事件:

dart 复制代码
enum MenuAction{
  newFile,
  openFile,
  importFile,
  saveFile,
  outputFilePng,
  outputFileJpg,
  outputFileSvg,
  back,
  undo,
  copy,
  past,
  clear,
}

菜单栏封装为 AppToolMenuBar,将菜单的点击事件回调给外界:


如下所示在代码中,菜单树的数据将通过 MenuEntry 列表来维护,只要在其中配置菜单按钮的信息即可。 接下来,定义 buildByMenuEntryList 方法,解析 MenuEntry 列表,构建对应的菜单项;其中传入 ValueChanged<MenuAction?> 方法除了按钮点击事件:

dart 复制代码
List<Widget> buildByMenuEntryList(List<MenuEntry> selections, ValueChanged<MenuAction?> onTapMenu) {
  Widget buildSelection(MenuEntry selection) {
    Widget child = Text(selection.label);
    if (selection.tail != null) {
      child =  Row(
        children: [
          child,
          const SizedBox(width: 20),
          Text(
            selection.tail!,
            style: const TextStyle(fontSize: 12, color: Colors.grey),
          ),
        ],
      );
    }
    if (selection.menuChildren != null) {
      return SubmenuButton(
        menuChildren: MenuEntry.build(selection.menuChildren!, onTapMenu),
        child: child,
      );
    }
    return MenuItemButton(
      shortcut: selection.shortcut,
      onPressed: () => onTapMenu(selection.action),
      child: child,
    );
  }
  return selections.map<Widget>(buildSelection).toList();
}

对于快捷键来说,也可以根据 MenuEntry 列表数据,解析生成快捷键和事件的映射关系。其中传入 ValueChanged<MenuAction?> 方法处理快捷键事件:

dart 复制代码
Map<MenuSerializableShortcut, Intent> shortcutsByMenuEntryList(
    List<MenuEntry> selections, ValueChanged<MenuAction?> onTap) {
  final Map<MenuSerializableShortcut, Intent> result =
      <MenuSerializableShortcut, Intent>{};
  for (final MenuEntry selection in selections) {
    if (selection.menuChildren != null) {
      result.addAll(shortcutsByMenuEntryList(selection.menuChildren!, onTap));
    } else {
      if (selection.shortcut != null) {
        result[selection.shortcut!] =
            VoidCallbackIntent(() => onTap(selection.action));
      }
    }
  }
  return result;
}

这样就能完成快捷键事件和按钮点击事件的统一处理:

dart 复制代码
void _onTapMenu(BuildContext context, MenuAction? value) async {
  /// TODO 处理菜单事件、快捷键事件
  if (value == MenuAction.importFile) {
    _handleImportImage(context);
  }
}

5. 小结

总的来看,MenuAnchor 组件是一个很强大的组件,它可以让以任意组件为锚点,弹出菜单栏。并且子组件和菜单组件都有非常大的定制空间,灵活性非常高。另外 MenuAnchor 还有其他属性:

  • 默认情况下,菜单栏将锚点组件的左下角对齐,可以通过 alignmentOffset 设置偏移量。
  • onOpenonClose 方法可以监听打开和关闭浮层的事件:

如果不喜欢 Flutter 提供的 MenuItemButton 样式,可以通过主题的 menuButtonTheme 进行修改。甚至是自己定义组件来实现 MenuItemButton 功能。 那本文就到这里,谢谢观看 ~

相关推荐
alexhilton33 分钟前
玩转Shader之学会如何变形画布
android·kotlin·android jetpack
whysqwhw5 小时前
安卓图片性能优化技巧
android
风往哪边走5 小时前
自定义底部筛选弹框
android
江上清风山间明月5 小时前
Flutter AlwaysScrollableScrollPhysics详解
flutter·滚动·scrollable·scrollphysics
Yyyy4826 小时前
MyCAT基础概念
android
Android轮子哥6 小时前
尝试解决 Android 适配最后一公里
android
普罗米拉稀6 小时前
Flutter 复用艺术:Mixin 与 Abstract 的架构哲学与线性化解密
flutter·ios·面试
雨白7 小时前
OkHttp 源码解析:enqueue 非同步流程与 Dispatcher 调度
android
风往哪边走8 小时前
自定义仿日历组件弹框
android
没有了遇见8 小时前
Android 外接 U 盘开发实战:从权限到文件复制
android