Flutter实现一个漂亮高可用的气泡框,聊天、弹窗均可用

先上效果图

需求来源:

有客户说我之前写的气泡框不好看(我之前用的是一个圆角矩形,哈哈)。哎,没办法,谁叫是甲方爸爸呢!改吧

给客户的上一个版本,我才用的是 BoxDecoration 来实现的,这次我们换一种吧。采用ShapeDecoration,我们发现其有一个 shape 属性,其类型为 ShapeBorder,因此,我们需要自定义一个 ShapeBorder。

话不多说,开干!(代码均没有第三方依赖,可直接使用)

一、自定义一个Border,从 OutlinedBorder 继承

OutlinedBorder 继承自 ShapeBorder,它为我们处理了边框相关的逻辑,因此,就从它继承吧

完整代码如下:

dart 复制代码
import 'dart:math';
import 'package:flutter/material.dart';

class _BubbleBorderArrowProperties {
  /// 箭头宽度的一半
  final double halfWidth;
  /// 箭头斜边的长度
  final double hypotenuse;
  /// 该斜边在主轴上的投影(水平时为X轴)
  final double projectionOnMain;
  /// 该斜边在纵轴上的投影(水平时为Y轴)
  final double projectionOnCross;
  /// 计算箭头半径在主轴上的投影(水平时为X轴)
  final double arrowProjectionOnMain;
  /// 计算箭头半径尖尖的长度
  final double topLen;
  _BubbleBorderArrowProperties({
    required this.halfWidth,
    required this.hypotenuse,
    required this.projectionOnMain,
    required this.projectionOnCross,
    required this.arrowProjectionOnMain,
    required this.topLen,
  });
}

class BubbleShapeBorder extends OutlinedBorder {
  final BorderRadius borderRadius;
  final AxisDirection arrowDirection;
  final double arrowLength;
  final double arrowWidth;
  final double arrowRadius;
  final double? arrowOffset;
  final Color? fillColor;

  const BubbleShapeBorder({
    super.side,
    required this.arrowDirection,
    this.borderRadius = BorderRadius.zero,
    this.arrowLength = 12,
    this.arrowWidth = 18,
    this.arrowRadius = 3,
    this.arrowOffset,
    this.fillColor,
  });

  @override
  OutlinedBorder copyWith({
    AxisDirection? arrowDirection,
    BorderSide? side,
    BorderRadius? borderRadius,
    double? arrowLength,
    double? arrowWidth,
    double? arrowRadius,
    double? arrowOffset,
    Color? fillColor,
  }) {
    return BubbleShapeBorder(
      arrowDirection: arrowDirection ?? this.arrowDirection,
      side: side ?? this.side,
      borderRadius: borderRadius ?? this.borderRadius,
      arrowLength: arrowLength ?? this.arrowLength,
      arrowWidth: arrowWidth ?? this.arrowWidth,
      arrowRadius: arrowRadius ?? this.arrowRadius,
      arrowOffset: arrowOffset ?? this.arrowOffset,
      fillColor: fillColor ?? this.fillColor,
    );
  }

  @override
  EdgeInsetsGeometry get dimensions => EdgeInsets.zero;

  @override
  Path getInnerPath(Rect rect, {TextDirection? textDirection}) {
    return _buildPath(rect);
  }

  @override
  Path getOuterPath(Rect rect, {TextDirection? textDirection}) {
    return _buildPath(rect);
  }
  _BubbleBorderArrowProperties _calculateArrowProperties() {
    final arrowHalfWidth = arrowWidth / 2;
    final double hypotenuse =
        sqrt(arrowLength * arrowLength + arrowHalfWidth * arrowHalfWidth);
    final double projectionOnMain = arrowHalfWidth * arrowRadius / hypotenuse;
    final double projectionOnCross =
        projectionOnMain * arrowLength / arrowHalfWidth;
    final double arrowProjectionOnMain = arrowLength * arrowRadius / hypotenuse;
    final double pointArrowTopLen =
        arrowProjectionOnMain * arrowLength / arrowHalfWidth;
    return _BubbleBorderArrowProperties(
      halfWidth: arrowHalfWidth,
      hypotenuse: hypotenuse,
      projectionOnMain: projectionOnMain,
      projectionOnCross: projectionOnCross,
      arrowProjectionOnMain: arrowProjectionOnMain,
      topLen: pointArrowTopLen,
    );
  }

  /// 核心逻辑:构建路径
  /// 计算方向为:上、右、下、左
  /// 
  /// 爱今天灵眸(ijtkj.cn),一款可根据天气改变系统显示模式的软件,期待各位有钱的码友支持
  Path _buildPath(Rect rect) {
    final path = Path();
    EdgeInsets padding = EdgeInsets.zero;
    if (arrowDirection == AxisDirection.up) {
      padding = EdgeInsets.only(top: arrowLength);
    } else if (arrowDirection == AxisDirection.right) {
      padding = EdgeInsets.only(right: arrowLength);
    } else if (arrowDirection == AxisDirection.down) {
      padding = EdgeInsets.only(bottom: arrowLength);
    } else if (arrowDirection == AxisDirection.left) {
      padding = EdgeInsets.only(left: arrowLength);
    }
    final nRect = Rect.fromLTRB(
        rect.left + padding.left,
        rect.top + padding.top,
        rect.right - padding.right,
        rect.bottom - padding.bottom);

    final arrowProp = _calculateArrowProperties();

    final startPoint = Offset(nRect.left + borderRadius.topLeft.x, nRect.top);

    path.moveTo(startPoint.dx, startPoint.dy);
    // 箭头在上边
    if (arrowDirection == AxisDirection.up) {
      Offset pointCenter =
          Offset(nRect.left + (arrowOffset ?? nRect.width / 2), 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);

    // 箭头在右边
    if (arrowDirection == AxisDirection.right) {
      Offset pointCenter =
          Offset(nRect.right, nRect.top + (arrowOffset ?? nRect.height / 2));
      Offset pointStart =
          Offset(nRect.right, pointCenter.dy - arrowProp.halfWidth);
      Offset pointArrow = Offset(rect.right, pointCenter.dy);
      Offset pointEnd =
          Offset(nRect.right, pointCenter.dy + arrowProp.halfWidth);

      // 下面计算开始的圆弧
      {
        Offset pointStartArcBegin =
            Offset(pointStart.dx, pointStart.dy - arrowRadius);
        Offset pointStartArcEnd = Offset(
            pointStart.dx + arrowProp.projectionOnCross,
            pointStart.dy + arrowProp.projectionOnMain);
        path.lineTo(pointStartArcBegin.dx, pointStartArcBegin.dy);
        path.quadraticBezierTo(pointStart.dx, pointStart.dy,
            pointStartArcEnd.dx, pointStartArcEnd.dy);
      }
      // 计算中间箭头的圆弧
      {
        Offset pointArrowArcBegin = Offset(pointArrow.dx - arrowProp.topLen,
            pointArrow.dy - arrowProp.arrowProjectionOnMain);
        Offset pointArrowArcEnd = Offset(pointArrow.dx - arrowProp.topLen,
            pointArrow.dy + arrowProp.arrowProjectionOnMain);
        path.lineTo(pointArrowArcBegin.dx, pointArrowArcBegin.dy);
        path.quadraticBezierTo(pointArrow.dx, pointArrow.dy,
            pointArrowArcEnd.dx, pointArrowArcEnd.dy);
      }
      // 下面计算结束的圆弧
      {
        Offset pointEndArcBegin = Offset(
            pointEnd.dx + arrowProp.projectionOnCross,
            pointEnd.dy - arrowProp.projectionOnMain);
        Offset pointEndArcEnd = Offset(pointEnd.dx, pointEnd.dy + arrowRadius);
        path.lineTo(pointEndArcBegin.dx, pointEndArcBegin.dy);
        path.quadraticBezierTo(
            pointEnd.dx, pointEnd.dy, pointEndArcEnd.dx, pointEndArcEnd.dy);
      }
    }

    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);

    // 箭头在下边
    if (arrowDirection == AxisDirection.down) {
      Offset pointCenter =
          Offset(nRect.left + (arrowOffset ?? nRect.width / 2), nRect.bottom);
      Offset pointStart =
          Offset(pointCenter.dx + arrowProp.halfWidth, nRect.bottom);
      Offset pointArrow = Offset(pointCenter.dx, rect.bottom);
      Offset pointEnd =
          Offset(pointCenter.dx - arrowProp.halfWidth, nRect.bottom);

      // 下面计算开始的圆弧
      {
        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.left + borderRadius.bottomLeft.x, nRect.bottom);
    // bottomLeft radius
    path.arcToPoint(
        Offset(nRect.left, nRect.bottom - borderRadius.bottomRight.y),
        radius: borderRadius.bottomLeft,
        rotation: 90);

    // 箭头在左边
    if (arrowDirection == AxisDirection.left) {
      Offset pointCenter =
          Offset(nRect.left, nRect.top + (arrowOffset ?? nRect.height / 2));
      Offset pointStart =
          Offset(nRect.left, pointCenter.dy + arrowProp.halfWidth);
      Offset pointArrow = Offset(rect.left, pointCenter.dy);
      Offset pointEnd =
          Offset(nRect.left, pointCenter.dy - arrowProp.halfWidth);

      // 下面计算开始的圆弧
      {
        Offset pointStartArcBegin =
            Offset(pointStart.dx, pointStart.dy + arrowRadius);
        Offset pointStartArcEnd = Offset(
            pointStart.dx - arrowProp.projectionOnCross,
            pointStart.dy - arrowProp.projectionOnMain);
        path.lineTo(pointStartArcBegin.dx, pointStartArcBegin.dy);
        path.quadraticBezierTo(pointStart.dx, pointStart.dy,
            pointStartArcEnd.dx, pointStartArcEnd.dy);
      }
      // 计算中间箭头的圆弧
      {
        Offset pointArrowArcBegin = Offset(pointArrow.dx + arrowProp.topLen,
            pointArrow.dy + arrowProp.arrowProjectionOnMain);
        Offset pointArrowArcEnd = Offset(pointArrow.dx + arrowProp.topLen,
            pointArrow.dy - arrowProp.arrowProjectionOnMain);
        path.lineTo(pointArrowArcBegin.dx, pointArrowArcBegin.dy);
        path.quadraticBezierTo(pointArrow.dx, pointArrow.dy,
            pointArrowArcEnd.dx, pointArrowArcEnd.dy);
      }
      // 下面计算结束的圆弧
      {
        Offset pointEndArcBegin = Offset(
            pointEnd.dx - arrowProp.projectionOnCross,
            pointEnd.dy + arrowProp.projectionOnMain);
        Offset pointEndArcEnd = Offset(pointEnd.dx, pointEnd.dy - arrowRadius);
        path.lineTo(pointEndArcBegin.dx, pointEndArcBegin.dy);
        path.quadraticBezierTo(
            pointEnd.dx, pointEnd.dy, pointEndArcEnd.dx, pointEndArcEnd.dy);
      }
    }

    path.lineTo(nRect.left, nRect.top + borderRadius.topLeft.y);
    path.arcToPoint(startPoint, radius: borderRadius.topLeft, rotation: 90);

    return path;
  }

  @override
  void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {
    if (fillColor == null && side == BorderSide.none) {
      return;
    }

    final path = _buildPath(rect);
    final Paint paint = Paint()
      ..color = side.color
      ..style = PaintingStyle.stroke;
    if (fillColor != null) {
      paint.color = fillColor!;
      paint.style = PaintingStyle.fill;
      canvas.drawPath(path, paint);
    }
    if (side != BorderSide.none) {
      paint.color = side.color;
      paint.strokeWidth = side.width;
      paint.style = PaintingStyle.stroke;
      canvas.drawPath(path, paint);
    }
  }

  @override
  ShapeBorder scale(double t) {
    return BubbleShapeBorder(
      arrowDirection: arrowDirection,
      side: side.scale(t),
      borderRadius: borderRadius * t,
      arrowLength: arrowLength * t,
      arrowWidth: arrowWidth * t,
      arrowRadius: arrowRadius * t,
      arrowOffset: (arrowOffset ?? 0) * t,
    );
  }

  @override
  bool operator ==(Object other) {
    if (other.runtimeType != runtimeType) {
      return false;
    }
    return other is BubbleShapeBorder &&
        other.side == side &&
        other.borderRadius == borderRadius &&
        other.arrowLength == arrowLength &&
        other.arrowWidth == arrowWidth &&
        other.arrowRadius == arrowRadius &&
        other.arrowDirection == arrowDirection &&
        other.arrowOffset == arrowOffset &&
        other.fillColor == fillColor;
  }

  @override
  int get hashCode => Object.hash(
        side,
        borderRadius,
        arrowLength,
        arrowWidth,
        arrowRadius,
        arrowDirection,
        arrowOffset,
        fillColor,
      );
}

其核心在于 Path _buildPath(Rect rect) 方法,该方法用于计算出一个合适的气泡框路径

二、使用我们自定义的Border 封装一个 BubbleWidget 组件

直接上代码:

dart 复制代码
import 'package:flutter/material.dart';
import 'borders/bubble_shape_border.dart';

/// 气泡组件
/// 爱今天灵眸(ijtkj.cn),一款可根据天气改变系统显示模式的软件,期待各位有钱的码友支持
class BubbleWidget extends StatelessWidget {
  final BorderSide border;
  final AxisDirection arrowDirection;
  final BorderRadius? borderRadius;
  final double arrowLength;
  final double arrowWidth;
  final double? arrowOffset;
  final double arrowRadius;
  final Color? backgroundColor;
  final EdgeInsets? padding;
  final WidgetBuilder contentBuilder;
  final List<BoxShadow>? shadows;
  final EdgeInsetsGeometry? margin;

  const BubbleWidget({
    super.key,
    required this.arrowDirection,
    this.arrowOffset,
    required this.contentBuilder,
    this.border = BorderSide.none,
    this.borderRadius,
    this.arrowLength = 10,
    this.arrowWidth = 17,
    this.arrowRadius = 3,
    this.backgroundColor,
    this.shadows,
    this.padding,
    this.margin,
  });

  @override
  Widget build(BuildContext context) {
    EdgeInsets bubblePadding = EdgeInsets.zero;
    if (arrowDirection == AxisDirection.up) {
      bubblePadding = EdgeInsets.only(top: arrowLength);
    } else if (arrowDirection == AxisDirection.down) {
      bubblePadding = EdgeInsets.only(bottom: arrowLength);
    } else if (arrowDirection == AxisDirection.left) {
      bubblePadding = EdgeInsets.only(left: arrowLength);
    } else if (arrowDirection == AxisDirection.right) {
      bubblePadding = EdgeInsets.only(right: arrowLength);
    }
    return Container(
      margin: margin,
      decoration: ShapeDecoration(
        shape: BubbleShapeBorder(
          side: border,
          arrowDirection: arrowDirection,
          borderRadius: borderRadius ?? BorderRadius.circular(4),
          arrowLength: arrowLength,
          arrowWidth: arrowWidth,
          arrowRadius: arrowRadius,
          arrowOffset: arrowOffset,
          fillColor: backgroundColor ?? const Color.fromARGB(255, 65, 65, 65),
        ),
        shadows: shadows,
      ),
      child: Padding(
        padding: bubblePadding.add(padding ?? EdgeInsets.zero),
        child: contentBuilder(context),
      ),
    );
  }
}

三、用法

在合适的位置引入以下代码即可

dart 复制代码
/// 气泡组件用法
/// 爱今天灵眸(ijtkj.cn),一款可根据天气改变系统显示模式的软件,期待各位有钱的码友支持
BubbleWidget(
    arrowDirection: AxisDirection.left,
    arrowOffset: 22,
    arrowLength: 8,
    arrowRadius: 4,
    arrowWidth: 14,
    padding: const EdgeInsets.all(12),
    borderRadius: BorderRadius.circular(8),
    backgroundColor: context.cardColor,
    margin: const EdgeInsets.only(right: 150),
    contentBuilder: (context) {
        return const SelectableText(
        '月覆盖用户超过2.5亿,拥有大量高忠诚度、高质量用户群所产生的超强人气和互动原创内容,形成了独具天涯特色的网民文化,其开放、包容、充满人文关怀的特色受到国内网民乃至全球华人的推崇',
        contextMenuBuilder: defaultDesktopSelectionContextMenuBuilder,
        );
    },
)

希望各位码友用得愉快

flutter doctor 如下

powershell 复制代码
[√] Flutter (Channel stable, 3.19.1, on Microsoft Windows [版本 10.0.19045.4046], locale zh-CN)
[√] Windows Version (Installed version of Windows is version 10 or higher)
[√] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
[√] Chrome - develop for the web
[√] Visual Studio - develop Windows apps (Visual Studio Community 2022 17.9.1)
[√] Android Studio (version 2023.1)
[√] VS Code (version 1.86.2)
[√] Connected device (3 available)
[√] Network resources
相关推荐
桂月二二33 分钟前
探索前端开发中的 Web Vitals —— 提升用户体验的关键技术
前端·ux
hunter2062062 小时前
ubuntu向一个pc主机通过web发送数据,pc端通过工具直接查看收到的数据
linux·前端·ubuntu
qzhqbb2 小时前
web服务器 网站部署的架构
服务器·前端·架构
刻刻帝的海角2 小时前
CSS 颜色
前端·css
浪浪山小白兔3 小时前
HTML5 新表单属性详解
前端·html·html5
lee5763 小时前
npm run dev 时直接打开Chrome浏览器
前端·chrome·npm
2401_897579653 小时前
AI赋能Flutter开发:ScriptEcho助你高效构建跨端应用
前端·人工智能·flutter
limit for me4 小时前
react上增加错误边界 当存在错误时 不会显示白屏
前端·react.js·前端框架
浏览器爱好者4 小时前
如何构建一个简单的React应用?
前端·react.js·前端框架
qq_392794484 小时前
前端缓存策略:强缓存与协商缓存深度剖析
前端·缓存