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("内部点击"),
),
]),