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布局对其进行定位
相关推荐
C_心欲无痕1 分钟前
ts - 类型收窄
前端·typescript
小雨下雨的雨1 分钟前
Flutter 框架跨平台鸿蒙开发 —— Row & Column 布局之轴线控制艺术
flutter·华为·交互·harmonyos·鸿蒙系统
笔COOL创始人5 分钟前
requestAnimationFrame 动画优化实践指南
前端·javascript·面试
sophie旭8 分钟前
性能监控之首屏性能监控小实践
前端·javascript·性能优化
Amumu1213822 分钟前
React 前端请求
前端·react.js·okhttp
小雨下雨的雨34 分钟前
Flutter 框架跨平台鸿蒙开发 —— Center 控件之完美居中之道
flutter·ui·华为·harmonyos·鸿蒙
3824278271 小时前
JS表单提交:submit事件的关键技巧与注意事项
前端·javascript·okhttp
Kagol1 小时前
深入浅出 TinyEditor 富文本编辑器系列2:快速开始
前端·typescript·开源
小二·1 小时前
Python Web 开发进阶实战:Flask-Login 用户认证与权限管理 —— 构建多用户待办事项系统
前端·python·flask
浩瀚之水_csdn1 小时前
python字符串解析
前端·数据库·python