☰
Flutter自绘进度条:从CustomPainter到Canvas的完整实践指南
2026/10/21 16:11:18 网站建设 项目流程

做 Flutter 开发这几年,我最早遇到进度条需求时,第一反应也是直接用LinearProgressIndicator。那时候还觉得挺好,样式能跑就行。可慢慢发现,几乎所有真实场景都会问你要“定制”能力:这个条要高一点、圆角要更大、颜色要跟随状态变化、进度数字要叠在条上、进度动起来要有缓动曲线……内置控件在这些需求面前非常被动——它只能给你一种预设的“线性 + 半透明色块”效果,可配置项就那么几个,颜色一深一浅换个样式都需要绕路处理。所以后来我的项目里凡是涉及进度展示,一律走上自绘这条路。

自绘进度条,本质就是用 Flutter 的CustomPainter拿到 Canvas 绘制能力,把轨道、进度、圆角、渐变、动画这些元素全部自己画出来。它解决的问题非常具体:内置控件给不了你要的视觉效果,或者给了但你没法精确控制。这篇文章会从思路、基础原理、完整代码到踩坑记录,把“Flutter 自绘进度条”这件事一次性讲透,适合刚接触 CustomPaint 的新手,也适合已经在用但被各种奇怪问题卡住的老手。

1. 为什么非要自绘进度条:内置控件的“够用但不够用”

1.1 LinearProgressIndicator 到底差在哪

我不否认LinearProgressIndicator在极简场景下能用。它的问题不是“不能用”,而是可定制空间太小。你能调的就那么几个参数:value(当前进度)、backgroundColor(轨道色)、color(进度色)、minHeight(高度)。看上去够用,实际上只要需求稍微复杂一丁点,立刻破功:

  • 想要渐变进度条?没有,color只接受单个颜色。
  • 想要圆角?没有,它默认是直角,连参数都不给。
  • 想要进度条内部叠加一个百分比数字?没有。
  • 想要进度值变化时带缓动动画?有,但动画曲线和时长都不可控。
  • 想要确定性和不确定性(indeterminate)两种状态切换时平滑过渡?默认实现的跳变很难看。

这时候有人会说,那我用Container+FractionallySizedBox+ClipRRect组合一下不就行了?确实可以,做一个不会动的静态进度条完全没问题。但只要加上动画、渐变、以及频繁更新的进度值,这种“纯 Widget 组合”的方式就会暴露出性能问题——进度每更新一次,整个 Widget 树都要参与重建和布局,文本、圆角、阴影全部重新计算。而自绘方案把这些都压进一层 Canvas,一次paint调用全部搞定,性能和可控性都高一个量级。

1.2 自绘到底解决了什么核心问题

自绘的核心价值在于:你把“视觉表现”的控制权完全拿到了自己手里。画多宽、画多高、圆角半径多少、渐变从哪儿开始到哪儿结束、进度条走到一半是什么颜色、阴影怎么打、文字放在哪个位置,全部由你的代码决定。框架只提供一个画布和一支画笔,画什么、怎么画,你是唯一作者。

这带来两个直接好处:

  • 复用性。写一个通用CustomPainter,整个项目的进度条样式统一,任何页面传入不同参数即可得到不同外观。产品改设计时只改一处核心实现,不用满项目找进度条代码。
  • 性能可控。CustomPaint是一个 RenderObject,只在自己发生变化时触发重绘,不需要重建整个 Widget 树。配合shouldRepaint做细粒度控制,可以做到只有进度值真正变化时才重画。

1.3 什么场景才值得自绘

自绘不是银弹,它也有学习成本和调试成本。根据我的经验,下面这几种场景非常值得走自绘:

  • 品牌定制型进度条:有专属色系、圆角规范、动画曲线,希望全 App 统一的。
  • 数据展示密集场景:比如文件上传、下载、磁盘空间占用、学习进度统计,需要实时更新、精确展示数值的。
  • 特殊形态需求:圆形进度环、半环形、跑道形、带刻度线的进度条等,内置控件完全无法覆盖。
  • 跟手交互场景:可拖动进度条、播放器进度调节,需要精确处理手势和绘制层关系的。

反过来,如果产品需求就是“一个默认的蓝色横条”,放着,别自绘,没必要。

2. 自绘前必须理解的三张牌:CustomPainter、Canvas、Paint

2.1 CustomPainter 的生命周期与回调

很多新手第一次接触自绘,直接卡在 "我应该继承什么类" 上。核心类就一个:CustomPainter。你自己写的 Painter 类继承它,然后实现两个方法:

  • paint(Canvas canvas, Size size):绘制逻辑全部写在这里。系统会把当前画布和画布尺寸传给你。
  • shouldRepaint(covariant CustomPainter oldDelegate):告诉框架“旧的绘制结果还能不能用”。如果新旧 Painter 的核心参数一样,就返回false,框架跳过重绘;不一样就返回true,重新执行paint。

这里有个关键理解:shouldRepaint不是“是否需要绘制”的开关,而是“绘制结果是否失效”的判断。你要比对新旧实例里跟视觉强相关的属性,而不是简单返回true。比如进度值一样、颜色一样、圆角一样,那确实没必要重画。

2.2 Canvas 坐标系与 Size 的坑

Canvas 的坐标系是 Flutter 自绘最容易踩坑的地方。它的原点(0,0)在画布左上角,x 轴向右递增,y 轴向下递增,单位是逻辑像素(lp)。这意味着:

  • 你想画一个从左边开始的进度条,起点就是(0, 0)。
  • 你想画一个圆角半径等于高度一半的“胶囊”,需要先拿到size.height再除以 2。
  • size由CustomPaint的size参数决定。如果你写CustomPaint(size: Size(double.infinity, 8)),那传给你的size.width就是父级约束下的实际宽度;如果你不传size又不约束外层,size会变成Size.zero,绘制结果直接“消失”。

我第一次写自绘进度条时就犯过这个错:CustomPaint放在Column里没有给宽度约束,结果调试半天发现 Canvas 的 size.width 是 0,进度条根本画不出来。正确做法是在外层套SizedBox(width: double.infinity, height: 8),或者让CustomPaint自己实现computeMinIntrinsicWidth这类布局方法。工程上最省事的方案是前者。

2.3 Paint 的几个常用属性组合

Painter 里最常用的是Paint对象。初始化的方式直接决定绘制效果,几个关键属性我从实际项目中总结的经验如下:

  • style:PaintingStyle.fill(填充)还是PaintingStyle.stroke(描边)。进度条轨道通常用 fill 画圆角矩形,圆形进度环用 stroke 画圆弧。
  • strokeWidth:描边模式下笔触的宽度。画圆形进度环时这个值就是环的粗细。
  • strokeCap:StrokeCap.round会让描边端点变成圆头。画圆环时如果不设置,圆弧两端会看到明显的截断,很丑。
  • shader:渐变效果靠它实现。LinearGradient.createShader(rect)返回一个 Shader,赋给shader属性后,画出来的内容就带渐变。
  • maskFilter:做外发光、模糊效果时用MaskFilter.blur。注意这玩意性能开销不小,别在每帧动画里滥用。

一个常见的误区是:渐变需要调用LinearGradient.createShader()并且传入一个Rect。这个 Rect 的范围就决定了渐变的起止区域。你传的 Rect 是进度条的实际绘制区域,渐变才会贴在进度条上,否则可能出现颜色错位、渐变方向不对的问题。

3. 从零实现一个渐变圆角进度条

3.1 第一步:搭好 Widget 与 Painter 的架构

先给最终的成果定个目标:一个支持渐变、圆角、缓动动画、并且可以叠加百分比文字的水平进度条。架构上我把它拆成两层:

  • 外层是一个StatefulWidget,负责管理动画状态,对外暴露value、height、colors等参数。
  • 内层是一个CustomPainter,只接收当前进度值和配色参数,纯函数式地把画面画出来。

为什么要拆成两层?因为CustomPainter不应该持有任何可变的业务状态。它就像一个“绘图函数”,你给我参数,我给你画面。动画状态放在 Widget 层管理,通过参数方式传给 Painter,这样职责清晰、测试也方便。某同学第一次写的时候把所有动画代码塞进 Painter 里,结果更新进度时连画布都重建了,性能惨不忍睹,后来按这个拆法重构才正常。

3.2 第二步:轨道与进度条的核心绘制代码

这里直接把 Painter 的完整实现贴出来:

import 'package:flutter/material.dart'; class CustomProgressPainter extends CustomPainter { final double progress; final Color trackColor; final Gradient gradient; CustomProgressPainter({ required this.progress, required this.trackColor, required this.gradient, }); @override void paint(Canvas canvas, Size size) { // 防止尺寸为零导致崩溃或不可见 if (size.width <= 0 || size.height <= 0) return; // 轨道背景:整体是一个圆角矩形 final trackRect = Offset.zero & size; final trackRRect = RRect.fromRectAndRadius( trackRect, Radius.circular(size.height / 2), ); canvas.drawRRect(trackRRect, Paint()..color = trackColor); // 进度为 0 时不需要画任何内容 if (progress <= 0) return; // 计算实际进度宽度,并做越界保护 final clampedProgress = progress.clamp(0.0, 1.0); final progressWidth = size.width * clampedProgress; final progressRect = Rect.fromLTWH(0, 0, progressWidth, size.height); final progressRRect = RRect.fromRectAndRadius( progressRect, Radius.circular(size.height / 2), ); canvas.drawRRect( progressRRect, Paint()..shader = gradient.createShader(progressRect), ); } @override bool shouldRepaint(covariant CustomProgressPainter oldDelegate) { return oldDelegate.progress != progress || oldDelegate.trackColor != trackColor || oldDelegate.gradient != gradient; } }

解释几个关键设计:

  • 圆角用的是size.height / 2,这是“胶囊形”进度条的标准做法。如果你写死一个Radius.circular(4),高度变成 20 的时候圆角还是 4,视觉上像个怪异的小圆角矩形。用高度一半永远保证两侧是完美的半圆。
  • 进度条宽度直接用size.width * progress,没有处理最小值问题。实际产品里进度非常小(比如 1%)时,圆角矩形会变得很扁,有时候视觉上不够明显。我的处理是加一个最小可见宽度(比如 4 像素),用max(progressWidth, minVisibleWidth)兜底。
  • 渐变范围传的是progressRect,注意不是整个size。这样渐变只存在于进度已覆盖的区域,看起来是“进度走到哪里颜色跟到哪里”。如果你想要“整个进度条区域永远渐变,只是被遮罩切出当前进度”的效果,那就需要换一种裁剪方案,后面进阶部分再展开。

3.3 第三步:用 AnimationController 把进度“动”起来

Painter 画的是静态画面,真正的“进度动画”要靠 AnimationController 驱动。先看完整 Widget 代码:

class GradientProgressBar extends StatefulWidget { final double value; final double height; final Color trackColor; final List<Color> gradientColors; final bool showText; final TextStyle? textStyle; const GradientProgressBar({ super.key, required this.value, this.height = 8, this.trackColor = const Color(0xFFE8EAF0), this.gradientColors = const [Color(0xFF4A90E2), Color(0xFF50C878)], this.showText = true, this.textStyle, }) : assert(value >= 0 && value <= 1, 'value 必须在 0~1 之间'); @override State<GradientProgressBar> createState() => _GradientProgressBarState(); } class _GradientProgressBarState extends State<GradientProgressBar> with SingleTickerProviderStateMixin { late final AnimationController _controller; late Animation<double> _animation; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); // 首次动画:从 0 到 widget.value _animation = Tween<double>(begin: 0, end: widget.value) .animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); _controller.forward(); } @override void didUpdateWidget(covariant GradientProgressBar oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.value != widget.value) { _animateTo(widget.value); } } void _animateTo(double target) { // 以当前动画值为起点,动画到新目标值 final begin = _animation.value; _animation = Tween<double>(begin: begin, end: target) .animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic)); _controller.forward(from: 0); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SizedBox( height: widget.height, child: Stack( alignment: Alignment.center, children: [ Positioned.fill( child: AnimatedBuilder( animation: _animation, builder: (context, child) { return CustomPaint( painter: CustomProgressPainter( progress: _animation.value, trackColor: widget.trackColor, gradient: LinearGradient( colors: widget.gradientColors, begin: Alignment.centerLeft, end: Alignment.centerRight, ), ), ); }, ), ), if (widget.showText) IgnorePointer( child: Text( '${(_animation.value * 100).toStringAsFixed(0)}%', style: widget.textStyle ?? TextStyle( fontSize: widget.height * 0.6, color: Colors.black54, fontWeight: FontWeight.w600, ), ), ), ], ), ); } }

这里有几个值得说清楚的点:

  • SingleTickerProviderStateMixin是必须的,它给 AnimationController 提供 vsync(垂直同步信号)。漏了它会直接报错,提示 “provide a vsync”。
  • 每次value变化时,先用当前动画值作为新 Tween 的起点,而不是从 0 开始。这样连续多次更新时会形成一个平滑衔接,不会往回跳。
  • AnimatedBuilder只包了CustomPaint,文字部分自己监听动画值。我故意让两者分开,这样动画值变化时文字和进度条各自刷新,互不阻塞。
  • 文字放在IgnorePointer里,避免它拦截点击事件。如果进度条支持点击或手势,这一步很重要,否则文字区域会挡住下面的 InkWell。

3.4 第四步:百分比文字与进度条的位置处理

很多人纠结文字放哪。简单方案是放中间居中,但实际产品里最常见的是放进度条右侧或者正上方。放右侧的处理方式我单独说明一下:

Widget build(BuildContext context) { return Row( children: [ Expanded( child: AnimatedBuilder( animation: _animation, builder: (context, child) { return LayoutBuilder( builder: (context, constraints) { return SizedBox( height: widget.height, width: constraints.maxWidth, child: CustomPaint( painter: CustomProgressPainter( progress: _animation.value, trackColor: widget.trackColor, gradient: LinearGradient(...), ), ), ); }, ); }, ), ), const SizedBox(width: 8), ValueListenableBuilder( valueListenable: _animation, builder: (context, v, child) { return Text( '${(v * 100).toStringAsFixed(0)}%', style: TextStyle(fontSize: 12), ); }, ), ], ); }

注意这里用了LayoutBuilder拿具体宽度。原因和前面讲的一样,CustomPaint在Expanded里实际的宽度约束需要从LayoutBuilder获取,不这样做的话progressWidth计算会基于错误的值。文字放在进度条外部后,视觉上更干净,也避免了“文字挡住进度条”的问题。不过这种方式有个小缺点:进度条实际可用宽度变少了,计算文字和进度条之间的间距时要注意,别让进度走到 100% 时撞上文字。

4. 实操记录:三种常见场景下的接入方式

4.1 普通页面里的固定进度展示

这种场景最简单,比如展示用户的成长值、任务完成度、账号安全等级。直接把GradientProgressBar放进页面即可:

GradientProgressBar( value: 0.72, height: 8, gradientColors: [Color(0xFFFF9500), Color(0xFFFF2D55)], showText: true, )

固定进度的好处是不需要任何交互逻辑,页面加载时动画自然从 0 播放到目标值,用户体验已经比内置控件好了。前提是保证value在 0 到 1 之间,其实我在 Widget 构造函数里已经加了assert做防呆,写的时候少操一份心。

4.2 上传/下载场景的动态进度

动态进度场景核心是三个字:别卡 UI。上传进度回调通常来自网络层,频率可能很高(每几十毫秒一次)。如果每次回调都直接setState,页面会疯狂重建,极容易掉帧。

我的做法是:网络层回调只更新一个状态值,通过ChangeNotifier或者ValueNotifier<double>保存,进度条组件用ValueListenableBuilder监听它。这样进度条的动画和更新完全独立于父页面生命周期:

class UploadProgressCard extends StatelessWidget { final ValueNotifier<double> progressNotifier; const UploadProgressCard({super.key, required this.progressNotifier}); @override Widget build(BuildContext context) { return ValueListenableBuilder<double>( valueListenable: progressNotifier, builder: (context, value, child) { return GradientProgressBar( value: value, height: 10, gradientColors: [Color(0xFF4A90E2), Color(0xFF7B68EE)], showText: true, ); }, ); } }

外部更新逻辑只需要一行:progressNotifier.value = received / total;。这个方案实测在 30fps 左右的更新频率下依然流畅,没有出现明显的掉帧。

4.3 倒计时与自动更新的轮询场景

还有一种特殊场景:进度条本身不是“任务完成度”,而是一个倒计时或者轮询的剩余时间。比如验证码重新发送的 60 秒倒计时、广告展示的 15 秒倒计时。这种进度条的值不是由外部事件驱动,而是自身按时间递减。

实现思路是给GradientProgressBar加一个可选参数autoDecrease,或者更通用地支持传入一个AnimationController。我做验证码倒计时时,会单独写一个 CountdownProgressBar,内部维护一个Tween<double>(begin: 1, end: 0),时长设为 60 秒,动画完成后自动停在 0。这种场景下文字内容就不是百分比了,而是剩余秒数,需要给进度条加一个labelBuilder回调,让外部完全控制文字:

GradientProgressBar( value: remainingSeconds / 60, height: 6, showText: true, labelBuilder: (v) => '${remainingSeconds}s', )

把文字生成逻辑抽象成回调,是这个组件设计中性价比极高的一步。产品改需求时,不用动绘制代码,只改回调就行。

5. 常见问题与排查技巧实录

5.1 进度条“卡死”不更新了

这是最容易被误判的问题。现象:进度条显示正常,但值怎么变都不动。排查思路先别急着怀疑CustomPainter,先检查shouldRepaint。

一种常见低级错误是shouldRepaint里什么都没比,直接return false,框架认为画面永远不需要重绘。另一种是比了但没比完,比如加了渐变参数后忘了把渐变加进比较逻辑里。我排查时直接在 Paint 方法第一行加个print,看日志输出频率。如果paint根本没执行,问题一定在shouldRepaint或者外部没有触发重建上。

5.2 圆角变成“竖向胶囊”

现象:进度条高度明明设了 8,圆角看起来却很高,甚至整个进度条像一个竖着的药丸。原因基本只有一个——RRect.fromRectAndRadius用的 Radius 写死了,比如Radius.circular(20),当高度只有 8 时,圆角半径超过高度一半,视觉就不对了。

正确做法:半径永远取size.height / 2。这行代码应该是动态计算的,而不是写死一个常量。另外注意,进度条进度很小的时候(比如 progress = 0.02),progressWidth可能小于高度,此时RRect的半径相当于还是拿 height 算,但 Rect 宽度不够,Flutter 会自动压缩圆角匹配形状,一般不会崩溃,但视觉上需要minVisibleWidth兜底。

5.3 设置了渐变却一片空白

这个问题几乎每个自绘新手都会遇到一次。具体现象:轨道画出来了,但进度条区域是空白。绝大多数原因都是LinearGradient.createShader(rect)传了个零宽度的 Rect。

比如进度值是 0,progressWidth就是 0,Rect 的 width 是 0,此时创建 Shader 就得不到任何内容。所以写代码时一定要在paint开头判断progress <= 0并且直接 return。另外还有一种情况:渐变 Rect 传的是Rect.fromLTWH(0, 0, 0, 0)或负值,会导致 shader 不可见。把 Rect 打印出来确认一下最直接。

5.4 页面性能卡顿与频繁绘制

进度条动画期间出现卡顿,要先分清是“UI 线程卡”还是“整体掉帧”。经验上 90% 的新手问题集中在以下几点:

  • 动画期间每帧都创建新的Gradient对象和Paint对象,导致垃圾回收压力大。正确做法是:不变化的Paint对象和Gradient在 Widget 层复用,Painter 里只保存引用。
  • 文字Text每帧都在toStringAsFixed,这个开销其实不算大,但如果文字还在跑文本布局,那就有问题了。方案是把文字样式缓存好,文本内容变化时只改字符串,不触发整体重新布局。
  • AnimatedBuilder的 builder 里写了大量非绘制逻辑。记住 builder 是动画每帧都会执行的,别把网络请求、集合遍历放进去,只做和绘制相关的轻量操作。

为了确认是 Engine 绘制瓶颈还是 Dart 层逻辑问题,我常用的办法是开 Flutter 的性能面板(DevTools 里的 Performance 页),看帧时间落在 Runtime 还是 UI/GPU。如果卡在 Dart 层,优先查上面这几条。

6. 进阶玩法:从横向进度条到圆形进度环

6.1 圆形进度环的绘制思路

很多应用(比如磁盘用量、健康达标率、课程完成度)喜欢用圆形进度环。它的绘制原理和横向进度条非常相似,区别只在于 Canvas 的坐标系搬运到了角度上。用drawArc或者drawArc对应的Paint描边方式,就能从 0 度扫到 360 度乘进度。

核心要点有三个:

  • 画圆弧需要传一个 Rect,通常是正方形,比如Rect.fromCircle(center: Offset(size.width/2, size.height/2), radius: radius)。
  • 使用drawArc时设置useCenter: false,然后再用描边 Paint 画出来,得到的就是一个环而不是扇形。
  • 角度起止约定:drawArc的起始角度 0 是三点钟方向,不是十二点钟。要做出从正上方开始的环,起始角度要传-pi / 2。

6.2 圆形进度环完整实现

class CircularProgressPainter extends CustomPainter { final double progress; final Color trackColor; final Gradient gradient; final double strokeWidth; CircularProgressPainter({ required this.progress, required this.trackColor, required this.gradient, this.strokeWidth = 6, }); @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final radius = (size.width < size.height ? size.width : size.height) / 2 - strokeWidth / 2; final rect = Rect.fromCircle(center: center, radius: radius); // 轨道环 canvas.drawArc( rect, 0, pi * 2, false, Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..color = trackColor, ); // 进度环 final sweepAngle = pi * 2 * progress.clamp(0.0, 1.0); final progressPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..shader = gradient.createShader(rect); if (sweepAngle > 0) { canvas.drawArc( rect, -pi / 2, sweepAngle, false, progressPaint, ); } } @override bool shouldRepaint(covariant CircularProgressPainter oldDelegate) { return oldDelegate.progress != progress || oldDelegate.trackColor != trackColor || oldDelegate.gradient != gradient || oldDelegate.strokeWidth != strokeWidth; } }

这里需要注意一个细节:strokeCap = StrokeCap.round在某些低端安卓设备上,圆弧的两个端点会渲染异常,出现“箭头状”的过度描边。如果遇到这个问题,解决方案是放弃strokeCap,改成在圆弧末端单独画一个小圆来模拟圆头,代价是代码复杂一点,但兼容性更好。

6.3 shouldRepaint 的正确使用姿势

最后啰嗦一下shouldRepaint,因为这是自绘组件高性能的命门。正确的做法是:只对影响绘制结果的属性做比较。如果 Painter 里有一个textScale但它不影响绘制,就别放进比较逻辑,否则每帧触发重绘。

我见过某同学的代码,shouldRepaint里直接return true,理由是“这样最简单”。动画期间每帧都会调用shouldRepaint,返回值 true 就等于告诉框架无脑重绘。当页面同时存在多个自定义绘制组件时,这种写法会拖垮整页性能。写shouldRepaint的正确心态是:能返回false就不返回true,能少比一个字段就少比一个。


最后分享一个我个人的小经验:自绘进度条这类组件,最难的不是绘制本身,而是把它设计成一个“边界清晰、参数合理、可复用”的公共组件。我在实操中反复踩坑后得到的结论是:Painter 保持无状态,动画交给外层,渐变对象尽可能复用,文字内容用回调暴露给业务方。这四条原则让我后续所有自绘组件(进度环、打分条、滑杆等)都走得比较顺。你第一次写可能觉得代码很多,但拆开看无非就是“画轨道、画进度、加动画、叠文字”四件事。只要这四件事的边界划分清楚,剩下的一切问题都只是调试时间问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询