效果
要实现的效果是 Material 3 的 CircularWavyProgressIndicator。简单来说,就是一个带波浪的圆形进度条。
实现步骤
绘制轨道和平滑波浪
首先我们将轨道和“碾平”的波浪绘制出来。
对于普通圆弧,我们通常会使用 DrawScope.drawArc()。但我们需要绘制波浪,后续需要对每个点进行偏移,所以必须使用 Path() 来进行每个点的绘制。
1@Composable 2fun CircularWavyProgressIndicatorStep1( 3 modifier: Modifier = Modifier, 4 waveColor: Color = MaterialTheme.colorScheme.primary, // 波浪颜色 5 trackColor: Color = MaterialTheme.colorScheme.secondaryContainer, // 轨道颜色 6 waveStrokeWidth: Dp = 4.dp, // 波浪笔触宽度 7 trackStrokeWidth: Dp = 4.dp, // 轨道笔触宽度 8) { 9 // 复用 Path 对象,避免重组时的重建导致内存抖动 10 val wavePath = remember { Path() } 11 val trackPath = remember { Path() } 12 13 val density = LocalDensity.current 14 // 将 Dp 转为 Px 15 val waveStrokeWidthPx = with(density) { waveStrokeWidth.toPx() } 16 val trackStrokeWidthPx = with(density) { trackStrokeWidth.toPx() } 17 18 Canvas(modifier = modifier.size(48.dp)) { 19 val center = this.center 20 // 采样:1度画一次 21 val step = 1 22 23 val maxStroke = maxOf(waveStrokeWidthPx, trackStrokeWidthPx) 24 // 基础半径:容器宽的一半 - 笔触的一半,确保画笔不超出 Canvas 边界 25 val baseRadius = (size.minDimension - maxStroke) / 2f 26 27 // --- 绘制波浪 --- 28 wavePath.rewind() // 清空路径 29 val endAngle = 180f // 暂时画 180 度 30 31 for (i in 0..endAngle.toInt() step step) { 32 val currentAngle = i.toFloat() 33 val rad = Math.toRadians(currentAngle.toDouble()) // 角度转弧度 34 35 val x = center.x + (baseRadius * cos(rad)).toFloat() 36 val y = center.y + (baseRadius * sin(rad)).toFloat() 37 38 if (i == 0) { 39 wavePath.moveTo(x, y) 40 } else { 41 wavePath.lineTo(x, y) 42 } 43 } 44 drawPath( 45 path = wavePath, 46 color = waveColor, 47 style = Stroke(width = waveStrokeWidthPx, cap = StrokeCap.Round) 48 ) 49 50 // --- 绘制轨道 --- 51 trackPath.rewind() 52 val trackStartAngle = endAngle 53 val trackEndAngle = 360f 54 55 for (i in trackStartAngle.toInt()..trackEndAngle.toInt() step step) { 56 val currentAngle = i.toFloat() 57 val rad = Math.toRadians(currentAngle.toDouble()) 58 59 val x = center.x + (baseRadius * cos(rad)).toFloat() 60 val y = center.y + (baseRadius * sin(rad)).toFloat() 61 62 if (i == trackStartAngle.toInt()) { 63 // 移到起始点 64 trackPath.moveTo(x, y) 65 } else { 66 trackPath.lineTo(x, y) 67 } 68 } 69 drawPath( 70 path = trackPath, 71 color = trackColor, 72 style = Stroke(width = trackStrokeWidthPx, cap = StrokeCap.Round) 73 ) 74 } 75} 76
注意:
- 为了避免在重组时,频繁创建
Path对象导致内存抖动。我们使用了remember{ Path() }来复用路径对象。不过要在每次绘制前,调用rewind()清空已有路径。 sin()和cos()接收的都是弧度,需要调用Math.toRadians(),将角度转为弧度。
运行效果:
绘制波浪线
接下来,我们来给圆加上“褶皱”。原理也很简单,在计算半径时,叠加一个正弦函数即可。
公式:R=基础半径+振幅×sin(弧度×频率)R = \text{基础半径} + \text{振幅} \times \sin(\text{弧度} \times \text{频率})R=基础半径+振幅×sin(弧度×频率)
1@Composable 2fun CircularWavyProgressIndicatorStep2( 3 modifier: Modifier = Modifier, 4 waveColor: Color = MaterialTheme.colorScheme.primary, 5 trackColor: Color = MaterialTheme.colorScheme.secondaryContainer, 6 waveStrokeWidth: Dp = 4.dp, 7 trackStrokeWidth: Dp = 4.dp, 8 amplitude: Dp = 1.2.dp, // 振幅:决定波浪起伏的高度 9 wavelength: Dp = 15.dp, // 波长:决定波浪的密集程度 10) { 11 val wavePath = remember { Path() } 12 val trackPath = remember { Path() } 13 14 val density = LocalDensity.current 15 val waveStrokeWidthPx = with(density) { waveStrokeWidth.toPx() } 16 val trackStrokeWidthPx = with(density) { trackStrokeWidth.toPx() } 17 val amplitudePx = with(density) { amplitude.toPx() } 18 val wavelengthPx = with(density) { wavelength.toPx() } 19 20 Canvas(modifier = modifier.size(48.dp)) { 21 val center = this.center 22 val step = 1 23 val maxStroke = maxOf(waveStrokeWidthPx, trackStrokeWidthPx) 24 // 注意:半径要额外减去振幅,防止波峰超出边界被截断 25 val baseRadius = (size.minDimension - maxStroke) / 2f - amplitudePx 26 27 // --- 绘制波浪 --- 28 wavePath.rewind() 29 30 // 计算波浪的总周长和频率 31 val circumference = 2 * PI * baseRadius 32 val frequency = circumference / wavelengthPx 33 34 val endAngle = 180f 35 36 for (i in 0..endAngle.toInt() step step) { 37 val currentAngle = i.toFloat() 38 val rad = Math.toRadians(currentAngle.toDouble()) 39 40 // 叠加正弦波偏移 41 val waveOffset = amplitudePx * sin((rad * frequency)) 42 val r = baseRadius + waveOffset 43 44 val x = center.x + (r * cos(rad)).toFloat() 45 val y = center.y + (r * sin(rad)).toFloat() 46 47 if (i == 0) wavePath.moveTo(x, y) else wavePath.lineTo(x, y) 48 } 49 drawPath( 50 path = wavePath, 51 color = waveColor, 52 style = Stroke(width = waveStrokeWidthPx, cap = StrokeCap.Round) 53 ) 54 55 // --- 绘制轨道 --- 56 trackPath.rewind() 57 val trackStartAngle = endAngle 58 val trackEndAngle = 360f 59 60 for (i in trackStartAngle.toInt()..trackEndAngle.toInt() step step) { 61 val currentAngle = i.toFloat() 62 val rad = Math.toRadians(currentAngle.toDouble()) 63 64 val x = center.x + (baseRadius * cos(rad)).toFloat() 65 val y = center.y + (baseRadius * sin(rad)).toFloat() 66 67 if (i == trackStartAngle.toInt()) trackPath.moveTo(x, y) else trackPath.lineTo(x, y) 68 } 69 drawPath( 70 path = trackPath, 71 color = trackColor, 72 style = Stroke(width = trackStrokeWidthPx, cap = StrokeCap.Round) 73 ) 74 } 75} 76
运行效果:
处理间隙
可以看到,轨道和波浪重叠了。此时,我们可以添加一个 gapSize(间隙)。
其实也就是将间隙对应的弧长转成角度,在绘制时,省略这部分角度罢了。
不过,要考虑到 StrokeCap.Round 圆头笔触向外延伸的半个笔触宽度的半圆。
所以最终的弧长 = 期望的间隙 + 一个笔触宽度(两个半圆)
1@Composable 2fun CircularWavyProgressIndicatorStep3( 3 modifier: Modifier = Modifier, 4 waveColor: Color = MaterialTheme.colorScheme.primary, 5 trackColor: Color = MaterialTheme.colorScheme.secondaryContainer, 6 waveStrokeWidth: Dp = 4.dp, 7 trackStrokeWidth: Dp = 4.dp, 8 amplitude: Dp = 1.2.dp, 9 wavelength: Dp = 15.dp, 10 gapSize: Dp = 4.dp, // 间隙大小 11) { 12 val wavePath = remember { Path() } 13 val trackPath = remember { Path() } 14 val density = LocalDensity.current 15 16 val waveStrokeWidthPx = with(density) { waveStrokeWidth.toPx() } 17 val trackStrokeWidthPx = with(density) { trackStrokeWidth.toPx() } 18 val amplitudePx = with(density) { amplitude.toPx() } 19 val wavelengthPx = with(density) { wavelength.toPx() } 20 val gapSizePx = with(density) { gapSize.toPx() } 21 22 Canvas(modifier = modifier.size(48.dp)) { 23 val center = this.center 24 val step = 1 25 val maxStroke = maxOf(waveStrokeWidthPx, trackStrokeWidthPx) 26 val baseRadius = (size.minDimension - maxStroke) / 2f - amplitudePx 27 28 // 物理弧长 = 视觉间隙 + 画笔(笔触)宽度 29 val effectiveGapLength = gapSizePx + maxStroke 30 // 将弧长转换为角度 31 val gapAngle = Math.toDegrees((effectiveGapLength / baseRadius).toDouble()).toFloat() 32 33 // --- 绘制波浪 --- 34 wavePath.rewind() 35 val circumference = 2 * PI * baseRadius 36 val frequency = circumference / wavelengthPx 37 val endAngle = 180f 38 39 for (i in 0..endAngle.toInt() step step) { 40 val currentAngle = i.toFloat() 41 val rad = Math.toRadians(currentAngle.toDouble()) 42 val waveOffset = amplitudePx * sin((rad * frequency)) 43 val r = baseRadius + waveOffset 44 45 val x = center.x + (r * cos(rad)).toFloat() 46 val y = center.y + (r * sin(rad)).toFloat() 47 48 if (i == 0) wavePath.moveTo(x, y) else wavePath.lineTo(x, y) 49 } 50 drawPath( 51 path = wavePath, 52 color = waveColor, 53 style = Stroke(width = waveStrokeWidthPx, cap = StrokeCap.Round) 54 ) 55 56 // --- 绘制轨道 --- 57 trackPath.rewind() 58 59 // 轨道起点 = 波浪终点 + 间隙角度 60 val trackStartAngle = endAngle + gapAngle 61 // 轨道终点 = 360度 - 间隙角度 62 val trackEndAngle = 360f - gapAngle 63 64 // 只有当剩余空间足够时才绘制轨道 65 if (trackStartAngle < trackEndAngle) { 66 for (i in trackStartAngle.toInt()..trackEndAngle.toInt() step step) { 67 val currentAngle = i.toFloat() 68 val rad = Math.toRadians(currentAngle.toDouble()) 69 val x = center.x + (baseRadius * cos(rad)).toFloat() 70 val y = center.y + (baseRadius * sin(rad)).toFloat() 71 72 if (i == trackStartAngle.toInt()) trackPath.moveTo(x, y) else trackPath.lineTo(x, y) 73 } 74 drawPath( 75 path = trackPath, 76 color = trackColor, 77 style = Stroke(width = trackStrokeWidthPx, cap = StrokeCap.Round) 78 ) 79 } 80 } 81} 82
运行效果:
添加动画
最后,我们来让这个进度条动起来。我们需要三个维度的动画:
- SweepAngle(呼吸):控制波浪进度条的长短。我们使用了
keyframes,实现了波浪慢吸快呼的非线性节奏。 - Rotation(自转):控制整个圆环的旋转。我们自定义了贝塞尔曲线,实现了脉冲式的旋转。
- PhaseShift(流动):控制波浪的流动。通过改变波的相位偏移来实现的,这样即使圆环整体不转,波浪看起来也像在向前流动。
1@Composable 2fun CircularWavyProgressIndicatorStep4( 3 modifier: Modifier = Modifier, 4 waveColor: Color = MaterialTheme.colorScheme.primary, 5 trackColor: Color = MaterialTheme.colorScheme.secondaryContainer, 6 waveStrokeWidth: Dp = 4.dp, 7 trackStrokeWidth: Dp = 4.dp, 8 amplitude: Dp = 1.2.dp, 9 wavelength: Dp = 15.dp, 10 gapSize: Dp = 4.dp, 11 cycleDuration: Int = 1500, // 整体旋转周期 12 waveFlowDuration: Int = 3000 // 波浪流动周期 13) { 14 val infiniteTransition = rememberInfiniteTransition(label = "WavyTransition") 15 16 // 呼吸动画: 使用 keyframes 实现非对称的伸缩 17 val sweepAngle by infiniteTransition.animateFloat( 18 initialValue = 30f, 19 targetValue = 30f, 20 animationSpec = infiniteRepeatable( 21 animation = keyframes { 22 durationMillis = 5000 23 // 3秒内缓慢张开 24 300f at 3000 using CubicBezierEasing(.42f, 0f, 1f, 1f) 25 // 2秒内快速收缩 26 30f at 5000 using FastOutSlowInEasing 27 }, 28 repeatMode = RepeatMode.Restart 29 ), 30 label = "SweepAngle" 31 ) 32 33 // 自转动画: 整体旋转 34 val rotation by infiniteTransition.animateFloat( 35 initialValue = 0f, 36 targetValue = 360f, 37 animationSpec = infiniteRepeatable( 38 animation = tween(cycleDuration, easing = CubicBezierEasing(0.33f, 1f, 0.68f, 1f)), 39 ), 40 label = "Rotation" 41 ) 42 43 // 相位流动动画: 控制波浪纹理移动 44 val phaseShift by infiniteTransition.animateFloat( 45 initialValue = 0f, 46 targetValue = (2 * PI).toFloat(), // 移动一个完整波长,实现无缝循环 47 animationSpec = infiniteRepeatable( 48 animation = tween(waveFlowDuration, easing = LinearEasing) 49 ), 50 label = "PhaseShift" 51 ) 52 53 val wavePath = remember { Path() } 54 val trackPath = remember { Path() } 55 val density = LocalDensity.current 56 57 val waveStrokeWidthPx = with(density) { waveStrokeWidth.toPx() } 58 val trackStrokeWidthPx = with(density) { trackStrokeWidth.toPx() } 59 val amplitudePx = with(density) { amplitude.toPx() } 60 val wavelengthPx = with(density) { wavelength.toPx() } 61 val gapSizePx = with(density) { gapSize.toPx() } 62 63 Canvas(modifier = modifier.size(48.dp)) { 64 val center = this.center 65 val step = 1 66 val maxStroke = maxOf(waveStrokeWidthPx, trackStrokeWidthPx) 67 val baseRadius = (size.minDimension - maxStroke) / 2f - amplitudePx 68 val effectiveGapLength = gapSizePx + maxStroke 69 val gapAngle = Math.toDegrees((effectiveGapLength / baseRadius).toDouble()).toFloat() 70 71 // 使用 rotate 旋转整个画布 72 rotate(rotation) { 73 // --- 绘制波浪 --- 74 wavePath.rewind() 75 val circumference = 2 * PI * baseRadius 76 val frequency = circumference / wavelengthPx 77 // 结束角度由动画控制 78 val endAngle = sweepAngle 79 80 for (i in 0..endAngle.toInt() step step) { 81 val currentAngle = i.toFloat() 82 val rad = Math.toRadians(currentAngle.toDouble()) 83 84 // 将 phaseShift 加到正弦函数中,实现波浪流动 85 val waveOffset = amplitudePx * sin((rad * frequency) + phaseShift) 86 val r = baseRadius + waveOffset 87 88 val x = center.x + (r * cos(rad)).toFloat() 89 val y = center.y + (r * sin(rad)).toFloat() 90 91 if (i == 0) wavePath.moveTo(x, y) else wavePath.lineTo(x, y) 92 } 93 drawPath( 94 path = wavePath, 95 color = waveColor, 96 style = Stroke(width = waveStrokeWidthPx, cap = StrokeCap.Round) 97 ) 98 99 // --- 绘制轨道 --- 100 trackPath.rewind() 101 val trackStartAngle = sweepAngle + gapAngle 102 val trackEndAngle = 360f - gapAngle 103 104 if (trackStartAngle < trackEndAngle) { 105 for (i in trackStartAngle.toInt()..trackEndAngle.toInt() step step) { 106 val currentAngle = i.toFloat() 107 val rad = Math.toRadians(currentAngle.toDouble()) 108 val x = center.x + (baseRadius * cos(rad)).toFloat() 109 val y = center.y + (baseRadius * sin(rad)).toFloat() 110 111 if (i == trackStartAngle.toInt()) trackPath.moveTo( 112 x, 113 y 114 ) else trackPath.lineTo(x, y) 115 } 116 drawPath( 117 path = trackPath, 118 color = trackColor, 119 style = Stroke(width = trackStrokeWidthPx, cap = StrokeCap.Round) 120 ) 121 } 122 } 123 } 124} 125
以上就是最终的完整代码了,可以直接复制使用,当然你可以重命名为 CircularWavyProgressIndicator。
关于 Transition,可以看我的这篇博客:
关于贝塞尔曲线,可以看我的这篇博客:
运行效果:


