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

1,439 阅读4分钟

前言

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

GitHub地址: github.com/kongpf8848/…

截图

screenshot_1.pngscreenshot_2.pngscreenshot_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();
      }
    )
    

参数说明

参数名类型默认值描述
anchorContextBuildContext锚点上下文
childWidget弹窗内容,用户自定义
directionBubbleDirectionBubbleDirection.bottomCenter弹窗方向
colorColorColors.white弹窗颜色
radiusBorderRadiusBorderRadius.zero弹窗圆角半径
borderBorderSideBorderSide.none弹窗边框
shadowsList<BoxShadow>?弹窗阴影
paddingEdgeInsetsGeometry?弹窗内边距
gapdouble0.0弹窗距离锚点的间距
maskColorColor?null遮罩层颜色
dismissOnTouchOutsidebooltrue点击弹窗外部是否关闭弹窗
miniEdgeMarginEdgeInsetsEdgeInsets.zero弹窗距离屏幕边缘最小间距
showArrowbooltrue是否显示箭头
arrowWidthDouble10.0箭头宽度
arrowHeightDouble5.0箭头高度
arrowRadiusDouble0.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布局对其进行定位