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

先上效果图

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 作者:知乎木子李

(0)
上一篇 2024年2月29日 下午4:10
下一篇 2024年3月1日 上午10:05

相关推荐

发表回复

登录后才能评论