返回

如何给 Flutter 环形图分段添加独立边框?(含代码)

IOS

给 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;
}

画出来效果大概是这样(忽略颜色差异,看结构):

原始图示

如果把上面代码注释里的 borderPaintdrawArc 调用放开,尝试画边框,结果是这样:

错误边框图示

这显然不是我们想要的。边框没有独立地包围每个分段,而是沿着分段的中心线画了一条细线。

为啥加不上独立的边框?

问题的核心在于 PaintstylestrokeWidth 属性如何工作,以及 canvas.drawArc 的行为。

  1. PaintingStyle.strokestrokeWidth :在原始代码里,fillPaint 使用了 PaintingStyle.stroke 配合一个比较大的 strokeWidth (thickness) 来模拟“填充”效果。drawArc 画的其实是一条有宽度的 线,这条线的中心轨迹就是由 RectstartAngle/sweepAngle 定义的圆弧。宽度 thickness 会沿着这条中心线向内外两边平均扩展。
  2. 边框尝试失败的原因 :当你用一个 strokeWidth 为 1 的 borderPaint 去绘制 相同Rect 和角度时,drawArc 同样是沿着那条中心线去画。只不过这次画的是一条宽度为 1 的细线。所以,这条细线自然就出现在了之前那条粗线(模拟填充的部分)的 正中间
  3. 期望的效果 :我们需要的是在粗线(分段)的 外部边缘内部边缘 以及 两个端点(径向线) 画上边框线,形成一个完整的轮廓。简单地再画一次 drawArc 肯定达不到这个效果。
  4. separationOffset 的影响 :代码里还用了 separationOffset 把每个分段的绘制中心向外推了一点,制造了分段间的空隙。这意味着每个分段的圆弧中心点都不同,直接画一个完整的圆环边框再叠加上去也不行。

解决方案:安排上!

要给每个分段加上独立的边框,我们需要更精细地控制绘制过程。不能只画中心弧线,而是要画出分段的完整轮廓。下面提供两种思路。

方法一:精打细算 - 绘制描边弧线 + 径向线

这个方法比较直接:既然边框由四条线(外弧、内弧、起始径向线、结束径向线)组成,那我们就分别把它们画出来。

原理:

  1. 先像原来一样,用 fillPaint (style=stroke, strokeWidth=thickness) 画出分段的“填充”部分。
  2. 然后,准备一个 borderPaint (style=stroke, strokeWidth=1, color=black)。
  3. 计算出分段的内半径 (radius - thickness / 2) 和外半径 (radius + thickness / 2)。
  4. 使用 borderPaint 绘制外边缘的弧线。
  5. 使用 borderPaint 绘制内边缘的弧线。
  6. 计算出分段起始角度和结束角度处,内、外半径上的四个点坐标。
  7. 使用 borderPaint 连接内外起始点,绘制起始径向边框线。
  8. 使用 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);
}

注意要点:

  • 计算点的坐标时,务必使用 cossin 函数,结合角度(弧度制)和半径。
  • 角度的起始点 (-pi / 2) 表示 12 点钟方向。
  • 每个分段的计算都要基于它自己 偏移后 的中心点 (usedCenter, leftCenter)。
  • Rect.fromCircleradius 参数是指圆弧 中心线 的半径。边框需要画在内外边缘,所以要分别用内半径和外半径来画弧线或计算点。

安全建议/进阶:

  • 精度问题: 如果 thickness 很小,或者分段角度很小,浮点数计算可能导致边框线之间有微小的缝隙或重叠。可以稍微调整 strokeWidth 或计算方式来优化视觉效果。
  • 代码复用: 上面的代码重复度很高。可以封装一个函数 _drawSegment,接收 canvas, center, radius, thickness, startAngle, sweepAngle, fillColor, borderColor 等参数,内部完成填充和边框的绘制。

方法二:另辟蹊径 - 使用 Path 构建和绘制

CustomPainter 还有一个强大的武器是 Path 对象。我们可以构建一个能精确分段形状(包括内外弧和两条径向边)的 Path,然后对这个 Path 进行绘制。

原理:

  1. 创建一个 Path 对象。
  2. 计算出分段的内外半径以及起始/结束角度对应的四个顶点坐标(同方法一)。
  3. 使用 path.moveTo 定位到外弧线的起点。
  4. 使用 path.arcTo 绘制外弧线到外弧线的终点。arcTo 需要一个定义了圆弧边界的 Rect 和起止角度。
  5. 使用 path.lineTo 连接到内弧线的终点。
  6. 使用 path.arcTo 绘制内弧线回到内弧线的起点。注意 arcTosweepAngle 可能是负值,表示反向绘制。或者调整 startAngle。还需要 forceMoveTo: false 确保路径连续。
  7. 使用 path.close()path.lineTo 回到外弧线的起点,封闭路径。
  8. 有了这个分段形状的 Path 后,可以进行两次绘制操作:
    • 绘制填充 :设置 PaintstylePaintingStyle.fill,颜色为分段颜色,然后 canvas.drawPath(path, fillPaint)
    • 绘制边框 :设置 PaintstylePaintingStyle.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.rotatecanvas.scale 来绘制不同角度和大小的分段,可能更高效。但对于带偏移的情况,独立构建 Path 更直接。
  • Path.addArc vs Path.arcToaddArc 适合直接添加一段弧线到 Path,但它会隐式地 moveTo 到弧的起点。arcTo 则允许你从当前 Path 的终点画弧线,更适合连接线段和弧线。
  • 性能考量: 对于非常复杂的图形,构建 Path 可能比多次调用 drawArcdrawLine 稍微耗资源一点点,但在现代设备上通常差异不大。Path 的优势在于其表达复杂形状的能力和绘制选项(填充/描边)的清晰分离。

选择哪种方法取决于个人偏好和具体场景。方法一更像是对原始思路的直接扩展,计算点位是关键。方法二使用了更高级的 Path 对象,代码结构可能更清晰(特别是填充和边框分离时),但需要熟悉 Path API。两种方法都能达到为 Flutter 环形图分段添加独立边框的目标。