Flutter中的Canvas绘图

https://zhuanlan.zhihu.com/p/424768534?utm_id=0

前言

在flutter日常开发中,有时候会遇到图表绘制相关的需求(如以下样式的表格):

或者是基础库无法实现的一些控件(如以下样式的进度条控件):

或者是一些图片遮罩的处理(如下图中用波浪线实现了对图片的裁剪功能):

这些较为复杂的情形都可以通过Canvas绘图来实现相关的效果。

Canvas简介

几乎所有的UI系统都会提供一个自绘UI的接口,狭义上的canvas一词指HTML中的canvas标签。canvas本身英文译为画布,广义上它在计算机领域里指的是所有使用画布来绘制UI这种方式。在Android和iOS它都有类似的实现。 

既然有画布,那肯定也有画笔paint,canvas需要与paint一起使用。canvas内部封装了一些基本绘制的API,我们可以通过canvas结合paint来绘制各种自定义图形。在Flutter中提供了一个CustomPaint组件,它通过接收CustomPainter的子类来实现绘制业务。

const CustomPaint({
  Key? key,
  this.painter,
  this.foregroundPainter,
  this.size = Size.zero,
  this.isComplex = false,
  this.willChange = false,
  Widget? child,
})
painter 背景画笔
foregroundPainter 前景画笔
size 当child为null时,代表默认绘制区域大小,如果有child则忽略此参数,画布尺寸则为child尺寸。如果有child但是想指定画布为特定大小,可以使用SizeBox包裹CustomPaint实现。
isComplex 是否复杂的绘制,如果是,Flutter会应用一些缓存策略来减少重复渲染的开销。
willChange 和isComplex配合使用,当启用缓存时,该属性代表在下一帧中绘制是否会改变。
child 子节点

CustomPainter是一个抽象类,我们需要继承它来实现具体的绘制业务。其中,paint方法实现具体的绘制功能,而shouldRepaint则决定UI树重新build时是否有必要重绘。

class Painter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // TODO: implement paint
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) {
    // TODO: implement shouldRepaint
    throw UnimplementedError();
  }
}

Canvas基础知识

  • 绘制坐标系

canvas是2d绘制引擎,上图是canvas绘制区域内的坐标系,圆点在左上角,水平向右为X轴正方向,垂直向下为Y轴正方向。

  • Paint(画笔)
Paint paint = Paint()
    ..isAntiAlias = true
    ..color = Colors.pink
    ..blendMode = BlendMode.colorDodge
    ..strokeWidth = 10
    ..style = PaintingStyle.fill;
isAntiAlias 抗锯齿是否开启
color 画笔颜色
blendMode 混合模式
strokeWidth 画笔宽度
style 样式
PaintingStyle.fill 填充模式(默认值)
PaintingStyle.stroke 线条模式
strokeCap 定义画笔端点形状
StrokeCap.butt 无形状(默认值)
StrokeCap.round 圆形
StrokeCap.square 正方形
strokeJoin 定义线段交接时的形状
StrokeJoin.miter(默认值),当两条线段夹角小于30°时,StrokeJoin.mitter将会变成StrokeJoin.bevel
StrokeJoin.bevel 斜面
StrokeJoin.round 圆角
动图封面
 

strokeJoin.miter样式

动图封面
 

strokeJoin.bevel样式

动图封面
 

strokeJoin.round样式

  • 绘制相关的api

(1)矩形-drawRect

class DrawPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint();
    paint.style = PaintingStyle.stroke;
    paint.strokeWidth = 10;
    canvas.drawRect(const Rect.fromLTWH(0, 0, 200, 150), paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

void drawRect(Rect rect, Paint paint)

rect 矩形区域
paint 画笔

(2)圆角矩形-drawRRect

class DrawPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint();
    paint.style = PaintingStyle.stroke;
    paint.strokeWidth = 10;
    canvas.drawRRect(RRect.fromRectAndRadius(const Rect.fromLTWH(0, 0, 200, 150), const Radius.circular(10)), paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

void drawRRect(RRect rrect, Paint paint)

rrect 圆角矩形区域
paint 画笔

(3)圆-drawCircle

class DrawPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint();
    paint.style = PaintingStyle.stroke;
    paint.strokeWidth = 10;
    canvas.drawCircle(const Offset(100, 100), 75, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

void drawCircle(Offset centerPoint, double radius, Paint paint)

centerPoint 中心点
radius 半径
paint 画笔

(4)椭圆-drawOval

class DrawPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint();
    paint.style = PaintingStyle.stroke;
    paint.strokeWidth = 10;
    canvas.drawOval(const Rect.fromLTWH(0, 0, 150, 100), paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

void drawOval(Rect rect, Paint paint)

rect 椭圆所在矩形区域
paint 画笔

(5)扇形-drawArc

import 'dart:math';

class DrawPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint();
    paint.style = PaintingStyle.stroke;
    paint.style = PaintingStyle.fill;
    canvas.drawArc(const Rect.fromLTWH(0, 0, 100, 100), 0, pi * 0.3, true, paint);
    canvas.drawArc(const Rect.fromLTWH(100, 0, 100, 100), 0, pi * 0.5, false, paint);
    canvas.drawArc(const Rect.fromLTWH(0, 100, 150, 100), 0, pi * 0.3, true, paint);
    canvas.drawArc(const Rect.fromLTWH(100, 100, 150, 100), 0, pi * 0.5, false, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

void drawArc(Rect rect, double startAngle, double sweepAngle, bool useCenter, Paint paint)

rect 扇形所在矩形区域
startAngle 开始角度
sweepAngle 增量角度
useCenter 是否使用中心点(下图中左侧图形均使用了中心点,右侧图形均没有使用中心点)
paint 画笔

(6)点-drawPoints

class DrawPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint();
    paint.color = Colors.black;
    paint.strokeWidth = 10;
    canvas.drawPoints(PointMode.points, const [Offset(0, 0), Offset(10, 50), Offset(80, 50), Offset(80, 100)], paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

void drawPoints(PointMode pointMode, List<Offset> points, Paint paint)

pointMode 扇形所在矩形区域
points PointMode.points 点模式
PointMode.lines 线段模式

PointMode.polygon 连线模式
paint 画笔

PointMode.points 点模式

PointMode.lines 线段模式

PointMode.polygon 连线模式

(7)线-drawLine

class DrawPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint();
    paint.color = Colors.black;
    paint.strokeWidth = 10;
    canvas.drawLine(const Offset(0, 0), const Offset(100, 100), paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

void drawLine(Offset p1, Offset p2, Paint paint)

p1 起始点
p2 结束点
paint 画笔

(8)路径-drawPath

class DrawPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Paint paint = Paint();
    paint.color = Colors.black;
    paint.strokeWidth = 10;
    paint.style = PaintingStyle.stroke;
    Path path = Path();
    path.moveTo(0, 0);
    path.lineTo(100, 0);
    path.cubicTo(100, 0, 50, 50, 70, 90);
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

void drawPath(Path path, Paint paint)

path 路径
paint 画笔

(9)裁剪-clipPath

class DrawPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Path path = Path();
    path.moveTo(0, 100);
    path.cubicTo(100, 50, 100, 150, 200, 100);
    path.lineTo(200, 200);
    path.lineTo(0, 200);
    path.close();
    canvas.clipPath(path);

    Paint paint = Paint();
    canvas.drawRect(const Rect.fromLTWH(0, 0, 200, 200), paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

void clipPath(Path path, {bool doAntiAlias = true})

path 路径
doAntiAlias 是否开启抗锯齿(默认值为true)

(10)文本-drawParagraph

class DrawPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    final textStyle = ui.TextStyle(color: Colors.black, fontSize: 30);
    final paragraphStyle = ui.ParagraphStyle(textDirection: TextDirection.ltr);
    final paragraphBuilder = ui.ParagraphBuilder(paragraphStyle);
    paragraphBuilder.pushStyle(textStyle);
    paragraphBuilder.addText('Hello, world');
    const constraints = ui.ParagraphConstraints(width: 300);
    final paragraph = paragraphBuilder.build();
    paragraph.layout(constraints);
    canvas.drawParagraph(paragraph, const Offset(0, 0));
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

void drawParagraph(Paragraph paragraph, Offset offset)

paragraph 段落对象
offset 坐标值

Canvas绘制表格

表格的绘制实际上是多种绘制api的组合使用,如上图所示,绘制时需要用到矩形、线以及文本的绘制。绘制出上图的效果需要对绘制的步骤进行一定的拆分,分为5步: 

  1. 绘制最外层矩形框(矩形的stroke模式) 
  2. 绘制交叉线(线的stroke模式) 
  3. 绘制填充颜色的矩形条(矩形的fill模式) 
  4. 绘制带外边框颜色的矩形条(矩形的stroke模式) 
  5. 绘制文本,并在最底下绘制一条黑线

以下是绘制表格的完整代码:

import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';

class DrawChart extends StatelessWidget {
  const DrawChart({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('绘制表格')),
      backgroundColor: Colors.white,
      body: Center(
        child: SizedBox(
          width: 400,
          height: 400,
          child: CustomPaint(painter: DrawPainter()),
        ),
      ),
    );
  }
}

class DrawPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    // 绘制外边框
    canvas.drawRect(
        Rect.fromLTWH(0, 0, size.width, size.height),
        Paint()
          ..style = PaintingStyle.stroke
          ..strokeWidth = 1
          ..color = Colors.grey.withOpacity(0.1));

    // 绘制线条
    double offset = 80;
    for (int i = 0; i < 4; i++) {
      drawVerticalLine(canvas, size, offsetX: offset, color: Colors.grey.withOpacity(0.1), strokeWidth: 1);
      drawHorizontalLine(canvas, size, offsetY: offset, color: Colors.grey.withOpacity(0.1), strokeWidth: 1);
      offset += 80;
    }

    for (int i = 0; i < 5; i++) {
      // 随机高度
      var height = Random().nextInt(size.height.toInt()).toDouble();
      // 随机颜色
      var color = ([...Colors.primaries]..shuffle()).first;
      // 矩形框fill模式
      canvas.drawRect(
          transformRect(size, index: i, height: height),
          Paint()
            ..style = PaintingStyle.fill
            ..strokeWidth = 1
            ..color = color.withOpacity(0.2));

      // 矩形框stroke模式
      canvas.drawRect(
          transformRect(size, index: i, height: height),
          Paint()
            ..style = PaintingStyle.stroke
            ..strokeWidth = 2
            ..color = color.withOpacity(0.2));

      // 绘制文本
      drawText(canvas, size,
          text: height.toInt().toString(), fontSize: 18, fixWidth: 40, offset: Offset(20 + i * 80, size.height - height - 25), color: color);
    }

    // 绘制底部线条
    drawHorizontalLine(canvas, size, offsetY: size.height, color: Colors.grey, strokeWidth: 2);
  }

  Rect transformRect(Size size, {required int index, required double height}) {
    return Rect.fromLTWH(20 + index * 80, size.height - height, 40, height);
  }

  // 绘制文本
  void drawText(Canvas canvas, Size size,
      {required String text, required Offset offset, double fontSize = 20, double? fixWidth, Color color = Colors.black}) {
    final textStyle = ui.TextStyle(color: color, fontSize: fontSize);
    final paragraphStyle = ui.ParagraphStyle(textDirection: TextDirection.ltr, textAlign: TextAlign.center);
    final paragraphBuilder = ui.ParagraphBuilder(paragraphStyle);
    paragraphBuilder.pushStyle(textStyle);
    paragraphBuilder.addText(text);
    var constraints = ui.ParagraphConstraints(width: fixWidth ?? size.width);
    final paragraph = paragraphBuilder.build();
    paragraph.layout(constraints);
    canvas.drawParagraph(paragraph, offset);
  }

  // 水平方向实线绘制
void drawHorizontalLine(Canvas canvas, Size size, {required double offsetY, required Color color, required double strokeWidth}) {
    Paint paint = Paint();
    paint.style = PaintingStyle.stroke;
    paint.strokeWidth = strokeWidth;
    paint.color = color;
    canvas.drawLine(Offset(0, offsetY), Offset(size.width, offsetY), paint);
  }

  // 垂直方向实线绘制
void drawVerticalLine(Canvas canvas, Size size, {required double offsetX, required Color color, required double strokeWidth}) {
    Paint paint = Paint();
    paint.style = PaintingStyle.stroke;
    paint.strokeWidth = strokeWidth;
    paint.color = color;
    canvas.drawLine(Offset(offsetX, 0), Offset(offsetX, size.height), paint);
  }

  // 水平方向虚线绘制
void drawHorizontalDashedLine(Canvas canvas, Size size,
      {required double offsetY, required Color color, required double strokeWidth, double lineWidth = 5, double gap = 5}) {
    Paint paint = Paint();
    paint.style = PaintingStyle.stroke;
    paint.strokeWidth = strokeWidth;
    paint.color = color;
    double offsetX = 0;
    while (offsetX + gap + lineWidth <= size.width) {
      canvas.drawLine(Offset(offsetX, offsetY), Offset(offsetX + lineWidth, offsetY), paint);
      offsetX += lineWidth + gap;
    }
  }

  // 垂直方向虚线绘制
void drawVerticalDashedLine(Canvas canvas, Size size,
      {required double offsetX, required Color color, required double strokeWidth, double lineWidth = 5, double gap = 5}) {
    Paint paint = Paint();
    paint.style = PaintingStyle.stroke;
    paint.strokeWidth = strokeWidth;
    paint.color = color;
    double offsetY = 0;
    while (offsetY + gap + lineWidth <= size.height) {
      canvas.drawLine(Offset(offsetX, offsetY), Offset(offsetX, offsetY + lineWidth), paint);
      offsetY += lineWidth + gap;
    }
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => oldDelegate != this;
}

Canvas图片裁剪

上图有图片裁剪的效果,图片裁剪的关键点是需要绘制出完整的path路径(上图中的波浪形路径)并对该路径类的内容进行裁剪,而绘制波浪形可以使用三阶贝塞尔曲线。

动图封面
 

三阶贝塞尔曲线

完整的path如下图所示:

结合图片之后的效果:

以下是相关源码:

import 'package:flutter/material.dart';

class ClipImage extends StatelessWidget {
  const ClipImage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('图片根据给定的path裁剪')),
      backgroundColor: Colors.white,
      body: Center(
        child: Container(
          color: Colors.white,
          width: 350,
          height: 350,
          child: CustomPaint(
            foregroundPainter: DrawPainter(),
            isComplex: true,
            child: Image.asset('lib/images/Orion.png', fit: BoxFit.cover),
          ),
        ),
      ),
    );
  }
}

class DrawPainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    Path path = Path();
    path.moveTo(0, size.width / 2);
    path.cubicTo(size.width / 2, size.height / 2 + 150, size.width / 2, size.height / 2 - 150, size.width, size.height / 2);
    path.lineTo(size.width, size.height);
    path.lineTo(0, size.height);
    path.close();
    canvas.clipPath(path);
    Paint paint = Paint();
    paint.blendMode = BlendMode.clear;
    canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => true;
}

Canvas自定义控件

  • 圆形进度条的实现(无动画版)
动图封面
 

要实现上图所示的效果,核心是使用路径的绘制。使用路径绘制出圆形的路径,画笔使用stroke模式,然后根据0~100的数值换算成2π的角度对应的百分比进行绘制即可。以下是源代码:

import 'dart:async';
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'dart:ui' as ui;

class DrawWidgetStep1 extends StatefulWidget {
  const DrawWidgetStep1({Key? key}) : super(key: key);

  @override
  State<StatefulWidget> createState() => DrawWidgetStep1State();
}

class DrawWidgetStep1State extends State<DrawWidgetStep1> {
  late Timer timer;

  @override
  void initState() {
    super.initState();
    timer = Timer.periodic(const Duration(milliseconds: 500), (timer) {
      setState(() {});
    });
  }

  @override
  void dispose() {
    super.dispose();
    timer.cancel();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('进度控件(1)')),
      body: Center(
        child: SizedBox(
          width: 200,
          height: 200,
          child: CustomPaint(
            painter: DrawPainter(
              strokeColor: Colors.black.withOpacity(0.5),
              startAngle: -pi * 0.5,
              percent: Random().nextDouble(),
              strokeWidth: 5,
              fontSize: 20,
            ),
          ),
        ),
      ),
    );
  }
}

class DrawPainter extends CustomPainter {
  final double strokeWidth;
  final Color strokeColor;
  final Color strokeBackgroundColor;
  final double startAngle;
  final double percent;
  final double fontSize;
  final Color textColor;

  DrawPainter({
    this.strokeWidth = 5,
    this.strokeColor = Colors.black,
    this.strokeBackgroundColor = Colors.grey,
    this.startAngle = 0,
    this.percent = 0,
    this.fontSize = 20,
    this.textColor = Colors.black,
  });

  @override
  void paint(Canvas canvas, Size size) {
    double val = 0;
    if (percent <= 0) {
      val = 0;
    } else if (percent >= 1) {
      val = 1;
    } else {
      val = percent;
    }

    {
      var paint = Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = strokeWidth;

      // 背景stroke
      {
        Path path = Path();
        var rect = Rect.fromLTWH(paint.strokeWidth / 2, paint.strokeWidth / 2, size.width - paint.strokeWidth, size.height - paint.strokeWidth);
        path.addArc(rect, 0, pi * 2);
        canvas.drawPath(path, paint..color = strokeBackgroundColor);
      }

      // 进度stroke
      {
        Path path = Path();
        var rect = Rect.fromLTWH(paint.strokeWidth / 2, paint.strokeWidth / 2, size.width - paint.strokeWidth, size.height - paint.strokeWidth);
        path.addArc(rect, startAngle, val * 2 * pi);
        canvas.drawPath(path, paint..color = strokeColor);
      }
    }

    drawText(
      canvas,
      size,
      text: (val * 100).toInt().toString() + '%',
      offset: Offset(0, size.height / 2),
      fontSize: fontSize,
      color: textColor,
    );
  }

  // 绘制文本
  void drawText(
    Canvas canvas,
    Size size, {
    required String text,
    required Offset offset,
    required double fontSize,
    required Color color,
    double? fixWidth,
  }) {
    final textStyle = ui.TextStyle(color: color, fontSize: fontSize);
    final paragraphStyle = ui.ParagraphStyle(textDirection: TextDirection.ltr, textAlign: TextAlign.center);
    final paragraphBuilder = ui.ParagraphBuilder(paragraphStyle);
    paragraphBuilder.pushStyle(textStyle);
    paragraphBuilder.addText(text);
    var constraints = ui.ParagraphConstraints(width: fixWidth ?? size.width);
    final paragraph = paragraphBuilder.build();
    paragraph.layout(constraints);

    canvas.drawParagraph(paragraph, Offset(offset.dx, offset.dy - paragraph.height / 2));
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return oldDelegate != this;
  }
}
  • 圆形进度条的实现(动画版)
动图封面
 

给控件增加动画效果可以增强用户体验,那如何给canvas绘制的控件增加动画效果呢? 

我们可以使用AnimationController来实现我们的需求。需要注意的地方是,动画效果需要有起始点和结束点以及动画持续的时间。然后通过补间动画Tween来计算起始点、结束点之间时间段的所有的动画数值并赋值给canvas控件进行绘制。这是核心所在。对无动画版的源代码进行改进:

import 'dart:async';
import 'dart:math';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'dart:ui' as ui;

class DrawWidgetStep2 extends StatefulWidget {
  const DrawWidgetStep2({Key? key}) : super(key: key);

  @override
  State<StatefulWidget> createState() => DrawWidgetStep2State();
}

class DrawWidgetStep2State extends State<DrawWidgetStep2> with TickerProviderStateMixin {
  late Timer timer;
  AnimationController? controller;
  late Animation<double> strokePercentAnimation;
  late Animation<double> labelPercentAnimation;
  late Animation<Color?> strokeColorAnimation;

  late double beginPercentValue;
  late double endPercentValue;

  late Color beginColor;
  late Color endColor;

  @override
  void initState() {
    super.initState();

    // 设置初始值结束值
    beginPercentValue = 0;
    endPercentValue = 0;
    beginColor = Colors.black;
    endColor = ([...Colors.primaries]..shuffle()).first;

    // 动画控制器初始化
    controller = AnimationController(vsync: this, duration: const Duration(milliseconds: 1000));

    // 添加监听
    controller?.addListener(() => setState(() {}));

    // 开始执行动画
    startAnimation();

    // 设置定时器
    timer = Timer.periodic(const Duration(milliseconds: 2000), (timer) {
      startAnimation();
    });
  }

  void startAnimation() {
    // 进度条动画
    strokePercentAnimation =
        Tween<double>(begin: beginPercentValue, end: endPercentValue).animate(CurvedAnimation(parent: controller!, curve: Curves.elasticOut));

    // text动画
    labelPercentAnimation =
        Tween<double>(begin: beginPercentValue, end: endPercentValue).animate(CurvedAnimation(parent: controller!, curve: Curves.easeInOutCubic));

    // 进度条颜色动画
    strokeColorAnimation = ColorTween(begin: beginColor, end: endColor).animate(CurvedAnimation(parent: controller!, curve: Curves.easeInOutCubic));

    beginPercentValue = endPercentValue;
    endPercentValue = Random().nextDouble();

    beginColor = endColor;
    endColor = ([...Colors.primaries]..shuffle()).first;

    controller?.forward(from: 0);
  }

  @override
  void dispose() {
    controller?.dispose();
    timer.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('进度控件(2)')),
      body: Center(
        child: SizedBox(
          width: 200,
          height: 200,
          child: CustomPaint(
            painter: DrawPainter(
              strokeBackgroundColor: Colors.grey.withOpacity(0.1),
              strokeColor: strokeColorAnimation.value!,
              startAngle: -pi * 0.5,
              percent: strokePercentAnimation.value,
              percentLabel: (labelPercentAnimation.value * 100).toInt().toString() + '%',
              strokeWidth: 5 + strokePercentAnimation.value * 40,
              fontSize: 20 + labelPercentAnimation.value * 10,
            ),
          ),
        ),
      ),
    );
  }
}

class DrawPainter extends CustomPainter {
  final double strokeWidth;
  final Color strokeColor;
  final Color strokeBackgroundColor;
  final double startAngle;
  final double percent;
  final double fontSize;
  final Color textColor;
  final String? percentLabel;

  DrawPainter({
    this.strokeWidth = 5,
    this.strokeColor = Colors.black,
    this.strokeBackgroundColor = Colors.grey,
    this.startAngle = 0,
    this.percent = 0,
    this.fontSize = 20,
    this.textColor = Colors.black,
    this.percentLabel,
  });

  @override
  void paint(Canvas canvas, Size size) {
    double val = 0;
    if (percent <= 0) {
      val = 0;
    } else if (percent >= 1) {
      val = 1;
    } else {
      val = percent;
    }

    {
      var paint = Paint()
        ..style = PaintingStyle.stroke
        ..strokeWidth = strokeWidth;

      // 背景stroke
{
        Path path = Path();
        var rect = Rect.fromLTWH(paint.strokeWidth / 2, paint.strokeWidth / 2, size.width - paint.strokeWidth, size.height - paint.strokeWidth);
        path.addArc(rect, 0, pi * 2);
        canvas.drawPath(path, paint..color = strokeBackgroundColor);
      }

      // 进度stroke
{
        Path path = Path();
        var rect = Rect.fromLTWH(paint.strokeWidth / 2, paint.strokeWidth / 2, size.width - paint.strokeWidth, size.height - paint.strokeWidth);
        path.addArc(rect, startAngle, val * 2 * pi);
        canvas.drawPath(path, paint..color = strokeColor);
      }
    }

    drawText(
      canvas,
      size,
      text: percentLabel ?? (val * 100).toInt().toString(),
      offset: Offset(0, size.height / 2),
      fontSize: fontSize,
      color: textColor,
    );
  }

  // 绘制文本
void drawText(
    Canvas canvas,
    Size size, {
    required String text,
    required Offset offset,
    required double fontSize,
    required Color color,
    double? fixWidth,
  }) {
    final textStyle = ui.TextStyle(color: color, fontSize: fontSize);
    final paragraphStyle = ui.ParagraphStyle(textDirection: TextDirection.ltr, textAlign: TextAlign.center);
    final paragraphBuilder = ui.ParagraphBuilder(paragraphStyle);
    paragraphBuilder.pushStyle(textStyle);
    paragraphBuilder.addText(text);
    var constraints = ui.ParagraphConstraints(width: fixWidth ?? size.width);
    final paragraph = paragraphBuilder.build();
    paragraph.layout(constraints);

    canvas.drawParagraph(paragraph, Offset(offset.dx, offset.dy - paragraph.height / 2));
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return oldDelegate != this;
  }
}

其他

演示代码在Github上的flutter_canvas,以下是编写此代码时的开发环境信息