做管理后台类应用的时候进度条是那种不起眼但绕不过去的元件。任务上传、存储水位、数据同步、批量计算哪儿都离不开它。Flutter 自带的LinearProgressIndicator和CircularProgressIndicator虽然开箱即用但在偏企业工具的界面里总显得太“原生”和后台开发老哥熟悉的 Element 样式进度条差着好几个视觉层次。Element 样式的 Progress 无非就是那几个特征细长的圆角轨道、按百分比填充的色块、默认/成功/异常/警告四态颜色映射、可选的内部百分比文字以及环形形态。这篇博文把我用 Flutter 完整复刻这套样式的全过程拆开来讲覆盖需求拆解、绘制选型、动画实现和真实项目里踩过的坑。适合正打算把 Web 后台迁移到 Flutter 的团队也适合被 UI 稿上“仿经典后台组件进度条”需求逼疯的 Flutter 开发者。1. 写之前先拆需求进度条不是一根矩形这么简单1.1 先把目标样式拆成一份组件清单仿制一个跨端组件最忌讳上来就对着截图写代码。我这次仿 Element 样式进度条第一步是列出需要还原的要素清单六条就够形态线性horizontal和环形circle两种轨道细高度常见 6~10px、圆角、淡底色填充按进度百分比绘制主色块支持渐变状态默认、成功、异常、警告四种语义色文本百分比文字可以放在外部右侧也可以放在进度条内部动态数值变化时有过渡动画条纹模式时斜条缓慢流动。为什么非要拆得这么细因为进度条在 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 / 2RRect会自动修正成两个半圆视觉上就是一个长条胶囊跟右侧还残余的大段轨道放在一起很不协调。我起初没处理结果测试阶段看到进度 3% 的截图才发现问题。修正方案很简单圆角半径取轨道一半和进度块宽度一半的较小值final barRadius (barWidth size.height) ? strokeWidth / 2 : barWidth / 2;如果进度只有 2 像素宽、轨道高 8 像素那么 bar 圆角就是 1 像素接近一个微凸的矩形等进度走到 30% 以上右侧才逐渐变回标准的 4 像素圆角。这个处理会带来一个“圆角从直变圆”的过渡视觉上是自然的因为宽度变大本身就意味着曲线弧度变大。3. 动起来数值动画、状态切换和条纹流动3.1 进度数值动画AnimationController 的正确姿势静态进度条做得再精致在数字从 20 跳到 80 的时候也显得廉价。我给组件加了一个数值动画核心是利用AnimationControllerTweenclass _XProgressState extends StateXProgress with SingleTickerProviderStateMixin { late final AnimationController _valueController; late Animationdouble _progress; late final AnimationController _stripeController; override void initState() { super.initState(); _valueController AnimationController( vsync: this, duration: Duration(milliseconds: widget.animationDuration), ); _progress Tweendouble( 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 Tweendouble( 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 里也很直接用ColorTweenColorTween( 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~100statusProgressStatusactive状态语义决定填充色shapeProgressShapelinearlinear 或 circlestrokeWidthdouble8线性轨道高度/环形线宽stripedboolfalse是否显示条纹stripedFlowboolfalse条纹是否流动textString?null自定义文本默认显示百分比textInsideboolfalse文本是否显示在进度条内部animationDurationint500数值动画时长毫秒barGradientGradient?null进度条渐变参数不宜再多再多调用方会晕。凡是能根据状态推导的一律不暴露。比如成功状态的颜色就是从status映射的外部不应该传“绿色色值”。4.2 组装一个开关切换线形和环形在build方法里我用shape做分支各自复用对应的 painteroverride 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里重新 createShadertextInside 文字被进度条遮挡文本宽度没有参与布局计算用 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这样做完动画性能基本不会出大问题。
阅读完成 · 觉得有帮助?