前言
Flutter自带的PopupMenuButton组件提供了弹出菜单功能,但菜单样式简单,不方便定制,有时满足不了UI设计需求,于是就自己封装了一个功能丰富的带箭头的气泡弹窗库,支持多种定位方式,支持自定义弹窗样式。
GitHub地址: github.com/kongpf8848/...
截图
![]() |
![]() |
![]() |
功能特性
- 🎯 12种定位选项:支持上下左右各个方向及对齐方式的弹窗定位
- 🔄 智能调整位置:自动检测边界并调整弹窗位置确保完全可见
- 🎨 高度可定制:支持自定义弹窗背景、圆角、间距、箭头大小和颜色等
- 🎭 功能丰富:支持点击外部关闭弹窗、设置遮罩层颜色等功能
安装
在pubspec.yaml文件中添加依赖:
yaml
dependencies:
bubble_popup_window: ^0.0.7
使用
dart
import 'package:bubble_popup_window/bubble_popup_window.dart';
GlobalKey key = GlobalKey();
ElevatedButton(
key: key,
onPressed: () {
_showToolTip(key.currentContext!);
},
child: const Text("Tooltip"),
)
void _showToolTip(BuildContext anchorContext) {
BubblePopupWindow.show(
//锚点上下文
anchorContext: anchorContext,
//弹窗布局,用户自定义
child: const Text(
'这是一个气泡弹窗',
style: TextStyle(
color: Colors.black,
fontSize: 14,
fontWeight: FontWeight.normal,
),
),
//弹窗方向
direction: BubbleDirection.bottomCenter,
//弹窗颜色
color: Colors.white,
//弹窗圆角半径
radius: BorderRadius.circular(8),
//弹窗边框
border: const BorderSide(
color: Colors.red,
width: 2,
),
//弹窗内边距
padding: const EdgeInsets.all(16),
//弹窗距离锚点间距
gap: 4.0,
//弹窗距离屏幕边缘最小间距
miniEdgeMargin: const EdgeInsets.only(left: 10, right: 10),
//遮罩层颜色
maskColor: null,
//点击弹窗外部时是否自动关闭弹窗
dismissOnTouchOutside: true,
//是否显示箭头
showArrow: true,
//箭头宽度
arrowWidth: 12.0,
//箭头高度
arrowHeight: 6.0,
//箭头半径
arrowRadius: 2.0,
);
}
anchorContext 用于确定锚点的位置和尺寸,可通过以下方式获取:
-
使用
GlobalKey为组件设置
GlobalKey后,通过key.currentContext!获取上下文dartGlobalKey key = GlobalKey(); ElevatedButton( key: key, ) //获取上下文 BuildContext anchorContext = key.currentContext!; -
使用
Builder组件通过
Builder组件的回调函数直接获取anchorContextdartBuilder( builder: (BuildContext anchorContext) { //在此使用anchorContext return Container(); } )
参数说明
| 参数名 | 类型 | 默认值 | 描述 |
|---|---|---|---|
anchorContext |
BuildContext |
无 | 锚点上下文 |
child |
Widget |
无 | 弹窗内容,用户自定义 |
direction |
BubbleDirection |
BubbleDirection.bottomCenter |
弹窗方向 |
color |
Color |
Colors.white |
弹窗颜色 |
radius |
BorderRadius |
BorderRadius.zero |
弹窗圆角半径 |
border |
BorderSide |
BorderSide.none |
弹窗边框 |
shadows |
List<BoxShadow>? |
无 | 弹窗阴影 |
padding |
EdgeInsetsGeometry? |
无 | 弹窗内边距 |
gap |
double |
0.0 |
弹窗距离锚点的间距 |
maskColor |
Color? |
null |
遮罩层颜色 |
dismissOnTouchOutside |
bool |
true |
点击弹窗外部是否关闭弹窗 |
miniEdgeMargin |
EdgeInsets |
EdgeInsets.zero |
弹窗距离屏幕边缘最小间距 |
showArrow |
bool |
true |
是否显示箭头 |
arrowWidth |
Double |
10.0 |
箭头宽度 |
arrowHeight |
Double |
5.0 |
箭头高度 |
arrowRadius |
Double |
0.0 |
箭头半径 |
实现思路
- 通过自定义
ShapeBorder,实现带箭头的形状,同时支持圆角、边框功能,是系统自带RoundedRectangleBorder的加强版本
dart
// src/bubble_shape_border.dart
class BubbleShapeBorder extends OutlinedBorder {
final BorderRadius borderRadius;
final ArrowDirection arrowDirection;
final double arrowWidth;
final double arrowHeight;
final double arrowRadius;
final double? arrowOffset;
const BubbleShapeBorder({
super.side,
this.borderRadius = BorderRadius.zero,
required this.arrowDirection,
this.arrowWidth = 10.0,
this.arrowHeight = 5.0,
this.arrowRadius = 0.0,
this.arrowOffset,
});
_BubbleBorderArrowProperties _calculateArrowProperties() {
final arrowHalfWidth = arrowWidth / 2;
final double hypotenuse =
math.sqrt(arrowHeight * arrowHeight + arrowHalfWidth * arrowHalfWidth);
final double projectionOnMain = arrowHalfWidth * arrowRadius / hypotenuse;
final double projectionOnCross =
projectionOnMain * arrowHeight / arrowHalfWidth;
final double arrowProjectionOnMain = arrowHeight * arrowRadius / hypotenuse;
final double pointArrowTopLen =
arrowProjectionOnMain * arrowHeight / arrowHalfWidth;
return _BubbleBorderArrowProperties(
halfWidth: arrowHalfWidth,
hypotenuse: hypotenuse,
projectionOnMain: projectionOnMain,
projectionOnCross: projectionOnCross,
arrowProjectionOnMain: arrowProjectionOnMain,
topLen: pointArrowTopLen,
);
}
@override
Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
return _buildPath(rect.deflate(side.strokeInset), true);
}
@override
Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
return _buildPath(rect.inflate(side.strokeOutset), false);
}
Rect _getRoundedRect(Rect rect) {
EdgeInsets padding = EdgeInsets.zero;
if (arrowDirection == ArrowDirection.top) {
padding = EdgeInsets.only(top: arrowHeight);
} else if (arrowDirection == ArrowDirection.right) {
padding = EdgeInsets.only(right: arrowHeight);
} else if (arrowDirection == ArrowDirection.bottom) {
padding = EdgeInsets.only(bottom: arrowHeight);
} else if (arrowDirection == ArrowDirection.left) {
padding = EdgeInsets.only(left: arrowHeight);
}
return Rect.fromLTRB(
rect.left + padding.left,
rect.top + padding.top,
rect.right - padding.right,
rect.bottom - padding.bottom,
);
}
//计算方向为:上、右、下、左
Path _buildPath(Rect rect, bool isInner) {
final path = Path();
final nRect = _getRoundedRect(rect);
final sideOffset = isInner ? -side.strokeInset : side.strokeOutset;
final arrowProp = _calculateArrowProperties();
path.moveTo(nRect.left + borderRadius.topLeft.x, nRect.top);
//top arrow
if (arrowDirection == ArrowDirection.top) {
Offset pointCenter = Offset(
nRect.left + (arrowOffset ?? nRect.width / 2) + sideOffset,
nRect.top);
Offset pointStart =
Offset(pointCenter.dx - arrowProp.halfWidth, nRect.top);
Offset pointArrow = Offset(pointCenter.dx, rect.top);
Offset pointEnd = Offset(pointCenter.dx + arrowProp.halfWidth, nRect.top);
Offset pointStartArcBegin =
Offset(pointStart.dx - arrowRadius, pointStart.dy);
Offset pointStartArcEnd = Offset(
pointStart.dx + arrowProp.projectionOnMain,
pointStart.dy - arrowProp.projectionOnCross);
path.lineTo(pointStartArcBegin.dx, pointStartArcBegin.dy);
path.quadraticBezierTo(pointStart.dx, pointStart.dy, pointStartArcEnd.dx,
pointStartArcEnd.dy);
Offset pointArrowArcBegin = Offset(
pointArrow.dx - arrowProp.arrowProjectionOnMain,
pointArrow.dy + arrowProp.topLen);
Offset pointArrowArcEnd = Offset(
pointArrow.dx + arrowProp.arrowProjectionOnMain,
pointArrow.dy + arrowProp.topLen);
path.lineTo(pointArrowArcBegin.dx, pointArrowArcBegin.dy);
path.quadraticBezierTo(pointArrow.dx, pointArrow.dy, pointArrowArcEnd.dx,
pointArrowArcEnd.dy);
Offset pointEndArcBegin = Offset(pointEnd.dx - arrowProp.projectionOnMain,
pointEnd.dy - arrowProp.projectionOnCross);
Offset pointEndArcEnd = Offset(pointEnd.dx + arrowRadius, pointEnd.dy);
path.lineTo(pointEndArcBegin.dx, pointEndArcBegin.dy);
path.quadraticBezierTo(
pointEnd.dx, pointEnd.dy, pointEndArcEnd.dx, pointEndArcEnd.dy);
}
path.lineTo(nRect.right - borderRadius.topRight.x, nRect.top);
//topRight radius
path.arcToPoint(
Offset(nRect.right, nRect.top + borderRadius.topRight.y),
radius: borderRadius.topRight,
rotation: 90,
);
//right arrow
if (arrowDirection == ArrowDirection.right) {
......
}
path.lineTo(nRect.right, nRect.bottom - borderRadius.bottomRight.y);
//bottomRight radius
path.arcToPoint(
Offset(nRect.right - borderRadius.bottomRight.x, nRect.bottom),
radius: borderRadius.bottomRight,
rotation: 90,
);
//bottom arrow
if (arrowDirection == ArrowDirection.bottom) {
......
}
path.lineTo(nRect.left + borderRadius.bottomLeft.x, nRect.bottom);
//bottomLeft radius
path.arcToPoint(
Offset(nRect.left, nRect.bottom - borderRadius.bottomLeft.y),
radius: borderRadius.bottomLeft,
rotation: 90,
);
//left arrow
if (arrowDirection == ArrowDirection.left) {
......
}
path.lineTo(nRect.left, nRect.top + borderRadius.topLeft.y);
//topLeft radius
path.arcToPoint(
Offset(nRect.left + borderRadius.topLeft.x, nRect.top),
radius: borderRadius.topLeft,
rotation: 90,
);
return path;
}
@override
void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
//绘制边框
switch (side.style) {
case BorderStyle.none:
break;
case BorderStyle.solid:
if (side.width > 0.0) {
var outerPath = getOuterPath(rect);
var innerPath = getInnerPath(rect);
Path path =
Path.combine(PathOperation.difference, outerPath, innerPath);
path.fillType = PathFillType.evenOdd;
final Paint paint = Paint()
..color = side.color
..style = PaintingStyle.fill;
canvas.drawPath(path, paint);
}
}
}
}
-
封装一个气泡框组件
BubbleContainer,支持箭头方向、大小等属性。通过组合
Container和ShapeDecoration,实现以下功能:- 根据是否显示箭头及箭头方向,自动调整容器内边距;
- 使用自定义的
BubbleShapeBorder实现带箭头的气泡形状; - 支持
Container常规装饰属性如颜色、阴影、边距等。
实现效果如下:

- 使用
PopupRoute管理弹窗的生命周期和动画效果 - 计算锚点位置的位置和尺寸,计算边界矩形信息
- 计算弹窗位置,检测弹窗是否会超出屏幕边界,自动调整方向
- 计算箭头偏移量,确保箭头始终和锚点居中对齐
- 创建
BubbleContainer组件,使用Stack布局对其进行定位


