如何给 Flutter 环形图分段添加独立边框?(含代码)
2025-04-02 12:29:40
给 Flutter 环形图 (Donut Chart) 的分段添加边框
搞 Flutter 自定义绘制的时候,碰到了个不大不小的问题:想给一个 CustomPainter
画出来的环形图(Donut Chart)的每个分段加上独立的边框,结果折腾半天没搞定。原始代码能画出环形图本身,但边框就是加不对。
这是原始代码的样子:
import 'dart:math';
import 'package:flutter/material.dart';
// 假设颜色定义
const Color green = Colors.green;
const Color lightGreen = Colors.lightGreen;
class DonutChartPainter extends CustomPainter {
final double planCalories;
final double leftCalories;
final double thickness = 40; // 圆环的厚度
DonutChartPainter(this.planCalories, this.leftCalories);
@override
void paint(Canvas canvas, Size size) {
final Paint fillPaint = Paint()
..style = PaintingStyle.stroke // 注意这里是 stroke
..strokeWidth = thickness // 厚度在这里设置
..strokeCap = StrokeCap.butt;
// 尝试添加边框的 Paint
// final Paint borderPaint = Paint()
// ..color = Colors.black
// ..style = PaintingStyle.stroke
// ..strokeWidth = 1; // 边框宽度
final double radius = size.width / 2;
final Offset center = Offset(size.width / 2, size.height / 2);
// 确保 planCalories 不为 0,避免除零错误
if (planCalories <= 0) return;
final double usedCalories = planCalories - leftCalories;
final double leftAngle = (leftCalories / planCalories) * 2 * pi;
final double usedAngle = (usedCalories / planCalories) * 2 * pi;
// 这个 separationOffset 是为了让分段之间有点空隙(爆炸效果)
final double separationOffset = 10;
// 计算每个分段稍微偏移后的中心点
final Offset usedCenter = Offset(
center.dx + separationOffset * cos(-pi / 2 + usedAngle / 2),
center.dy + separationOffset * sin(-pi / 2 + usedAngle / 2));
final Offset leftCenter = Offset(
center.dx + separationOffset * cos(-pi / 2 + usedAngle + leftAngle / 2),
center.dy + separationOffset * sin(-pi / 2 + usedAngle + leftAngle / 2));
// 注意:Rect 的 center 用的是偏移后的 center
// 半径也需要减去 thickness / 2,因为 stroke 是从中心向两边扩展的
Rect usedOvalRect = Rect.fromCircle(center: usedCenter, radius: radius - thickness / 2);
Rect leftOvalRect = Rect.fromCircle(center: leftCenter, radius: radius - thickness / 2);
// 绘制“已用”部分
fillPaint.color = green;
canvas.drawArc(usedOvalRect, -pi / 2, usedAngle, false, fillPaint);
// 绘制“剩余”部分
fillPaint.color = lightGreen;
canvas.drawArc(leftOvalRect, -pi / 2 + usedAngle, leftAngle, false, fillPaint);
/*
// 失败的尝试:直接用 borderPaint 绘制同样的 Arc
canvas.drawArc(usedOvalRect, -pi / 2, usedAngle, false, borderPaint);
canvas.drawArc(leftOvalRect, -pi / 2 + usedAngle, leftAngle, false, borderPaint);
*/
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
画出来效果大概是这样(忽略颜色差异,看结构):
如果把上面代码注释里的 borderPaint
和 drawArc
调用放开,尝试画边框,结果是这样:
这显然不是我们想要的。边框没有独立地包围每个分段,而是沿着分段的中心线画了一条细线。
为啥加不上独立的边框?
问题的核心在于 Paint
的 style
和 strokeWidth
属性如何工作,以及 canvas.drawArc
的行为。
PaintingStyle.stroke
和strokeWidth
:在原始代码里,fillPaint
使用了PaintingStyle.stroke
配合一个比较大的strokeWidth
(thickness
) 来模拟“填充”效果。drawArc
画的其实是一条有宽度的 线,这条线的中心轨迹就是由Rect
和startAngle
/sweepAngle
定义的圆弧。宽度thickness
会沿着这条中心线向内外两边平均扩展。- 边框尝试失败的原因 :当你用一个
strokeWidth
为 1 的borderPaint
去绘制 相同 的Rect
和角度时,drawArc
同样是沿着那条中心线去画。只不过这次画的是一条宽度为 1 的细线。所以,这条细线自然就出现在了之前那条粗线(模拟填充的部分)的 正中间。 - 期望的效果 :我们需要的是在粗线(分段)的 外部边缘、内部边缘 以及 两个端点(径向线) 画上边框线,形成一个完整的轮廓。简单地再画一次
drawArc
肯定达不到这个效果。 separationOffset
的影响 :代码里还用了separationOffset
把每个分段的绘制中心向外推了一点,制造了分段间的空隙。这意味着每个分段的圆弧中心点都不同,直接画一个完整的圆环边框再叠加上去也不行。
解决方案:安排上!
要给每个分段加上独立的边框,我们需要更精细地控制绘制过程。不能只画中心弧线,而是要画出分段的完整轮廓。下面提供两种思路。
方法一:精打细算 - 绘制描边弧线 + 径向线
这个方法比较直接:既然边框由四条线(外弧、内弧、起始径向线、结束径向线)组成,那我们就分别把它们画出来。
原理:
- 先像原来一样,用
fillPaint
(style=stroke, strokeWidth=thickness) 画出分段的“填充”部分。 - 然后,准备一个
borderPaint
(style=stroke, strokeWidth=1, color=black)。 - 计算出分段的内半径 (
radius - thickness / 2
) 和外半径 (radius + thickness / 2
)。 - 使用
borderPaint
绘制外边缘的弧线。 - 使用
borderPaint
绘制内边缘的弧线。 - 计算出分段起始角度和结束角度处,内、外半径上的四个点坐标。
- 使用
borderPaint
连接内外起始点,绘制起始径向边框线。 - 使用
borderPaint
连接内外结束点,绘制结束径向边框线。
操作步骤与代码示例:
修改 paint
方法:
@override
void paint(Canvas canvas, Size size) {
final double thickness = 40;
final Paint fillPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = thickness
..strokeCap = StrokeCap.butt; // Butt cap 让端点是平的
// 边框画笔
final Paint borderPaint = Paint()
..color = Colors.black
..style = PaintingStyle.stroke
..strokeWidth = 1.0; // 边框线宽度
final double radius = size.width / 2;
final Offset center = Offset(size.width / 2, size.height / 2);
if (planCalories <= 0) return;
final double usedCalories = planCalories - leftCalories;
final double leftAngle = (leftCalories / planCalories) * 2 * pi;
final double usedAngle = (usedCalories / planCalories) * 2 * pi;
final double separationOffset = 10;
// --- 绘制 "已用" (Used) 分段 ---
final double usedStartAngle = -pi / 2;
final Offset usedCenter = Offset(
center.dx + separationOffset * cos(usedStartAngle + usedAngle / 2),
center.dy + separationOffset * sin(usedStartAngle + usedAngle / 2));
// 内外半径需要根据偏移后的中心和原始半径来算,或者直接用 size 定义的 radius
// 这里为了简单,我们先用 size.width/2 作为基准半径
final double outerRadius = radius + separationOffset; // 近似外半径
final double innerRadius = radius - thickness + separationOffset; // 近似内半径
// 注意: drawArc 使用的 radius 是绘制 stroke 的中心半径
// 所以原始 fillPaint 用的 Rect 需要基于 `radius - thickness / 2`
// 但边框要画在 fill 的边缘
final double fillCenterRadius = radius - thickness / 2; // fill 的中心线半径
final double borderOuterRadius = fillCenterRadius + thickness / 2; // 边框外半径
final double borderInnerRadius = fillCenterRadius - thickness / 2; // 边框内半径
// 1. 绘制 "已用" 填充部分 (基于偏移中心)
Rect usedFillRect = Rect.fromCircle(center: usedCenter, radius: fillCenterRadius);
fillPaint.color = green;
canvas.drawArc(usedFillRect, usedStartAngle, usedAngle, false, fillPaint);
// 2. 绘制 "已用" 边框
// 2a. 外弧线 (需要用偏移后的中心和外半径创建 Rect)
Rect usedOuterBorderRect = Rect.fromCircle(center: usedCenter, radius: borderOuterRadius);
canvas.drawArc(usedOuterBorderRect, usedStartAngle, usedAngle, false, borderPaint);
// 2b. 内弧线 (需要用偏移后的中心和内半径创建 Rect)
Rect usedInnerBorderRect = Rect.fromCircle(center: usedCenter, radius: borderInnerRadius);
canvas.drawArc(usedInnerBorderRect, usedStartAngle, usedAngle, false, borderPaint);
// 2c. 计算径向线的端点 (基于偏移中心 usedCenter)
// 起始角度的点
final Offset outerStartPointUsed = Offset(
usedCenter.dx + borderOuterRadius * cos(usedStartAngle),
usedCenter.dy + borderOuterRadius * sin(usedStartAngle),
);
final Offset innerStartPointUsed = Offset(
usedCenter.dx + borderInnerRadius * cos(usedStartAngle),
usedCenter.dy + borderInnerRadius * sin(usedStartAngle),
);
// 结束角度的点
final double usedEndAngle = usedStartAngle + usedAngle;
final Offset outerEndPointUsed = Offset(
usedCenter.dx + borderOuterRadius * cos(usedEndAngle),
usedCenter.dy + borderOuterRadius * sin(usedEndAngle),
);
final Offset innerEndPointUsed = Offset(
usedCenter.dx + borderInnerRadius * cos(usedEndAngle),
usedCenter.dy + borderInnerRadius * sin(usedEndAngle),
);
// 2d. 绘制径向线
canvas.drawLine(outerStartPointUsed, innerStartPointUsed, borderPaint);
canvas.drawLine(outerEndPointUsed, innerEndPointUsed, borderPaint);
// --- 绘制 "剩余" (Left) 分段 (逻辑类似) ---
final double leftStartAngle = usedStartAngle + usedAngle;
final Offset leftCenter = Offset(
center.dx + separationOffset * cos(leftStartAngle + leftAngle / 2),
center.dy + separationOffset * sin(leftStartAngle + leftAngle / 2));
// 1. 绘制 "剩余" 填充部分 (基于偏移中心)
Rect leftFillRect = Rect.fromCircle(center: leftCenter, radius: fillCenterRadius);
fillPaint.color = lightGreen;
canvas.drawArc(leftFillRect, leftStartAngle, leftAngle, false, fillPaint);
// 2. 绘制 "剩余" 边框
// 2a. 外弧线
Rect leftOuterBorderRect = Rect.fromCircle(center: leftCenter, radius: borderOuterRadius);
canvas.drawArc(leftOuterBorderRect, leftStartAngle, leftAngle, false, borderPaint);
// 2b. 内弧线
Rect leftInnerBorderRect = Rect.fromCircle(center: leftCenter, radius: borderInnerRadius);
canvas.drawArc(leftInnerBorderRect, leftStartAngle, leftAngle, false, borderPaint);
// 2c. 计算径向线的端点 (基于偏移中心 leftCenter)
final Offset outerStartPointLeft = Offset(
leftCenter.dx + borderOuterRadius * cos(leftStartAngle),
leftCenter.dy + borderOuterRadius * sin(leftStartAngle),
);
final Offset innerStartPointLeft = Offset(
leftCenter.dx + borderInnerRadius * cos(leftStartAngle),
leftCenter.dy + borderInnerRadius * sin(leftStartAngle),
);
final double leftEndAngle = leftStartAngle + leftAngle;
final Offset outerEndPointLeft = Offset(
leftCenter.dx + borderOuterRadius * cos(leftEndAngle),
leftCenter.dy + borderOuterRadius * sin(leftEndAngle),
);
final Offset innerEndPointLeft = Offset(
leftCenter.dx + borderInnerRadius * cos(leftEndAngle),
leftCenter.dy + borderInnerRadius * sin(leftEndAngle),
);
// 2d. 绘制径向线
canvas.drawLine(outerStartPointLeft, innerStartPointLeft, borderPaint);
canvas.drawLine(outerEndPointLeft, innerEndPointLeft, borderPaint);
}
注意要点:
- 计算点的坐标时,务必使用
cos
和sin
函数,结合角度(弧度制)和半径。 - 角度的起始点 (
-pi / 2
) 表示 12 点钟方向。 - 每个分段的计算都要基于它自己 偏移后 的中心点 (
usedCenter
,leftCenter
)。 Rect.fromCircle
的radius
参数是指圆弧 中心线 的半径。边框需要画在内外边缘,所以要分别用内半径和外半径来画弧线或计算点。
安全建议/进阶:
- 精度问题: 如果
thickness
很小,或者分段角度很小,浮点数计算可能导致边框线之间有微小的缝隙或重叠。可以稍微调整strokeWidth
或计算方式来优化视觉效果。 - 代码复用: 上面的代码重复度很高。可以封装一个函数
_drawSegment
,接收canvas
,center
,radius
,thickness
,startAngle
,sweepAngle
,fillColor
,borderColor
等参数,内部完成填充和边框的绘制。
方法二:另辟蹊径 - 使用 Path 构建和绘制
CustomPainter
还有一个强大的武器是 Path
对象。我们可以构建一个能精确分段形状(包括内外弧和两条径向边)的 Path
,然后对这个 Path
进行绘制。
原理:
- 创建一个
Path
对象。 - 计算出分段的内外半径以及起始/结束角度对应的四个顶点坐标(同方法一)。
- 使用
path.moveTo
定位到外弧线的起点。 - 使用
path.arcTo
绘制外弧线到外弧线的终点。arcTo
需要一个定义了圆弧边界的Rect
和起止角度。 - 使用
path.lineTo
连接到内弧线的终点。 - 使用
path.arcTo
绘制内弧线回到内弧线的起点。注意arcTo
的sweepAngle
可能是负值,表示反向绘制。或者调整startAngle
。还需要forceMoveTo: false
确保路径连续。 - 使用
path.close()
或path.lineTo
回到外弧线的起点,封闭路径。 - 有了这个分段形状的
Path
后,可以进行两次绘制操作:- 绘制填充 :设置
Paint
的style
为PaintingStyle.fill
,颜色为分段颜色,然后canvas.drawPath(path, fillPaint)
。 - 绘制边框 :设置
Paint
的style
为PaintingStyle.stroke
,颜色为边框色,strokeWidth
为 1,然后canvas.drawPath(path, borderPaint)
。
- 绘制填充 :设置
这种方法有个变化 :原始代码是用粗 stroke
模拟 fill
。如果改用 Path
,你可以选择:
a) 继续用粗 stroke
画填充(画一条中心弧线的 Path
),然后用方法一画边框。
b) 推荐: 用 Path
定义完整形状,然后用 fill
绘制填充,用细 stroke
绘制边框。这样逻辑更清晰。
下面是 采用 Path + Fill + Stroke 的实现思路:
操作步骤与代码示例 (只演示 "已用" 部分):
@override
void paint(Canvas canvas, Size size) {
final double thickness = 40;
// 填充画笔 (现在用 fill 样式)
final Paint fillPaint = Paint()..style = PaintingStyle.fill;
// 边框画笔
final Paint borderPaint = Paint()
..color = Colors.black
..style = PaintingStyle.stroke
..strokeWidth = 1.0;
final double radius = size.width / 2;
final Offset center = Offset(size.width / 2, size.height / 2);
if (planCalories <= 0) return;
final double usedCalories = planCalories - leftCalories;
final double leftCaloriesSafe = max(0.0, leftCalories); // 保证非负
final double planCaloriesSafe = max(1.0, planCalories); // 保证非零
final double totalAngle = 2 * pi;
final double leftAngle = (leftCaloriesSafe / planCaloriesSafe) * totalAngle;
final double usedAngle = max(0.0, totalAngle - leftAngle); // 保证非负
final double separationOffset = 10;
// --- 绘制 "已用" (Used) 分段 using Path ---
final double usedStartAngle = -pi / 2;
final Offset usedCenter = Offset( // 计算偏移中心
center.dx + separationOffset * cos(usedStartAngle + usedAngle / 2),
center.dy + separationOffset * sin(usedStartAngle + usedAngle / 2));
// 计算边框的内外半径 (基于原始基准 radius)
final double outerRadius = radius; // 假设原始半径就是外半径
final double innerRadius = radius - thickness; // 内半径
// 构建 "已用" 分段的 Path
final Path usedPath = Path();
// 定义外弧和内弧所在的 Rect (都基于偏移中心)
final Rect outerRect = Rect.fromCircle(center: usedCenter, radius: outerRadius);
final Rect innerRect = Rect.fromCircle(center: usedCenter, radius: innerRadius);
// 移动到外弧起点
usedPath.moveTo(
usedCenter.dx + outerRadius * cos(usedStartAngle),
usedCenter.dy + outerRadius * sin(usedStartAngle),
);
// 绘制外弧
usedPath.arcTo(outerRect, usedStartAngle, usedAngle, false); // false 表示不强制 moveTo
// 计算内弧终点(即结束角度处内半径上的点)
final double usedEndAngle = usedStartAngle + usedAngle;
final Offset innerEndPoint = Offset(
usedCenter.dx + innerRadius * cos(usedEndAngle),
usedCenter.dy + innerRadius * sin(usedEndAngle),
);
// 绘制到内弧终点的直线 (径向线)
usedPath.lineTo(innerEndPoint.dx, innerEndPoint.dy);
// 绘制内弧 (反向绘制, sweepAngle 为负)
// 注意: arcToPoint 不够直观, 还是用 arcTo 加 LineTo 组合
// 或者先计算好所有点然后用 lineTo 连接? 用 arcTo 更精确
usedPath.arcTo(innerRect, usedEndAngle, -usedAngle, false); // sweep 负值
// 关闭路径 (会自动连回起点, 形成另一条径向线)
usedPath.close();
// 1. 绘制填充
fillPaint.color = green;
canvas.drawPath(usedPath, fillPaint);
// 2. 绘制边框
canvas.drawPath(usedPath, borderPaint);
// --- 绘制 "剩余" (Left) 分段 (同理构建 Path) ---
final double leftStartAngle = usedStartAngle + usedAngle;
final Offset leftCenter = Offset(
center.dx + separationOffset * cos(leftStartAngle + leftAngle / 2),
center.dy + separationOffset * sin(leftStartAngle + leftAngle / 2));
final Path leftPath = Path();
// ... (类似地构建 leftPath)
final Rect leftOuterRect = Rect.fromCircle(center: leftCenter, radius: outerRadius);
final Rect leftInnerRect = Rect.fromCircle(center: leftCenter, radius: innerRadius);
leftPath.moveTo(
leftCenter.dx + outerRadius * cos(leftStartAngle),
leftCenter.dy + outerRadius * sin(leftStartAngle),
);
leftPath.arcTo(leftOuterRect, leftStartAngle, leftAngle, false);
final double leftEndAngle = leftStartAngle + leftAngle;
leftPath.lineTo(
leftCenter.dx + innerRadius * cos(leftEndAngle),
leftCenter.dy + innerRadius * sin(leftEndAngle),
);
leftPath.arcTo(leftInnerRect, leftEndAngle, -leftAngle, false);
leftPath.close();
// 绘制 "剩余"
fillPaint.color = lightGreen;
canvas.drawPath(leftPath, fillPaint);
canvas.drawPath(leftPath, borderPaint);
}
进阶使用技巧:
- Path 复用与变换: 如果不需要
separationOffset
(即所有分段共用一个中心点),你可以创建一个基础的单位圆环分段Path
,然后通过canvas.rotate
和canvas.scale
来绘制不同角度和大小的分段,可能更高效。但对于带偏移的情况,独立构建Path
更直接。 Path.addArc
vsPath.arcTo
:addArc
适合直接添加一段弧线到Path
,但它会隐式地moveTo
到弧的起点。arcTo
则允许你从当前Path
的终点画弧线,更适合连接线段和弧线。- 性能考量: 对于非常复杂的图形,构建
Path
可能比多次调用drawArc
和drawLine
稍微耗资源一点点,但在现代设备上通常差异不大。Path
的优势在于其表达复杂形状的能力和绘制选项(填充/描边)的清晰分离。
选择哪种方法取决于个人偏好和具体场景。方法一更像是对原始思路的直接扩展,计算点位是关键。方法二使用了更高级的 Path
对象,代码结构可能更清晰(特别是填充和边框分离时),但需要熟悉 Path
API。两种方法都能达到为 Flutter 环形图分段添加独立边框的目标。