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 功能。 那本文就到这里,谢谢观看 ~

相关推荐
豆 腐24 分钟前
MySQL【四】
android·数据库·笔记·mysql
想取一个与众不同的名字好难2 小时前
android studio导入OpenCv并改造成.kts版本
android·ide·android studio
Jewel1053 小时前
Flutter代码混淆
android·flutter·ios
Yawesh_best4 小时前
MySQL(5)【数据类型 —— 字符串类型】
android·mysql·adb
曾经的三心草7 小时前
Mysql之约束与事件
android·数据库·mysql·事件·约束
guoruijun_2012_411 小时前
fastadmin多个表crud连表操作步骤
android·java·开发语言
Winston Wood11 小时前
一文了解Android中的AudioFlinger
android·音频
一头小火烧12 小时前
flutter打包签名问题
flutter
sunly_12 小时前
Flutter:异步多线程结合
flutter
AiFlutter12 小时前
Flutter网络通信-封装Dio
flutter