Flutter PopupMenu For Android Habiter

156 阅读2分钟

PopupMenu

基于 Flutter 的 PopupMenuButton 封装,更加易用。

如果你习惯了 Android 开发时的体验,喜欢 Material 的设计,那么可以用这个封装好的 PopupMenu,尽可能还原了 Android Material 的使用习惯。

如果你有更好的建议或实现方式,或者觉得目前的实现有问题,不妨在评论区留下你的意见~

import 'package:flutter/material.dart';

/// Popup Menu
class PopupMenu extends StatefulWidget {
  const PopupMenu({super.key, required this.onSelected, required this.items});

  final Function(MenuItem) onSelected;
  final List<MenuItem> items;

  @override
  State<PopupMenu> createState() => _PopupMenuState();
}

class _PopupMenuState extends State<PopupMenu> {
  @override
  void initState() {
    super.initState();
    _popupWindowCount = 0;
  }

  @override
  Widget build(BuildContext context) {
    return _buildMainMenu(items: widget.items);
  }

  /// 构建 PopupMenuItem
  AppPopupItem<MenuItem> _buildPopupMenuItem({required MenuItem value, required Widget child}) {
    return AppPopupItem(
      // 取消内部设置的 padding,外部自己设置
      // 这么做是因为 PopupMenuItem 中嵌套 PopupMenuButton 时,内部的 Padding 会导致无法触发显示 submenu。
      padding: EdgeInsets.zero,
      height: 0,
      value: value,
      child: child,
      onTap: () {
        // 这个和 PopupMenuButton 的 onSelected 不冲突,两个都设置都会回调;先回调 onTap 再回调 onSelected。
        if (value.checked != null) {
          // 如果是 checkable 的 MenuItem,则在这里同时更新状态
          value.checked = !value.checked!;
        }

        value.onTap?.call();
      },
    );
  }

  static const double popupMenuMinWidth = 120;

  // 构建 PopuMenuItem 显示内容
  Widget _buildMenuItemContent({required Widget child}) {
    return Padding(
      padding: const EdgeInsets.only(left: 12, right: 12, top: 10, bottom: 10),
      child: ConstrainedBox(
        constraints: const BoxConstraints(minWidth: popupMenuMinWidth),
        child: child,
      ),
    );
  }

  Widget _buildMainMenu({Widget? mainMenuWidget, required List<MenuItem> items}) {
    return _buildPopupMenuButton(
      mainMenuWidget: mainMenuWidget,
      itemBuilder: (context) {
        List<PopupMenuEntry<MenuItem>> menuItems = [];
        for (var item in items) {
          if (item.children.isNullOrEmpty) {
            // 不含 submenu
            menuItems.add(
              _buildPopupMenuItem(
                value: item,
                child: _buildMenuItemContent(
                  child: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: [
                      Text(item.name),
                      if (item.checked != null) ...[
                        Builder(builder: (context) {
                          return appCheckBox(
                            value: item.checked!,
                            onChanged: (bool? newValue) {
                              item.checked = newValue ?? false;
                              context.markRebuildUI();
                              dismissAllPopupMenu(ignoreBase: true);
                            },
                          );
                        })
                      ]
                    ],
                  ),
                ),
              ),
            );
          } else {
            // 含有 submenu
            if (item.id != items.firstOrNull?.id) {
              menuItems.add(const PopupMenuDivider());
            }
            menuItems.add(
              _buildPopupMenuItem(
                value: item,
                // 继续构建 submenu
                child: _buildMainMenu(
                  mainMenuWidget: _buildMenuItemContent(
                    child: Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      mainAxisSize: MainAxisSize.max,
                      children: [
                        Text(item.name),
                        const Icon(Icons.arrow_right),
                      ],
                    ),
                  ),
                  items: item.children!,
                ),
              ),
            );
          }
        }

        return menuItems;
      },
    );
  }

  int _popupWindowCount = 0;

  /// dismiss
  dismissAllPopupMenu({bool ignoreBase = false}) {
    int base = ignoreBase ? 0 : 1;
    while (_popupWindowCount-- > base) {
      // Flutter 的 PopupMenu 也是在 Navigation 堆栈里的,所以要关闭 PopupMenu,调用 pop 即可。
      Navigator.of(context).pop(null);
    }

    _popupWindowCount = 0;
  }

  /// build PopupMenuButton
  Widget _buildPopupMenuButton({
    /// menu 按钮(Flutter是这样设计的,这个 Popup 不是调用一个 show 函数来显示的,而是提供一个 Widget 设置,点击这个 Widget 系统就会弹出显示 Popup)
    Widget? mainMenuWidget,

    /// menu 菜单项目列表构建
    required List<PopupMenuEntry<MenuItem>> Function(BuildContext) itemBuilder,
  }) {
    return Builder(builder: (context) {
      return PopupMenuButton<MenuItem>(
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(AppDimens.radius),
        ),
        itemBuilder: itemBuilder,
        onOpened: () {
          _popupWindowCount++;
        },
        onSelected: (value) {
          widget.onSelected.call(value);
          dismissAllPopupMenu();
        },
        onCanceled: () {
          dismissAllPopupMenu();
        },
        child: mainMenuWidget ?? const Icon(Icons.menu),
      );
    });
  }
}

class AppPopupItem<T> extends PopupMenuItem<T> {
  const AppPopupItem({
    super.key,
    super.value,
    super.onTap,
    super.enabled = true,
    super.height = kMinInteractiveDimension,
    super.padding,
    super.textStyle,
    super.labelTextStyle,
    super.mouseCursor,
    required super.child,
  });

  @override
  AppPopupItemState<T> createState() => AppPopupItemState<T>();
}

class AppPopupItemState<T> extends PopupMenuItemState<T, PopupMenuItem<T>> {
  @override
  void handleTap() {
    // 自定义 PopupMenuItem,可以在这里屏蔽 handleTap 事件。
    // Flutter 默认会调用  Navigator.pop<T>(context, widget.value); 来自动关闭 PopupMenu,如果不想要这个效果,注释掉 super.handleTap();
    super.handleTap();
  }
}

/// Menu Node
class MenuItem<T> {
  /// Menu id,方便在 PopupMenuButton 的 onSelected 回调中确认点击的是哪个 Menu。
  /// Example:
  /// enum MenuType { a, b, c, d, e, f, g }
  T id;

  /// Menu display name
  String name;

  /// checkable,null 表示普通菜单,非 null 表示 checkable 菜单,true 表示选中,false 表示未选中。
  bool? checked;

  /// submenu
  List<MenuItem>? children;

  /// 内部点击回调
  /// 如果不想在 PopupMenuButton 的 onSelected 回调中统一处理事件,可以直接传递 onTap 参数。
  VoidCallback? onTap;

  MenuItem({
    required this.id,
    required this.name,
    this.checked,
    this.children,
    this.onTap,
  });
}

/// Test Menu Type id
enum MenuType { a, b, c, d, e, f, g }

用法

PopupMenu(onSelected: (v) => print("click menu: ${v.name}"), items: [
                  MenuItem(
                    id: MenuType.a,
                    name: "学科",
                    children: [
                      MenuItem(
                        id: MenuType.b,
                        name: "语文",
                      ),
                      MenuItem(
                        id: MenuType.c,
                        name: "数学",
                      ),
                      MenuItem(
                        id: MenuType.b,
                        name: "英语",
                        children: [
                          MenuItem(
                            id: MenuType.e,
                            name: "四级",
                          ),
                          MenuItem(
                            id: MenuType.f,
                            name: "六级",
                          ),
                        ],
                      ),
                    ],
                  ),
                  MenuItem(
                    id: MenuType.g,
                    name: "Single Item",
                    checked: false,
                    onTap: () => print("内部点击"),
                  ),
                ]),