☰
Flutter仿Element风格进度条:CustomPaint自绘与动画实践
2026/10/21 15:16:41 网站建设 项目流程

做管理后台类应用的时候,进度条是那种不起眼但绕不过去的元件。任务上传、存储水位、数据同步、批量计算,哪儿都离不开它。Flutter 自带的LinearProgressIndicator和CircularProgressIndicator虽然开箱即用,但在偏企业工具的界面里总显得太“原生”,和后台开发老哥熟悉的 Element 样式进度条差着好几个视觉层次。Element 样式的 Progress 无非就是那几个特征:细长的圆角轨道、按百分比填充的色块、默认/成功/异常/警告四态颜色映射、可选的内部百分比文字,以及环形形态。这篇博文把我用 Flutter 完整复刻这套样式的全过程拆开来讲,覆盖需求拆解、绘制选型、动画实现和真实项目里踩过的坑。适合正打算把 Web 后台迁移到 Flutter 的团队,也适合被 UI 稿上“仿经典后台组件进度条”需求逼疯的 Flutter 开发者。

1. 写之前先拆需求:进度条不是一根矩形这么简单

1.1 先把目标样式拆成一份组件清单

仿制一个跨端组件,最忌讳上来就对着截图写代码。我这次仿 Element 样式进度条,第一步是列出需要还原的要素清单,六条就够:

  1. 形态:线性(horizontal)和环形(circle)两种;
  2. 轨道:细高度(常见 6~10px)、圆角、淡底色;
  3. 填充:按进度百分比绘制主色块,支持渐变;
  4. 状态:默认、成功、异常、警告四种语义色;
  5. 文本:百分比文字可以放在外部右侧,也可以放在进度条内部;
  6. 动态:数值变化时有过渡动画,条纹模式时斜条缓慢流动。

为什么非要拆得这么细?因为进度条在 UI 稿里往往是一根“有颜色的矩形”,但实际开发时你会发现,每一根矩形背后都藏着一整套状态和交互逻辑。比如条纹流动,它不止是静态斜线,还要配合动画平移;比如文本位置,text-inside时百分比文字要跟进度条的实时宽度做避让;再比如圆角,进度只有 2% 的时候,右端圆角的处理跟 50% 的时候完全不同。这些细节如果不提前列出来,后面每加一个需求就得重构一次组件。

我见过不少同学用LinearProgressIndicator加一个Stack就交了差,结果产品经理说“我要条纹”“我要环形”“我要红色异常态”的时候,才发现官方组件根本没法扩展。与其到时候打补丁,不如先做这份清单。

1.2 颜色不要写死:让状态成为一等参数

Element 样式的进度条有一个很实用的设计:颜色不是任由调用方传一个色值,而是通过status语义驱动。我沿用了这套思路,在 Flutter 侧定义了一个枚举:

enum ProgressStatus { active, success, exception, warning }

然后统一做映射:

状态色值(参考经典组件库)典型场景
active#409EFF 蓝色任务执行中、上传中、同步中
success#67C23A 绿色已完成、成功
exception#F56C6C 红色失败、异常中断
warning#E6A23C 橙色部分完成、有告警

这样做的价值不在省几行 switch,而是让组件外部只表达“这件事是什么状态”,不表达“这个状态长什么样”。业务层传status: ProgressStatus.exception,组件负责把它渲染成红色异常条,业务层不用关心具体色值。后期如果要整体换主题色,只需要在组件内部替换色板,几十个调用页面的颜色会跟着一起变,这个收益在后台管理系统里非常明显。

如果业务方确实需要自定义颜色,我建议加一个 optional 的barColor参数覆盖默认映射,而不是取消 status 机制。

1.3 为什么最终选 CustomPaint 而不是继续拼 Container

实现进度条,Flutter 里有三条路:嵌套 Container、官方组件、CustomPaint。我对比了一下:

方案优点缺点
Stack + FractionallySizedBox代码直白、新手友好条纹/渐变/圆角组合起来嵌套很深,环形无法复用,动画性能一般
LinearProgressIndicator 改造官方出厂、接入快没有状态语义、不支持文本、条纹和渐变都要自己包层
CustomPaint + AnimationController一个 Painter 通吃线形和环形,绘制完全可控需要理解 canvas 坐标系和 Painter 生命周期,调试门槛略高

我最终选了 CustomPaint。原因很简单:进度条的绘制几何其实非常基础,就是轨道矩形加填充矩形,不涉及复杂文本排版,用 canvas 反而是最直接的方式。而且同一套进度值、同一套状态颜色逻辑,可以同时驱动线形 painter 和环形 painter。如果你只做一个静态的、永不变色的线性进度,Stack 方案没问题;但只要涉及表格里那些动态要素,自绘的性价比立刻拉满。

2. 线性进度条:从“能看”到“好用”的三个版本

2.1 初版方案:Stack + FractionallySizedBox 的“静态截图”陷阱

很多人第一次写进度条,都会写出下面这个版本:

SizedBox( width: 260, height: 8, child: Stack( children: [ Container( height: 8, decoration: BoxDecoration( color: const Color(0xFFEBEEF5), borderRadius: BorderRadius.circular(4), ), ), FractionallySizedBox( widthFactor: 0.6, child: Container( decoration: BoxDecoration( color: const Color(0xFF409EFF), borderRadius: BorderRadius.circular(4), ), ), ), ], ), )

这段代码用FractionallySizedBox控制填充宽度,思路没问题,但它本质上是一张“静态截图”。一旦你加上条纹流动,内部要再包一层ClipRect;加上渐变,Container的decoration里要堆LinearGradient;加上text-inside,文本定位要跟着进度宽度实时算。三层嵌套下来,布局代码比业务逻辑还长。

更要命的是,多状态切换时,如果你给每个状态单独写一套 Container 样式,整个组件会变成一堆条件分支的堆砌。我第一版就是这么做的,三个状态分支写完后自己都看不下去了,于是推倒重来,改成自定义绘制。

2.2 自绘版本:一个 Painter 画清轨道和进度

最终实现的线性 painter 核心代码如下:

class LinearProgressPainter extends CustomPainter { LinearProgressPainter({ required this.progress, required this.trackColor, required this.barColor, this.barGradient, this.strokeWidth = 8, this.striped = false, this.stripedOffset = 0, }); final double progress; // 0~1 final Color trackColor; final Color barColor; final Gradient? barGradient; final double strokeWidth; final bool striped; final double stripedOffset; @override void paint(Canvas canvas, Size size) { // 1. 画轨道:淡底色,圆角 final trackRRect = RRect.fromRectAndRadius( Offset.zero & size, Radius.circular(strokeWidth / 2), ); canvas.drawRRect(trackRRect, Paint()..color = trackColor); if (progress <= 0) return; // 2. 计算进度条实际宽度 final barWidth = size.width * progress; // 3. 圆角半径不能超过宽度的一半 final barRadius = (barWidth >= size.height) ? strokeWidth / 2 : barWidth / 2; final barRRect = RRect.fromRectAndRadius( Rect.fromLTWH(0, 0, barWidth, size.height), Radius.circular(barRadius), ); // 4. 进度条填充:支持纯色和渐变 final barPaint = Paint() ..color = barColor; if (barGradient != null) { barPaint.shader = barGradient!.createShader( Rect.fromLTWH(0, 0, barWidth, size.height), ); } canvas.drawRRect(barRRect, barPaint); } @override bool shouldRepaint(covariant LinearProgressPainter oldDelegate) { return oldDelegate.progress != progress || oldDelegate.trackColor != trackColor || oldDelegate.barColor != barColor || oldDelegate.striped != striped || oldDelegate.stripedOffset != stripedOffset; } }

这段代码只有十几行,但有几个细节值得说明。

绘制顺序是先轨道后填充,轨道用RRect.fromRectAndRadius画一个圆角矩形,填充条再叠上去。这样进度条和轨道天然共享同样的圆角,不会出现“进度条方块顶到轨道圆角上”的违和感。

渐变部分用barGradient.createShader生成 shader 赋给Paint,注意createShader必须在每次paint里调用。有人会图省事把Paint和 shader 缓存成 painter 的字段,一旦父级尺寸变化,shader 区域还是旧尺寸,渲染就会错位。这个坑我踩过一次,后来一律在paint方法里即时创建。

2.3 极短进度下的“胶囊怪相”和圆角修正

进度条有个容易被忽略的边界 case:当进度只有 1% 或者 2% 时,进度块宽度小于轨道高度,如果圆角半径仍然取strokeWidth / 2,RRect会自动修正成两个半圆,视觉上就是一个长条胶囊,跟右侧还残余的大段轨道放在一起,很不协调。

我起初没处理,结果测试阶段看到进度 3% 的截图才发现问题。修正方案很简单,圆角半径取轨道一半和进度块宽度一半的较小值:

final barRadius = (barWidth >= size.height) ? strokeWidth / 2 : barWidth / 2;

如果进度只有 2 像素宽、轨道高 8 像素,那么 bar 圆角就是 1 像素,接近一个微凸的矩形;等进度走到 30% 以上,右侧才逐渐变回标准的 4 像素圆角。这个处理会带来一个“圆角从直变圆”的过渡,视觉上是自然的,因为宽度变大本身就意味着曲线弧度变大。

3. 动起来:数值动画、状态切换和条纹流动

3.1 进度数值动画:AnimationController 的正确姿势

静态进度条做得再精致,在数字从 20 跳到 80 的时候也显得廉价。我给组件加了一个数值动画,核心是利用AnimationController+Tween:

class _XProgressState extends State<XProgress> with SingleTickerProviderStateMixin { late final AnimationController _valueController; late Animation<double> _progress; late final AnimationController _stripeController; @override void initState() { super.initState(); _valueController = AnimationController( vsync: this, duration: Duration(milliseconds: widget.animationDuration), ); _progress = Tween<double>( begin: 0, end: (widget.value / 100).clamp(0.0, 1.0), ).animate( CurvedAnimation(parent: _valueController, curve: Curves.easeOutCubic), ); _stripeController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); if (widget.stripedFlow) _stripeController.repeat(); } @override void didUpdateWidget(covariant XProgress oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.value != widget.value) { _progress = Tween<double>( begin: _progress.value, // 关键:从当前显示值出发 end: (widget.value / 100).clamp(0.0, 1.0), ).animate( CurvedAnimation(parent: _valueController, curve: Curves.easeOutCubic), ); _valueController.forward(from: 0); } if (oldWidget.stripedFlow != widget.stripedFlow) { if (widget.stripedFlow) { _stripeController.repeat(); } else { _stripeController.stop(); _stripeController.value = 0; } } } @override void dispose() { _valueController.dispose(); _stripeController.dispose(); super.dispose(); } }

这里最需要注意的是begin的取值。很多人写的时候会让begin固定为 0,结果是每次数值更新,进度条都从零开始跑一遍,动态效果非常跳。正确做法是从当前已经显示的值_progress.value出发,这样连续两次更新之间会形成一条连续的动画曲线。

另外,didUpdateWidget里要判断oldWidget.value != widget.value,而不是直接用widget.value去更新。理由很简单,State 的didUpdateWidget在每次父组件 rebuild 时都会触发,如果父组件只是改了旁边一个按钮的文字,进度条也跟着重新播放动画,那用户体验会非常糟糕。

3.2 状态切换的颜色过渡

数值可以在 300 毫秒内平滑过渡,颜色为什么要瞬间变?后台界面里任务从“执行中”变成“已完成”,进度条从蓝变绿,如果没有过渡,视觉上就像屏幕闪了一下。

实现颜色过渡在 Flutter 里也很直接,用ColorTween:

ColorTween( begin: oldStatusColor, end: newStatusColor, ).animate(_statusController)

然后在动画监听器里调用setState或者直接通过AnimatedBuilder把tween.value!传给 painter。

我这里做了一个简化处理:State 里保存当前状态对应的颜色,当didUpdateWidget检测到widget.status变化时,记录旧颜色和新颜色,并启动一个 200 毫秒左右的_statusController动画,最后在 builder 中把Color.lerp(begin, end, curve.value)传给LinearProgressPainter.barColor。如果不想为一个颜色动画多维护一个 Controller,也可以直接让 painter 接收barColor时用TweenAnimationBuilder包裹整体组件,代价是颜色变化时数值动画也会被牵连。我的建议是单独用一个 Controller,费用很低但动画独立性最好。

3.3 条纹与流动:裁剪斜线就够了

Element 样式的进度条支持条纹模式,官方叫 striped,视觉上是进度块表面覆盖一层间距均匀的斜条纹,条纹还会缓慢向右流动。实现这套效果不需要复杂的图片资源,在 painter 里用 canvas 画斜线即可。

if (striped) { final stripePaint = Paint() ..color = Colors.white.withOpacity(0.25); canvas.save(); canvas.clipRRect(barRRect); // 只允许条纹出现在进度块内部 final gap = 6.0; final shift = stripedOffset; // 0~6 之间循环的偏移值 for (double x = -size.height; x < barWidth + size.height; x += gap) { canvas.drawLine( Offset(x + shift, size.height), Offset(x + shift - size.height, 0), stripePaint, ); } canvas.restore(); }

这段代码的原理是用间距 6 的斜线,从左上往右下画 45 度条纹。循环起点取-size.height是为了让画布最左侧也有条纹覆盖,终点取barWidth + size.height同理,保证右侧边缘不露白。

stripedOffset从_stripeController的 0~1 值映射而来,也就是_stripeController.value * 6,在监听器中不断刷新 repeaint,条纹就会跟着流动起来。用canvas.clipRRect之前,必须保证进度块区域已经画好,否则条纹会溢出到轨道外头。

3.4 环形进度条:换一种画法,复用同一套逻辑

环形进度条在 Element 里叫 circle,它跟线性进度条共用同一套状态、颜色和数据,只是绘制方式不同。Flutter 实现环形也是用 CustomPaint,这次画的是圆弧:

class CircleProgressPainter extends CustomPainter { CircleProgressPainter({ required this.progress, required this.trackColor, required this.barColor, this.strokeWidth = 10, }); final double progress; final Color trackColor; final Color barColor; final double strokeWidth; @override void paint(Canvas canvas, Size size) { final center = size.center(Offset.zero); final radius = (size.shortestSide - strokeWidth) / 2; final arcRect = Rect.fromCircle(center: center, radius: radius); final trackPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..color = trackColor; canvas.drawCircle(center, radius, trackPaint); if (progress <= 0) return; final barPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..color = barColor; canvas.drawArc( arcRect, -math.pi / 2, 2 * math.pi * progress, false, barPaint, ); } @override bool shouldRepaint(covariant CircleProgressPainter oldDelegate) { return oldDelegate.progress != progress || oldDelegate.trackColor != trackColor || oldDelegate.barColor != barColor; } }

这里有几个点需要新手注意。drawArc的起始角度我用了-math.pi / 2,也就是从 12 点方向开始画,而不是从 3 点方向,这样视觉上更符合常见仪表盘的起始位置。strokeCap设为StrokeCap.round能让进度条的端头变圆,与经典组件库的环形样式比较一致。中心放百分比文字用Stack叠加即可,不需要计算文字坐标。

环形进度条要撑开尺寸,我习惯在外层用SizedBox.square(dimension: 120)固定,否则 CustomPaint 默认尺寸是 0,画了也白画。

4. 封装成组件:API 设计和真实接入场景

4.1 对外暴露的参数设计

照搬经典组件库的用法,我把 XProgress 对外参数收敛成这样:

参数类型默认值说明
valuedouble0进度百分数,0~100
statusProgressStatusactive状态语义,决定填充色
shapeProgressShapelinearlinear 或 circle
strokeWidthdouble8线性轨道高度/环形线宽
stripedboolfalse是否显示条纹
stripedFlowboolfalse条纹是否流动
textString?null自定义文本,默认显示百分比
textInsideboolfalse文本是否显示在进度条内部
animationDurationint500数值动画时长,毫秒
barGradientGradient?null进度条渐变

参数不宜再多,再多调用方会晕。凡是能根据状态推导的,一律不暴露。比如成功状态的颜色,就是从status映射的,外部不应该传“绿色色值”。

4.2 组装:一个开关切换线形和环形

在build方法里,我用shape做分支,各自复用对应的 painter:

@override Widget build(BuildContext context) { final statusColor = _statusColorFor(widget.status); if (widget.shape == ProgressShape.circle) { return AnimatedBuilder( animation: Listenable.merge([_valueController, _stripeController]), builder: (context, _) => SizedBox.square( dimension: 120, child: Stack( alignment: Alignment.center, children: [ CustomPaint( size: const Size(120, 120), painter: CircleProgressPainter( progress: _progress.value.clamp(0.0, 1.0), trackColor: const Color(0xFFEBEEF5), barColor: statusColor, strokeWidth: widget.strokeWidth, ), ), Text( widget.text ?? '${(_progress.value * 100).round()}%', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), ), ], ), ), ); } return AnimatedBuilder( animation: Listenable.merge([_valueController, _stripeController]), builder: (context, _) => SizedBox( width: widget.width ?? double.infinity, height: widget.strokeWidth, child: CustomPaint( painter: LinearProgressPainter( progress: _progress.value.clamp(0.0, 1.0), trackColor: const Color(0xFFEBEEF5), barColor: statusColor, barGradient: widget.barGradient, strokeWidth: widget.strokeWidth, striped: widget.striped, stripedOffset: _stripeController.value * 6, ), ), ), ); }

注意线性进度条我没有实现textInside的完整排版,原因是文本避让逻辑有点绕,需要先预估文本宽度再反推进度条的保留宽度。完整方案是把外层包进LayoutBuilder,拿到可用宽度后先计算文本宽度,再从进度条最大宽度里减去文本宽度,最后得到进度绘制区域。这个逻辑不复杂,但建议只在确实需要的时候加,大部分管理后台场景用外部文本就够了。

4.3 三个真实接入场景

第一个是任务上传列表。每条任务卡片里放一根线性进度条,任务执行中蓝条,完成变绿条,失败红条。调用代码很干净:

XProgress( value: task.progress, status: task.status, strokeWidth: 6, )

第二个是存储水位卡片,环形进度条加中心百分比,还想要一点渐变效果:

XProgress( shape: ProgressShape.circle, value: storage.usagePercent, strokeWidth: 12, barGradient: LinearGradient( colors: [Color(0xFF409EFF), Color(0xFF66B1FF)], ), )

第三个是批量计算任务,为了表达“任务正在紧张计算中”,用带流动条纹的进度条。视觉上斜纹缓慢移动,用户一看就知道任务没卡死:

XProgress( value: job.progress, status: ProgressStatus.active, striped: true, stripedFlow: true, )

三个场景互相独立,组件参数完全覆盖,业务侧不需要为每个页面写样式。

5. 踩坑清单与排查实录

5.1 高频问题速查表

现象原因解法
进度只有几像素时右端变成大圆球圆角半径超过了宽度一半圆角取min(strokeWidth/2, barWidth/2)
100% 时进度条右边露出轨道底色barWidth 计算到了 size.width 之外绘制宽度 clamp 到size.width
数值连续变化时动画从 0 开始Tween.begin写死为 0用_progress.value作为 begin
动态数值不触发动画didUpdateWidget没有比较 old/new只在新旧 value 不同时才 forward
条纹流动导致整个页面卡顿每次 build 重建 Paint 对象painter 重写 shouldRepaint 减少无效绘制
渐变在布局变化后颜色错位shader 区域是旧尺寸在paint里重新 createShader
textInside 文字被进度条遮挡文本宽度没有参与布局计算用 LayoutBuilder 预留文本宽度

5.2 状态切换颜色过渡的渐进方案

前面提过ColorTween,但实现时有个细节:如果状态连续快速切换,比如任务从执行中变成功后立刻又变警告,普通forward(from: 0)会导致前一个动画还没跑完就重新开始。我的做法是在启动新过渡前先读当前展示的颜色作为新的 begin 色,这样无论切换多快,颜色都是从一个真实可见的中间值出发的。类比数值动画的 begin 取当前值,原理完全一样。

5.3 关于性能、像素对齐和文本渲染的额外提醒

自绘进度条的性能问题主要集中在 infinite animation 上。条纹流动的_stripeController是 repeat 状态,每帧都会触发CustomPaint重绘,这时候 painter 的shouldRepaint写不好,页面性能会急剧下降。我建议 painter 只比较 progress、color、striped、stripedOffset 这几个字段,不要无脑返回 true。

像素对齐也是一件容易被忽略的事。进度条宽度在绘制时用size.width * progress,遇到非整数宽度,边缘会出现半像素的模糊。想在部分设备上减轻虚化,可以配合 DPR 对齐:

final dpr = MediaQuery.of(context).devicePixelRatio; final barWidth = (size.width * progress * dpr).round() / dpr;

最后说文本渲染:默认百分比文本建议用Text包一层固定样式,缩进和行高要跟组件主题一致;如果业务方要自定义 text,组件不要直接塞String?,而是预留Widget? textBuilder之类的扩展点。一来文字内容可能带图标,二来不同语境的百分比格式差异很大。

5.4 别忘了暗色模式和零进度

后台管理系统里上暗色模式很常见。进度条的trackColor如果写死#EBEEF5这种浅灰,暗色模式下会非常刺眼。我给 XProgress 增加了一组可被主题覆盖的默认色板,track 色在暗色模式下自动替换为更深的灰。零进度也要测试:进度为 0 时 painter 里直接 return,不要让barWidth变成 0 后再去画一个宽度 0 的圆角矩形,Flutter 在某些设备上对零宽度 RRect 会给出怪异的表现。

我在实际项目里还遇到过一种情况:进度条放在Row里,某次数值从 100 回到 0,动画过程中_progress.value短暂变成负值,导致barWidth变负。所以每次绘制前都做一次clamp(0.0, 1.0),这个防御性操作不能省。

写在最后

这类仿经典后台组件风格的工作,难点从来不是某个 Flutter API 会不会用,而是 Web 端看起来简单的效果,移动端要照顾的细节更多:文本避让、小屏宽度、动画精度、高刷屏下的功耗,每一样都得自己定节奏。我第一次用 Stack 方案时觉得半天就能做完,后来改成自绘版又花了两天专门打磨边界 case,但成品逻辑统一,后续加新形态反而比维护一堆容器快得多。

如果你也在做类似的后台组件,我的建议是先从自绘入手,把状态、宽度、文本、动画一次性抽象到位。先拆状态,再写绘制,最后统一封装,这个顺序能帮你省掉一大半返工。最后再分享一个小技巧:把每个 painter 的shouldRepaint当成单元测试来写,字段不同才重绘,字段相同就返回 false,这样做完动画性能基本不会出大问题。

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

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

立即咨询