6788501e4a
新增 AdaptiveMenuButton:GlobalKey 量触发器矩形,据上下剩余空间决定向下/向上 弹出、右对齐裁剪到屏内,Overlay 全屏 barrier + 淡入缩放动画。「我的设备」⋯ 菜单由 PopupMenuButton 换成它(重命名 + 强制退出/清除),修正小屏被遮挡问题。 devices_screen_test 同步改用 AdaptiveMenuButton 触发器断言。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
203 lines
6.1 KiB
Dart
203 lines
6.1 KiB
Dart
// adaptive_menu.dart — 自适应方向的弹出菜单(通用组件)。
|
|
//
|
|
// 行为:点触发按钮后,根据按钮在屏幕的位置自动决定菜单**向下**还是**向上**弹
|
|
// (下方空间够就向下,不够就向上;都不够取较大一侧),且**始终留间隙、不遮挡触发
|
|
// 按钮本身**。水平方向菜单右缘对齐按钮右缘并夹在屏内。点菜单外区域关闭。
|
|
//
|
|
// 用 Overlay 自绘(而非 PopupMenuButton——后者不保证翻转、可能盖住按钮)。
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../pangolin_theme.dart';
|
|
|
|
/// 一个菜单项。danger=true 用危险色(如删除)。
|
|
class AdaptiveMenuItem {
|
|
const AdaptiveMenuItem({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onSelected,
|
|
this.danger = false,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onSelected;
|
|
final bool danger;
|
|
}
|
|
|
|
/// 触发按钮(图标)+ 自适应弹出菜单。
|
|
class AdaptiveMenuButton extends StatefulWidget {
|
|
const AdaptiveMenuButton({
|
|
super.key,
|
|
required this.items,
|
|
required this.icon,
|
|
this.iconColor,
|
|
this.iconSize = 18,
|
|
this.menuWidth = 208,
|
|
this.itemHeight = 44,
|
|
});
|
|
|
|
final List<AdaptiveMenuItem> items;
|
|
final IconData icon;
|
|
final Color? iconColor;
|
|
final double iconSize;
|
|
final double menuWidth;
|
|
final double itemHeight;
|
|
|
|
@override
|
|
State<AdaptiveMenuButton> createState() => _AdaptiveMenuButtonState();
|
|
}
|
|
|
|
class _AdaptiveMenuButtonState extends State<AdaptiveMenuButton> {
|
|
final _anchorKey = GlobalKey();
|
|
OverlayEntry? _entry;
|
|
|
|
@override
|
|
void dispose() {
|
|
_close();
|
|
super.dispose();
|
|
}
|
|
|
|
void _close() {
|
|
_entry?.remove();
|
|
_entry = null;
|
|
}
|
|
|
|
void _open() {
|
|
if (widget.items.isEmpty) return;
|
|
final box = _anchorKey.currentContext?.findRenderObject() as RenderBox?;
|
|
final overlay = Overlay.of(context);
|
|
final overlayBox = overlay.context.findRenderObject() as RenderBox?;
|
|
if (box == null || overlayBox == null) return;
|
|
|
|
final topLeft = box.localToGlobal(Offset.zero, ancestor: overlayBox);
|
|
final size = box.size;
|
|
final screen = overlayBox.size;
|
|
final safe = MediaQuery.of(context).padding;
|
|
|
|
const gap = 6.0;
|
|
final menuH = widget.items.length * widget.itemHeight + 10;
|
|
final triggerTop = topLeft.dy;
|
|
final triggerBottom = topLeft.dy + size.height;
|
|
|
|
final spaceBelow = screen.height - safe.bottom - 8 - triggerBottom;
|
|
final spaceAbove = triggerTop - safe.top - 8;
|
|
// 下方放得下就向下;否则若下方比上方大也向下;否则向上。
|
|
final openDown = spaceBelow >= menuH + gap || spaceBelow >= spaceAbove;
|
|
|
|
// 水平:菜单右缘对齐按钮右缘,夹在屏内。
|
|
final maxLeft = (screen.width - widget.menuWidth - 8).clamp(8.0, double.infinity);
|
|
final left = (topLeft.dx + size.width - widget.menuWidth).clamp(8.0, maxLeft);
|
|
|
|
// 垂直:向下→按钮底+gap(不遮挡);向上→按钮顶-gap-菜单高(不遮挡)。
|
|
final top = openDown
|
|
? triggerBottom + gap
|
|
: (triggerTop - gap - menuH).clamp(safe.top + 8, double.infinity);
|
|
|
|
final c = context.pangolin;
|
|
_entry = OverlayEntry(
|
|
builder: (_) => Stack(children: [
|
|
Positioned.fill(
|
|
child: GestureDetector(behavior: HitTestBehavior.translucent, onTap: _close),
|
|
),
|
|
Positioned(
|
|
left: left,
|
|
top: top,
|
|
width: widget.menuWidth,
|
|
child: _MenuCard(
|
|
scheme: c,
|
|
items: widget.items,
|
|
itemHeight: widget.itemHeight,
|
|
fromTop: openDown,
|
|
onPick: (it) {
|
|
_close();
|
|
it.onSelected();
|
|
},
|
|
),
|
|
),
|
|
]),
|
|
);
|
|
overlay.insert(_entry!);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = context.pangolin;
|
|
return InkResponse(
|
|
key: _anchorKey,
|
|
radius: 22,
|
|
onTap: _open,
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(6),
|
|
child: Icon(widget.icon, size: widget.iconSize, color: widget.iconColor ?? c.fg3),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MenuCard extends StatelessWidget {
|
|
const _MenuCard({
|
|
required this.scheme,
|
|
required this.items,
|
|
required this.itemHeight,
|
|
required this.fromTop,
|
|
required this.onPick,
|
|
});
|
|
|
|
final PangolinScheme scheme;
|
|
final List<AdaptiveMenuItem> items;
|
|
final double itemHeight;
|
|
final bool fromTop; // 向下弹时从顶部展开,向上弹时从底部展开
|
|
final ValueChanged<AdaptiveMenuItem> onPick;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final c = scheme;
|
|
return TweenAnimationBuilder<double>(
|
|
duration: const Duration(milliseconds: 130),
|
|
curve: Curves.easeOutCubic,
|
|
tween: Tween(begin: 0, end: 1),
|
|
builder: (_, v, child) => Opacity(
|
|
opacity: v.clamp(0.0, 1.0),
|
|
child: Transform.scale(
|
|
scale: 0.96 + 0.04 * v,
|
|
alignment: fromTop ? Alignment.topRight : Alignment.bottomRight,
|
|
child: child,
|
|
),
|
|
),
|
|
child: Material(
|
|
color: c.surface,
|
|
elevation: 6,
|
|
shadowColor: Colors.black26,
|
|
clipBehavior: Clip.antiAlias,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(PangolinRadius.md),
|
|
side: BorderSide(color: c.border),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const SizedBox(height: 5),
|
|
for (final it in items)
|
|
InkWell(
|
|
onTap: () => onPick(it),
|
|
child: SizedBox(
|
|
height: itemHeight,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
child: Row(children: [
|
|
Icon(it.icon, size: 16, color: it.danger ? c.danger : c.fg2),
|
|
const SizedBox(width: 11),
|
|
Text(it.label,
|
|
style: PangolinText.sm.copyWith(color: it.danger ? c.danger : c.fg1)),
|
|
]),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 5),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|