在开发中,我们经常会遇到长按弹出框。这里我们自己实现一个。
自定义弹出框popup
功能点
- 支持上下左右弹出
- 支持长按、点击弹出
- 箭头指示器与点击的widget居中对齐,弹出的pop自适应边界
- 与系统路由联动
相关参数
| 属性名 | 类型 | 默认值 | 简介 |
|---|---|---|---|
| context | BuildContext | 必填 | 为了方便获取点击的widget的尺寸和位置 |
| child | Widget | 必填 | 弹出的widget |
| margin | EdgeInsets | EdgeInsets.zero | 弹窗边距 |
| position | PopPosition | PopPosition.top | 弹出的位置 |
| arrowSize | double | 10 | 箭头的尺寸 |
| arrowAspectRatio | double | 0.5 | 箭头高宽比 |
| arrowColor | Color | Colors.black | 箭头颜色 |
| showArrow | bool | true | 是否显示箭头 |
| space | double | 0 | 点击的widget与弹窗箭头之间的间距 |
| barrierColor | Color | null | 弹窗背景颜色 |
使用方法
使用Builder包裹需要点击的Widget
Builder _buildChild(PopPosition position) {
var name = position.name.split(".").first;
return Builder(builder: (context) {
return GestureDetector(
onLongPress: () {
showPopup(
position: position,
context: context,
margin: const EdgeInsets.symmetric(horizontal: 5),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(6),
color: Colors.black,
),
padding: const EdgeInsets.all(4),
child: Text(
List.generate(Random().nextInt(10) + 3, (index) => name).toString(),
style: const TextStyle(color: Colors.white),
),
));
},
child: Container(
decoration: BoxDecoration(border: Border.all(color: Colors.red)),
width: 100,
height: 100,
child: Center(
child: Text(name),
),
),
);
});
代码实现
定义弹出方向
使用枚举定义上下左右四个方向,angle是不同方向对应箭头的旋转角度
enum PopPosition {
top(0),
bottom(pi),
left(-pi / 2),
right(pi / 2),
;
final double angle;
const PopPosition(this.angle);
}
定义箭头
使用CustomClipper裁剪出一个三角形
class Arrow extends StatelessWidget {
final double size;
final double aspectRatio;
final Color color;
const Arrow({super.key, required this.size, this.aspectRatio = 0.87, required this.color});
@override
Widget build(BuildContext context) {
return ClipPath(
clipper: _ArrowClipper(aspectRatio),
child: Container(
width: size,
height: size,
color: color,
),
);
}
}
class _ArrowClipper extends CustomClipper<Path> {
final double aspectRatio;
_ArrowClipper(this.aspectRatio);
@override
Path getClip(Size size) {
var width = size.width;
var arrowHeight = width * aspectRatio;
Path path = Path();
path.moveTo(0, 0);
path.lineTo(width, 0);
path.lineTo(width / 2, arrowHeight);
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) {
return true;
}
}
弹出
使用Navigator弹出自定义PopupRoute,点击返回按钮,空白地方自动隐藏弹窗
Navigator.push(context, _PopupRoute(child: layout, backColor: barrierColor));
class _PopupRoute extends PopupRoute {
final Widget child;
final Color? backColor;
_PopupRoute({
required this.child,
this.backColor,
});
@override
Color? get barrierColor => backColor;
@override
bool get barrierDismissible => true;
@override
String? get barrierLabel => null;
@override
Widget buildPage(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation) {
return child;
}
@override
Duration get transitionDuration => const Duration();
}
弹窗的边界约束
主要逻辑
- 获取点击widget的边界
rect,这里通过context找到RenderBox获取 - 确定箭头的位置,
rect正上方 - 确定
pop位置,我们需要计算出pop(上图红色矩形范围)的边界,然后保证pop不会超出这个边界 - 使用
MultiChildLayoutDelegate放置箭头和pop
获取点击widget的边界rect
void showPopup({
required BuildContext context,
required Widget child,
EdgeInsets margin = EdgeInsets.zero,
PopPosition position = PopPosition.top,
double arrowSize = 10,
double arrowAspectRatio = 0.5,
Color arrowColor = Colors.black,
bool showArrow = true,
double space = 0,
Color? barrierColor,
}) {
var rendBox = context.findRenderObject() as RenderBox?;
if (rendBox != null) {
var size = rendBox.size;
var offset = rendBox.localToGlobal(Offset.zero);
var rect = Rect.fromLTWH(offset.dx, offset.dy, size.width, size.height);
var layout = CustomMultiChildLayout(
delegate: _MultiChildLayoutDelegate(
rect: rect,
position: position,
margin: margin,
showArrow: showArrow,
arrowSize: arrowSize,
space: space,
),
children: [
LayoutId(id: _Id.pop, child: Material(child: child)),
if (showArrow)
LayoutId(
id: _Id.arrow,
child: Transform.rotate(
angle: position.angle,
child: Arrow(size: arrowSize, aspectRatio: arrowAspectRatio, color: arrowColor),
),
),
],
);
Navigator.push(context, _PopupRoute(child: layout, backColor: barrierColor));
}
}
使用自定义MultiChildLayoutDelegate重写performLayout方法使用positionChild控制位置,对应的child定义两个id,分别为箭头和弹窗
enum _Id {
pop,
arrow,
}
class _MultiChildLayoutDelegate extends MultiChildLayoutDelegate {
final Rect rect;
final PopPosition position;
final EdgeInsets margin;
final bool showArrow;
final double space;
final double arrowSize;
_MultiChildLayoutDelegate({
required this.rect,
required this.position,
required this.margin,
required this.showArrow,
required this.arrowSize,
required this.space,
});
@override
void performLayout(Size size) {
Rect layoutRect = Rect.fromLTRB(margin.left, margin.top, size.width - margin.right, size.height - margin.bottom);
Rect outRect = getOutRect(layoutRect);
var popSize = layoutChild(_Id.pop, BoxConstraints.loose(Size(outRect.width, outRect.height)));
var popRect = getRect(popSize, dis);
//适配边界
popRect = keepInside(outRect, popRect);
//定位pop
positionChild(_Id.pop, popRect.topLeft);
if (showArrow) {
var arrowSize = layoutChild(_Id.arrow, BoxConstraints.loose(Size(outRect.width, outRect.height)));
var arrowRect = getRect(arrowSize, space);
positionChild(_Id.arrow, arrowRect.topLeft);
}
}
@override
bool shouldRelayout(covariant MultiChildLayoutDelegate oldDelegate) {
return false;
}
///点击的widget与pop之间的间距
double get dis => (showArrow ? arrowSize : 0) + space;
///获取目标方块的位置
Rect getRect(Size size, double space) {
var dy = (rect.height + size.height) / 2 + space;
var dx = (rect.width + size.width) / 2 + space;
Offset offset;
switch (position) {
case PopPosition.top:
offset = Offset(0, -dy);
break;
case PopPosition.bottom:
offset = Offset(0, dy);
break;
case PopPosition.left:
offset = Offset(-dx, 0);
break;
case PopPosition.right:
offset = Offset(dx, 0);
break;
}
Offset center = rect.center.translate(offset.dx, offset.dy);
return Rect.fromCenter(center: center, width: size.width, height: size.height);
}
///获取pop所在的边界范围
Rect getOutRect(Rect layoutRect) {
switch (position) {
case PopPosition.top:
return Rect.fromLTRB(layoutRect.left, layoutRect.top, layoutRect.right, rect.top - dis);
case PopPosition.bottom:
return Rect.fromLTRB(layoutRect.left, rect.bottom + dis, layoutRect.right, layoutRect.bottom);
case PopPosition.left:
return Rect.fromLTRB(layoutRect.left, layoutRect.top, rect.left - dis, layoutRect.bottom);
case PopPosition.right:
return Rect.fromLTRB(rect.right + dis, layoutRect.top, layoutRect.right, layoutRect.bottom);
}
}
///保持b在a范围内
Rect keepInside(Rect a, Rect b) {
// 判断 b 是否在 a 范围内
if (!a.contains(b.topLeft) || !a.contains(b.bottomRight)) {
// 计算需要移动的距离
double dx = 0;
double dy = 0;
if (b.left < a.left) {
dx = a.left - b.left;
} else if (b.right > a.right) {
dx = a.right - b.right;
}
if (b.top < a.top) {
dy = a.top - b.top;
} else if (b.bottom > a.bottom) {
dy = a.bottom - b.bottom;
}
return b.shift(Offset(dx, dy));
} else {
return b;
}
}
}