likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 9

先上效果图

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

需求来源:

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

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

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

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

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

完整代码如下:

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 组件

直接上代码:

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

三、用法

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

/// 气泡组件用法
/// 爱今天灵眸(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 如下

[√] 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
转载自:https://juejin.cn/post/7340854234894090252
评论
请登录