资讯动态

Jetpack Compose自定义圆形渐变进度条实战:从Canvas到动画

发布时间:2026/9/19 5:29:14 来源:尧图企业网站定制
先交代一下我为什么折腾这个Compose圆形渐变进度条。需求很常见运动App里的卡路里圆环、学习时长打卡环、设备电量可视化要求环形渐变、中间显示百分比、进度变化有动画。我扫了一遍Material3组件库发现 CircularProgressIndicator 虽然能用但要实现渐变效果、自定义圆弧粗细、在圆环中间塞文字都得自己动手。于是就有了这篇实战记录从绘制原理、完整代码、动画封装到实际踩过的几个坑和进阶方案一次性讲透。这篇文章适合刚接触 Jetpack Compose 自定义绘制的同学也适合已经在用 Canvas 但想做得更精细的开发者。我会尽量把“为什么这么做”讲清楚而不是只丢一段能跑的代码。毕竟自定义绘制这东西不理解坐标系和画布变换复制代码也是一头雾水。1. 为什么放着现成的CircularProgressIndicator不用1.1 需求场景长什么样先说说圆形渐变进度条的典型使用场景。最常见的是健身类App的卡路里消耗环整个环从红色渐变到橙色中间用大号数字显示已消耗的热量值其次是时间管理类应用用不同颜色的圆环表示番茄钟周期、专注时长再有就是各类数据大盘用多个嵌套圆环展示完成率、达标率。这些场景有两个共同的视觉需求渐变单色圆环在视觉上比较平渐变能制造“从起点到终点”的层次感让进度本身更有叙事性。比如从蓝色渐变到紫色看起来就像能量在累积。中间内容圆环中间要放百分比、数值、阶段名称这就需要圆环有明确的中心内容区。我最初也想过用图片或者叠加多层圆形View来实现但那样的话进度变化、动画跟随、颜色调整都非常僵硬。真正常态化的做法是在 Compose 里用 Canvas 自己画。1.2 默认组件缺了什么Compose Material3 的 CircularProgressIndicator 确实提供了基础的圆形进度能力可以设置 progress、trackColor、strokeWidth新版还支持了 lambda 形式的 progress。但真要把渐变圆环做出来它有几个硬伤能力CircularProgressIndicator自绘 Canvas 方案渐变支持不支持只能单色任意 Brush 渐变圆弧粗细支持 strokeWidth完全自定义端头样式默认平头控制有限StrokeCap 任意切换中间内容无法直接放置同心绘制文字或图标起始角度固定从顶部开始任意角度控制轨道与进度样式分离有限完全分离这里的核心问题是官方加载指示器是给“不确定进度”和“简单确定进度”设计的它本身没有预留内容插槽也不是为了做数据可视化仪表盘而生的。一旦牵扯到品牌色、渐变纹理、中间文案就只能自绘。其实自绘圆形进度条的难度并不高核心就三个 APICanvas、drawArc、Brush。把它们之间的关系搞明白你就能封装出完全属于自己的组件。2. 圆形进度条绘制原理从Canvas到渐变Brush的四步拆解2.1 Canvas和drawArc的基础语法Compose 里的 Canvas 是一个 Composable它本质上是一个持有 DrawScope 的绘制容器。你可以在它的 lambda 里调用所有绘制函数包括 drawArc、drawCircle、drawLine、drawText 等等。绘制圆形进度条离不开 drawArc。它的核心参数是 startAngle 和 sweepAngle这两个参数的单位是“度”不是弧度。重点来了drawArc 的 0 度位置在屏幕的 3 点钟方向角度增量方向是顺时针。这个约定跟传统数学坐标系相反。数学坐标系里角度逆时针增长而屏幕坐标系因为 y 轴向下导致顺时针为正。所以如果你直接写drawArc( startAngle 0f, sweepAngle 90f, ... )画出来的是从 3 点钟方向向下画 90 度也就是从 3 点钟转到 6 点钟。很多第一次接触 drawArc 的人都会在这里懵一下。另一个容易忽略的参数是 useCenter。如果 useCenter 为 true圆弧会从中心点连到弧线两端形成一个扇形如果为 false就只画弧线本身这是进度条需要的模式。2.2 为什么是sweepGradient而不是linearGradientCompose 里实现渐变最常用的是 Brush。LinearGradient 会沿一条直线方向进行颜色过渡SweepGradient 则是以某个点为中心绕一圈进行 360 度渐变也就是常说的“扫掠渐变”。对于圆形进度条SweepGradient 是天然的匹配项。因为它本身就是绕圆心的角度渐变和圆环的弧长分布逻辑一致。圆弧从 12 点钟方向转到终点正好对应 SweepGradient 从起始颜色到结束颜色的变化。如果强行用 LinearGradient 画圆环会出现颜色沿直线分布、到了环上却扭曲的情况。一个比较直观的类比是LinearGradient 就像把一根喷了渐变色带的直尺硬生生弯成一个圆环色带在弯折处会发生挤压和断裂。而 SweepGradient 就像一个圆形调色盘颜色本身就是绕着圆心铺开的。SweepGradient 的构造函数通常在代码里写成这样Brush.sweepGradient( colors listOf( Color(0xFFFF5F6D), Color(0xFFFFC371), Color(0xFF36D1DC) ), center center )它的默认起点也是 3 点钟方向角度顺时针增加。这跟 drawArc 完全一致所以如果你直接组合它们起点位置天然对齐。2.3 角度偏移3点方向0度与-90度虽然 drawArc 和 sweepGradient 的角度起点都在 3 点钟方向但绝大多数进度条组件的业务起点在 12 点钟方向。这时候就需要把整个绘制整体旋转 -90 度。这里有一个很容易踩坑的分歧点你是只把 drawArc 的 startAngle 设置成 -90 度还是用 rotate 把整个绘制场景旋转只改 startAngle 的写法是这样的drawArc( brush brush, startAngle -90f, sweepAngle 360f * progress, ... )这确实会让圆弧从 12 点开始画。但问题是 Brush 里的 SweepGradient 起点仍然是 3 点钟方向结果是圆弧起点和渐变起点错位了 90 度。如果渐变颜色对比很强烈你会看到进度条从 12 点出发时颜色并不是想要的第一个颜色而是渐变中间的某个过渡色。更优雅的做法是整体旋转rotate(degrees -90f) { drawArc( brush brush, startAngle 0f, sweepAngle 360f * progress, ... ) }这样圆弧和渐变一起旋转两者的 0 度起点都落在 12 点钟方向颜色从第一个颜色开始视觉完全对齐。使用 rotate 还有一个好处后续如果业务需要改成从任意角度开始比如电池环从 6 点钟开始只需要把 -90f 换成对应的偏移值即可。3. 第一版实现一个能跑的圆形渐变进度环3.1 尺寸边界计算为什么圆弧会被裁掉一半绘制圆形进度条之前先要处理一个非常容易忽略的坑圆弧的描边宽度是在路径中心线两侧均匀扩展的。如果你画一个外接矩形和 Canvas 边界完全重合的圆Stroke 宽度为 12dp那么有 6dp 的笔画会画到 Canvas 外面直接被裁掉。解决方法很简单把圆弧的外接矩形向内收缩 stroke/2。val stroke strokeWidth.toPx() val diameter this.size.minDimension - stroke val arcSize Size(diameter, diameter) val arcTopLeft Offset(stroke / 2f, stroke / 2f)这里用 minDimension 是为了保证在 Canvas 不是严格正方形时也能取到合适的直径。如果你确定 Canvas 是正方形直接用 size.width 也可以。这个细节直接决定了最终的圆环是否完整。我第一次写的时候没注意结果圆环上下边缘被切了一块检查半天才发现是边界计算问题。3.2 轨道与进度弧的分层绘制一个完整的进度环需要画两层先画底层的轨道一般是灰色或浅色全圆再画顶层的彩色进度弧。轨道相对简单直接画一个 360 度的圆弧即可。进度弧则需要根据 progress 计算 sweepAngleval sweepAngle 360f * progress.coerceIn(0f, 1f)绘制出来之后记得加上 StrokeCap.Round。圆头端点会让进度条两端看起来更柔和整体质感瞬间提升。不过这个圆头在进度接近 100% 时也会带来一点麻烦后面我会专门讲。3.3 完整代码第一版下面是一份可以直接跑起来的第一版实现不包含动画先把静态绘制逻辑讲清楚。Composable fun GradientRingProgress( progress: Float, modifier: Modifier Modifier, strokeWidth: Dp 12.dp, trackColor: Color Color(0xFFE8EAF0), gradientColors: ListColor listOf( Color(0xFFFF5F6D), Color(0xFFFFC371) ) ) { Canvas(modifier modifier.requiredSize(160.dp)) { val stroke strokeWidth.toPx() val diameter this.size.minDimension - stroke val arcSize Size(diameter, diameter) val arcTopLeft Offset(stroke / 2f, stroke / 2f) // 轨道 drawArc( color trackColor, startAngle 0f, sweepAngle 360f, useCenter false, topLeft arcTopLeft, size arcSize, style Stroke(width stroke) ) // 渐变进度弧 val brush Brush.sweepGradient( colors gradientColors, center center ) rotate(degrees -90f) { drawArc( brush brush, startAngle 0f, sweepAngle 360f * progress.coerceIn(0f, 1f), useCenter false, topLeft arcTopLeft, size arcSize, style Stroke(width stroke, cap StrokeCap.Round) ) } } }把 progress 设为 0.7f运行一下你应该能看到一个从 12 点钟方向开始、顺时针延伸到 7 成位置、颜色从红色过渡到橙色的圆环。这版代码的核心逻辑就两步先算内缩后的弧矩形再通过 rotate 把进度弧和渐变同时转到 12 点方向。理解了这两步后面加动画、加文字、加参数都是锦上添花。4. 让它动起来动画、文案与参数化封装4.1 进度动画animateFloatAsState还是Animatable静态进度环只能看不能玩真实项目里肯定要加动画。Compose 里有两个常见选择animateFloatAsState 和 Animatable。animateFloatAsState 的写法最简单适合“进度值由外部状态驱动、直接渐变到新值”的场景val animatedProgress by animateFloatAsState( targetValue progress, animationSpec tween(1000, easing FastOutSlowInEasing), label ringProgress )Animatable 的灵活度更高适合需要手动控制动画生命周期、动态调整时长、或者连续播放多个动画的场景val progressAnimator remember { Animatable(0f) } LaunchedEffect(targetProgress) { progressAnimator.animateTo( targetValue targetProgress, animationSpec tween( durationMillis (abs(targetProgress - progressAnimator.value) * 1000).toInt() ) ) }两套方案的取舍可以看这个表格维度animateFloatAsStateAnimatable代码量少声明式稍多命令式动态时长不支持时长固定支持可任意计算动画组合单段可组合多段可中断协程控制无可取消、可监听状态如果是通用的进度条组件我建议用 animateFloatAsState简单可靠调用方只需要传 targetValue。只有在特殊页面需要按差值计算时长时再改用 Animatable。4.2 圆环中间的文字信息绘制圆环中心放文字是刚需。Compose 里在 Canvas 中绘制文字的标准做法是先测量文本尺寸再计算居中偏移。推荐用 rememberTextMeasurer 来做文本测量代码里先在外面测量好再在 Canvas 绘制时直接引用val textMeasurer rememberTextMeasurer() val textLayout text?.let { value - remember(value, textStyle) { textMeasurer.measure( text AnnotatedString(value), style textStyle ) } } Canvas(modifier modifier) { textLayout?.let { layout - drawText( textLayoutResult layout, topLeft Offset( x center.x - layout.size.width / 2f, y center.y - layout.size.height / 2f ) ) } }这里有一个性能注意点文本测量measure是相对昂贵的操作尽量在 Canvas 外面做并且用 remember 缓存起来。不要在 draw 阶段每次都测量文本否则动画期间每一帧都在做文本布局很容易引起卡顿。4.3 参数化设计与调用示例把上面这些逻辑合并再补上常用的可配置项一个可复用的圆形渐变进度条组件就成型了。Composable fun GradientRingProgress( progress: Float, modifier: Modifier Modifier, size: Dp 140.dp, strokeWidth: Dp 12.dp, trackColor: Color Color(0xFFE8EAF0), gradientColors: ListColor listOf( Color(0xFFFF5F6D), Color(0xFFFFC371) ), strokeCap: StrokeCap StrokeCap.Round, text: String? null, textStyle: TextStyle TextStyle( fontSize 28.sp, fontWeight FontWeight.Bold, color Color(0xFF333333) ), animateDurationMillis: Int 800 ) { val textMeasurer rememberTextMeasurer() val animatedProgress by animateFloatAsState( targetValue progress.coerceIn(0f, 1f), animationSpec tween(animateDurationMillis, easing FastOutSlowInEasing), label ringProgress ) val textLayout text?.let { value - remember(value, textStyle) { textMeasurer.measure( text AnnotatedString(value), style textStyle ) } } Canvas(modifier modifier.requiredSize(size)) { val stroke strokeWidth.toPx() val diameter this.size.minDimension - stroke val arcSize Size(diameter, diameter) val arcTopLeft Offset(stroke / 2f, stroke / 2f) drawArc( color trackColor, startAngle 0f, sweepAngle 360f, useCenter false, topLeft arcTopLeft, size arcSize, style Stroke(width stroke) ) val brush Brush.sweepGradient( colors gradientColors, center center ) rotate(degrees -90f) { drawArc( brush brush, startAngle 0f, sweepAngle 360f * animatedProgress, useCenter false, topLeft arcTopLeft, size arcSize, style Stroke(width stroke, cap strokeCap) ) } textLayout?.let { layout - drawText( textLayoutResult layout, topLeft Offset( x center.x - layout.size.width / 2f, y center.y - layout.size.height / 2f ) ) } } }调用方式很直接GradientRingProgress( progress 0.75f, size 160.dp, strokeWidth 14.dp, gradientColors listOf(Color(0xFF36D1DC), Color(0xFF5B86E5), Color(0xFF36D1DC)), text 75% )注意我在这里特意让渐变首尾颜色相同这是为了避免后面要讲的渐变接缝问题提前在这里做了一层防护。5. 实测踩过的四个坑与完整排查思路5.1 进度方向反了角度坐标与直觉的冲突第一次写完代码我信心满满地把 progress 设为 0.25f预期圆弧从 12 点方向到达 3 点方向。结果圆弧跑到了从 6 点方向经过 9 点再到 12 点整个方向跟预期相反。排查过程并不复杂我打印了 startAngle 默认值确认 drawArc 的 0 度在 3 点钟方向正角度顺时针。由于屏幕坐标系 y 轴向下顺时针成了正方向这跟初中数学的“逆时针为正”完全相反。确认根因后我把绘制整体 rotate(-90f)让圆弧从 12 点方向开始。这里的关键教训是不要只调 startAngle要让圆弧和渐变 Brush 一起转否则颜色起点和弧线起点会错位。5.2 渐变接缝SweepGradient在0度/360度处的颜色突变组件基本成型后我把 progress 调到 1.0f圆环完整闭合结果在 12 点钟方向看到一条明显的“裂缝”红色一侧突然跳变成蓝色一侧。原因在于 SweepGradient 的颜色映射是线性的0 度位置使用 colors[0]360 度位置使用 colors[last]。由于 0 度和 360 度在物理上是同一个位置如果 colors[0] 和 colors[last] 不是同一个颜色就会在这个位置产生一个从最后一个颜色跳回第一个颜色的突变。排查思路是把 gradientColors 换成两种对比明显的颜色比如红色和蓝色progress 分别设为 0.9f 和 1.0f对比观察接缝是否出现。确认后我列出了三个解决方案首尾同色让 colors 列表第一个和最后一个颜色相同渐变绕一圈没有任何跳变适合“环形能量环”这类永久闭合的视觉。动态压缩 stops让渐变只在当前进度区域生效而不是在整个圆周上分布这样即使首尾不同色也不存在可见的 360 度接缝适合“起点到终点”的进度条。接受接缝如果业务本身不追求满进度显示接缝不会出现就不用处理。我在封装组件时默认用首尾同色的渐变方案用户传三四个颜色时也能保证视觉平滑。5.3 StrokeCap.Round的端点外扩用上 StrokeCap.Round 之后进度在 80% 左右时非常好看弧线两端有圆润的收口。但当进度接近 100%我发现起点和终点位置冒出一个“鼓包”像是线段在首尾相接处叠加了一个圆形。排查链路如下先怀疑是 sweepAngle 超过了 360 度。打印发现 progress 是 1.0fsweepAngle 是 360f符合预期。接着怀疑是绘制产生了重叠反复检查 drawArc 参数后突然意识到这是 StrokeCap.Round 的固有行为——圆头端点会在路径两端各向外延伸 strokeWidth / 2 的长度。当路径接近闭合时起点端点和终点端点的圆头会叠加形成一个比stroke宽度更大的凸起。解决方式是在进度大于等于 0.999f 时把 StrokeCap 切换为 Buttval cap if (animatedProgress 0.999f) StrokeCap.Butt else strokeCap这样满进度时圆环端点平直相接不会有鼓包。而 0 到 99% 的区间仍然保持圆润的视觉风格。5.4 动画期间的重复重组动画接进来之后页面明显变卡。我打开 Profile 检查发现动画期间整个 Composable 树每秒重组 60 次不只是进度环本身连同一层级的其他组件也在重组。这个问题的根因是我在组合函数体内直接读取了 animatedProgress 这个 State 值。Compose 的状态读取范围决定了重组范围任何在 Composable lambda 里读取的状态变化都会触发该 Composable 重组。把读取放到 Canvas 的 draw lambda 里之后动画状态变化只会触发重绘而不会触发重组。看一下上面 4.3 的代码我处理得很明确animatedProgress 是在 Canvas 外获取的但在 Canvas lambda 里读取。动画运行时DrawScope 重绘组合函数体内的文本测量、remember 缓存都不受影响这样页面就能保持流畅。如果你把代码写成这样就会踩到重组坑// 不推荐组合函数体内读取动画值 val animatedProgress by animateFloatAsState(...) val brush Brush.sweepGradient(colors gradientColors, center center) Canvas(modifier modifier) { drawArc(brush brush, sweepAngle 360f * animatedProgress, ...) }因为 brush 和依赖动画值的计算都在组合阶段发生整个 Composable 会跟着动画一起重组。我在实际项目里就是因为没有注意这个问题页面元素一多掉帧才明显。6. 进阶渐变方向随进度变化的实现方案6.1 两种渐变语义圆周绝对渐变 vs 起点到终点渐变到这一步基础组件已经足够应付大多数场景。但如果你想把进度条做得更精细还需要理解两种渐变语义的差异圆周绝对渐变颜色均匀分布在整个圆周上。进度到 50% 时弧线末端是中段颜色进度到 100% 时末端是最后一个颜色。这是默认 SweepGradient 的行为。起点到终点渐变不论进度是多少弧线起点永远是第一个颜色弧线末端永远是最后一个颜色。这是很多设计稿实际想表达的“渐变进度条”。这两种语义用下面的表格对比更直观场景圆周绝对渐变起点到终点渐变50% 时末端颜色colors 中段色colors 最后一个颜色100% 时效果红到蓝完整渐变红到蓝完整渐变实现复杂度简单需要动态计算 stops适用场景能量环、闭合环进度条、仪表盘我之前封装的第一版属于圆周绝对渐变。如果你的设计稿要求“弧线从红色渐变为蓝色”中途任何进度都应该红到蓝那就需要动态压缩渐变范围。6.2 动态压缩stops的代码实现动态压缩的核心思路是不把 colors 均匀映射到整个 0f..1f 范围而是映射到 0f..progress 范围。由于最终绘制也只画到 progress 对应的角度弧线视觉上会恰好呈现“从第一个颜色到最后一个颜色”。实现代码如下val brush if (animatedProgress 0f) { Brush.sweepGradient( colors listOf(gradientColors.first(), gradientColors.first()), center center ) } else { val stops gradientColors.mapIndexed { index, color - (index.toFloat() / (gradientColors.size - 1)) * animatedProgress to color } Brush.sweepGradient( colorStops stops.toTypedArray(), center center ) }假设 gradientColors 是 [红色, 黄色, 蓝色]animatedProgress 是 0.5f那么 stops 就是0f - 红色0.25f - 黄色0.5f - 蓝色在 SweepGradient 内部从 0.5f 到 1f 的范围会使用最后一个颜色继续填充但这部分不会暴露因为 drawArc 只画到 0.5f 对应的 180 度位置。于是你看到的弧线就是红到蓝的完整渐变。这个方案的缺点是 Brush 在动画期间会随 progress 变化而重建。不过实测对于单个圆环组件来说这个开销可以接受。如果你的页面有大量进度环并且要求极致性能可以在组合阶段用 remember 缓存静态的 stops只有在 progress 完成时才更新但那样就会牺牲掉动画过程中的渐变连续性属于一种取舍。6.3 我的选型建议我在实际项目中最终采用的组合方案是这样的组件对外暴露两个参数一个控制渐变是否首尾闭合一个控制是否启用“起点到终点渐变”。默认状态是启用起点到终点渐变、使用 Butt cap同时支持动画。这样在绝大多数设计稿下都能直接呈现正确效果。有一点要特别提醒动态压缩 stops 的方案只适用于单段连续弧如果你后续要做“双色各占一半”的分段圆弧比如前半段红黄渐变、后半段蓝绿渐变那就必须改成分别绘制两段 drawArc每段各用一个 Brush而不是在一个 SweepGradient 里硬凑。如果你希望组件更精简也可以把动态压缩逻辑抽出来fun dynamicSweepGradient( colors: ListColor, progress: Float, center: Offset ): Brush { if (progress 0f) { return Brush.sweepGradient(listOf(colors.first(), colors.first()), center) } val stops colors.mapIndexed { index, color - (index.toFloat() / (colors.size - 1)) * progress to color } return Brush.sweepGradient(stops.toTypedArray(), center) }这个函数独立出来之后UnitTest 也好写其他组件也能复用。最后说一点我的实际体会。自绘圆环进度条并不难难的是把视觉细节和用户交互理顺。角度坐标系、渐变接缝、端点延伸这些问题不实际跑一遍真的发现不了。我的建议是先写一个最简版本跑起来完成“轨道 进度弧 渐变”三步再逐步叠加动画和文字不要试图一口气写出一个支持所有能力的组件那样排查起问题来会很痛苦。等你把基础版本玩熟了自然会理解哪些参数该暴露、哪些内部处理就好。这一套思路不仅适用于圆形进度条任何 Compose 自定义绘制组件都可以沿用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价