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布局对其进行定位
相关推荐
摆烂大大王1 小时前
玩转 OpenClaw:用 TaskFlow + Heartbeat 打造自动化工作流
前端·人工智能·自动化
zhangxingchao1 小时前
AI 大模型核心六:量化、Workflow 与 Agent、多轮 RAG
前端·人工智能·后端
梦想的颜色2 小时前
TypeScript 完全指南(上):从零开始掌握类型系统
前端·typescript
之歆2 小时前
Day01_ES6+ 专业指南:从基础到实战的现代JavaScript开发(下)
前端·javascript·es6
lichenyang4532 小时前
鸿蒙 MVVM 实战:从 Demo 到工程化,聊聊登录、状态管理与埋点系统设计
前端
洛宇2 小时前
一个口语 skill,灵感居然来自2021年的那个夏天
人工智能·程序员·github
IT_陈寒2 小时前
Vite打包时遇到的坑,原来问题出在这里
前端·人工智能·后端
kyriewen3 小时前
AI生成代码快如闪电,但我修了三个小时——它到底帮了谁?
前端·javascript·ai编程
ayqy贾杰4 小时前
基层管理的三板斧,在AI时代行不通了
前端·后端·团队管理
Apifox4 小时前
Apifox 5 月更新|Postman 导入优化、Runner 支持非 root 运行、请求代码自动带鉴权
前端·后端·安全