简介
弹窗菜单用到的场景还是蛮多的,比如这样的:
image.png实现方案
-
采用
PopupMenuButton
组件,配合PopupMenuItem
,可以非常方便地实现这种弹出菜单效果。 -
实践下来,基本的功能实现是足够用了,但是如果需要自定义视图,就显得力不从心;
-
弹窗菜单基本上是一种
OverlayEntry
,所以如果需要自定义,应该用这个; -
OverlayEntry
弹窗菜单与目标组件是紧挨着的,如何定位是个问题。 -
CompositedTransformFollower 与 CompositedTransformTarget的组合可以解决目标组件与弹出
OverlayEntry
的定位问题。
封装
- 模仿
PopupMenuButton
,定义接口参数。为了增加自定义的自由度,弹出部分也只是个Widget
,而不是一个数组。
/// 模仿PopupMenuButton写的弹窗菜单
class PandaPopupMenu extends StatelessWidget {
const PandaPopupMenu({
Key? key,
required this.targetWiget,
required this.menuWiget,
}) : super(key: key);
final Widget targetWiget;
final Widget menuWiget;
@override
Widget build(BuildContext context) {
return const Center(
child: Text(
'PandaPopupMenu is working',
style: TextStyle(fontSize: 20),
),
);
}
}
- 内部变量:组件对所需要的连线;弹窗用一个
OverlayEntry
/// 内部变量
final LayerLink _layerLink = LayerLink();
OverlayEntry? _overlayEntry;
Target布局
这个就是PopupMenuButton
一直显示的部分,外面套一个Container
,可以带来很大的方便。
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
_showOverlay(context);
},
child: Container(
margin: margin,
padding: padding,
color: color,
width: width,
height: height,
decoration: decoration,
child: CompositedTransformTarget(
link: _layerLink,
child: targetWidget,
),
),
);
}
菜单代码
/// 显示浮层
void _showOverlay(BuildContext context) {
/// 防止重复创建,不然失去句柄的OverlayEntry将无法消除
if (_overlayEntry == null) {
_overlayEntry = _createOverlayEntry();
if (_overlayEntry != null) {
Overlay.of(context).insert(_overlayEntry!);
}
}
}
/// 隐藏浮层
void _hideOverlay() {
/// 防止null调用异常
if (_overlayEntry != null) {
_overlayEntry?.remove();
_overlayEntry = null;
}
}
/// 创建浮层
OverlayEntry _createOverlayEntry() {
return OverlayEntry(
builder: (BuildContext context) {
return GestureDetector(
onTap: () {
_hideOverlay();
},
child: UnconstrainedBox(
child: CompositedTransformFollower(
link: _layerLink,
targetAnchor: Alignment.bottomCenter,
followerAnchor: Alignment.topCenter,
offset: const Offset(0, 10),
child: Material(
child: menuWigdet,
),
),
),
);
},
);
}
-
OverlayEntry
默认是全屏充满的,PopupMenuButton
就是这样的情况。不过,在很多时候我们不希望这样。比如,我们现在的设想是点击Target
部分,显示菜单;点击菜单部分,隐藏菜单。要去掉全屏,只要在外面套一个UnconstrainedBox
就可以了。 -
OverlayEntry
不能反复创建,不然的话,丢失句柄的OverlayEntry
会无法消除;所以创建和消除方法需要做好判空处理。 -
默认锚点都在左上角,这样导致弹出的菜单盖住了
Target
,失去“跟随”的意义。这个只要改一下targetAnchor,followerAnchor
取值就可以了。 -
偏移量:
Target
和菜单是紧挨着的,如果需要间隔,那么只要修改offset
参数就可以了。
调用代码
PandaPopupMenu(
targetWigdet: Container(
color: Colors.yellow,
width: 30,
height: 30,
),
menuWigdet: Container(
color: Colors.blue,
width: 200,
height: 300,
),
),
代码很简单,就是两个不同颜色的矩形
效果
- 默认状态:就是一个黄色矩形
- 展开状态:点击黄色矩形,添加一个浮层,目前是一个蓝色矩形;跟随状态,并且有间隔。点击弹出的蓝色矩形,可以隐藏。
-
由于去掉了
OverlayEntry
的全屏属性,其他地方都能正常点击,响应也正常,页面跳转也不受影响。 -
问题:由于这里页面的底部是
Tab
,切换Tab
的时候,失去了“跟随”目标,所以弹出的蓝色就只能居中显示。
![image.png](https://img.haomeiwen.com/i1186939/35473caf95611854.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
变换
作为一个整体,变换之后,也能很好地跟随。
- 旋转
Transform(
transform: Matrix4.rotationZ(-15 / 180 * 3.14),
alignment: Alignment.center,
child: PandaPopupMenu(
targetWigdet: Container(
color: Colors.yellow,
width: 30,
height: 30,
),
menuWigdet: Container(
color: Colors.blue,
width: 200,
height: 300,
),
),
),
image.png
- 缩放
Transform(
transform: Matrix4.diagonal3Values(0.5, 0.5, 1),
alignment: Alignment.center,
child: PandaPopupMenu(
targetWigdet: Container(
color: Colors.yellow,
width: 30,
height: 30,
),
menuWigdet: Container(
color: Colors.blue,
width: 200,
height: 300,
),
),
),
image.png
- 斜切
Transform(
transform: Matrix4.skewX(15 / 180 * 3.14),
alignment: Alignment.center,
child: PandaPopupMenu(
targetWigdet: Container(
color: Colors.yellow,
width: 30,
height: 30,
),
menuWigdet: Container(
color: Colors.blue,
width: 200,
height: 300,
),
),
),
image.png
- 平移
Transform(
transform: Matrix4.translationValues(30, 10, 0),
alignment: Alignment.center,
child: PandaPopupMenu(
targetWigdet: Container(
color: Colors.yellow,
width: 30,
height: 30,
),
menuWigdet: Container(
color: Colors.blue,
width: 200,
height: 300,
),
),
),
image.png
代码最后的样子
// ignore_for_file: must_be_immutable
import 'package:flutter/material.dart';
/// 模仿PopupMenuButton写的弹窗菜单
class PandaPopupMenu extends StatelessWidget {
PandaPopupMenu({
Key? key,
required this.targetWigdet,
required this.menuWigdet,
this.margin,
this.padding,
this.color,
this.width,
this.height,
this.decoration,
this.offset = const Offset(0, 10),
this.targetAnchor = Alignment.bottomCenter,
this.followerAnchor = Alignment.topCenter,
}) : super(key: key);
final Widget targetWigdet;
final Widget menuWigdet;
final EdgeInsetsGeometry? margin;
final EdgeInsetsGeometry? padding;
final Color? color;
final double? width;
final double? height;
final Decoration? decoration;
final Offset offset;
final Alignment targetAnchor;
final Alignment followerAnchor;
/// 内部变量
final LayerLink _layerLink = LayerLink();
OverlayEntry? _overlayEntry;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
_showOverlay(context);
},
child: Container(
margin: margin,
padding: padding,
color: color,
width: width,
height: height,
decoration: decoration,
child: CompositedTransformTarget(
link: _layerLink,
child: targetWigdet,
),
),
);
}
/// 显示浮层
void _showOverlay(BuildContext context) {
/// 防止重复创建,不然失去句柄的OverlayEntry将无法消除
if (_overlayEntry == null) {
_overlayEntry = _createOverlayEntry();
if (_overlayEntry != null) {
Overlay.of(context).insert(_overlayEntry!);
}
}
}
/// 隐藏浮层
void _hideOverlay() {
/// 防止null调用异常
if (_overlayEntry != null) {
_overlayEntry?.remove();
_overlayEntry = null;
}
}
/// 创建浮层
OverlayEntry _createOverlayEntry() {
return OverlayEntry(
builder: (BuildContext context) {
return GestureDetector(
onTap: () {
_hideOverlay();
},
child: UnconstrainedBox(
child: CompositedTransformFollower(
link: _layerLink,
targetAnchor: Alignment.bottomCenter,
followerAnchor: Alignment.topCenter,
offset: const Offset(0, 10),
child: Material(
child: menuWigdet,
),
),
),
);
},
);
}
}
参考文章
Flutter 组件 | 手牵手,一起走 CompositedTransformFollower 与 CompositedTransformTarget
网友评论