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

前言

Flutter自带的PopupMenuButton组件提供了弹出菜单功能,但菜单样式简单,不方便定制,有时满足不了UI设计需求,于是就自己封装了一个功能丰富的带箭头的气泡弹窗库,支持多种定位方式,支持自定义弹窗样式。

GitHub地址:
https://github.com/kongpf8848/bubble_popup_window

截图

screenshot_1.png
screenshot_2.png
screenshot_3.png

功能特性

  • 🎯 12种定位选项:支持上下左右各个方向及对齐方式的弹窗定位
  • 🔄 智能调整位置:自动检测边界并调整弹窗位置确保完全可见
  • 🎨 高度可定制:支持自定义弹窗背景、圆角、间距、箭头大小和颜色等
  • 🎭 功能丰富:支持点击外部关闭弹窗、设置遮罩层颜色等功能

安装

在pubspec.yaml文件中添加依赖:

dependencies:
  bubble_popup_window: ^0.0.7

使用

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

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

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

    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的加强版本
// 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常规装饰属性如颜色、阴影、边距等。

实现效果如下:

bubble_container.png
  • 使用PopupRoute管理弹窗的生命周期和动画效果
  • 计算锚点位置的位置和尺寸,计算边界矩形信息
  • 计算弹窗位置,检测弹窗是否会超出屏幕边界,自动调整方向
  • 计算箭头偏移量,确保箭头始终和锚点居中对齐
  • 创建BubbleContainer组件,使用Stack布局对其进行定位
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容