Flutter :自己动手,封装一个小巧精致的气泡弹窗库

前言

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!获取上下文

    dart 复制代码
    GlobalKey key = GlobalKey();
    ElevatedButton(
      key: key,
    )
    
    //获取上下文
    BuildContext anchorContext = key.currentContext!;
  • 使用Builder组件

    通过Builder组件的回调函数直接获取anchorContext

    dart 复制代码
    Builder(
      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,支持箭头方向、大小等属性。

    通过组合ContainerShapeDecoration,实现以下功能:

    • 根据是否显示箭头及箭头方向,自动调整容器内边距;
    • 使用自定义的BubbleShapeBorder实现带箭头的气泡形状;
    • 支持Container常规装饰属性如颜色、阴影、边距等。

实现效果如下:

  • 使用PopupRoute管理弹窗的生命周期和动画效果
  • 计算锚点位置的位置和尺寸,计算边界矩形信息
  • 计算弹窗位置,检测弹窗是否会超出屏幕边界,自动调整方向
  • 计算箭头偏移量,确保箭头始终和锚点居中对齐
  • 创建BubbleContainer组件,使用Stack布局对其进行定位
相关推荐
合作小小程序员小小店2 小时前
web网页开发,在线%人力资源管理%系统,基于Idea,html,css,jQuery,java,jsp,ssh,mysql。
java·前端·css·数据库·mysql·html·intellij-idea
Ace_31750887762 小时前
拼多多商品详情接口深度解析:从加密参数破解到数据全量获取
前端·数据库·github
yuejich2 小时前
命名规范snake_case
服务器·前端·数据库
天平2 小时前
开发了几个app后,我在React Native用到的几个库的推荐
android·前端·react native
消失的旧时光-19432 小时前
Kotlinx.serialization 对多态对象(sealed class )支持更好用
java·服务器·前端
少卿3 小时前
React Compiler 完全指南:自动化性能优化的未来
前端·javascript
广州华水科技3 小时前
水库变形监测推荐:2025年单北斗GNSS变形监测系统TOP5,助力基础设施安全
前端
广州华水科技3 小时前
北斗GNSS变形监测一体机在基础设施安全中的应用与优势
前端
七淮3 小时前
umi4暗黑模式设置
前端