feat(client): 通用自适应弹出菜单(按屏幕位置上/下弹,不挡触发器)
新增 AdaptiveMenuButton:GlobalKey 量触发器矩形,据上下剩余空间决定向下/向上 弹出、右对齐裁剪到屏内,Overlay 全屏 barrier + 淡入缩放动画。「我的设备」⋯ 菜单由 PopupMenuButton 换成它(重命名 + 强制退出/清除),修正小屏被遮挡问题。 devices_screen_test 同步改用 AdaptiveMenuButton 触发器断言。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
// 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user