效果
要实现的效果是 Material 3 的 CircularWavyProgressIndicator。简单来说,就是一个带波浪的圆形进度条。
实现步骤
绘制轨道和平滑波浪
首先我们将轨道和"碾平"的波浪绘制出来。
对于普通圆弧,我们通常会使用 DrawScope.drawArc()。但我们需要绘制波浪,后续需要对每个点进行偏移,所以必须使用 Path() 来进行每个点的绘制。
kotlin
@Composable
fun CircularWavyProgressIndicatorStep1(
modifier: Modifier = Modifier,
waveColor: Color = MaterialTheme.colorScheme.primary, // 波浪颜色
trackColor: Color = MaterialTheme.colorScheme.secondaryContainer, // 轨道颜色
waveStrokeWidth: Dp = 4.dp, // 波浪笔触宽度
trackStrokeWidth: Dp = 4.dp, // 轨道笔触宽度
) {
// 复用 Path 对象,避免重组时的重建导致内存抖动
val wavePath = remember { Path() }
val trackPath = remember { Path() }
val density = LocalDensity.current
// 将 Dp 转为 Px
val waveStrokeWidthPx = with(density) { waveStrokeWidth.toPx() }
val trackStrokeWidthPx = with(density) { trackStrokeWidth.toPx() }
Canvas(modifier = modifier.size(48.dp)) {
val center = this.center
// 采样:1度画一次
val step = 1
val maxStroke = maxOf(waveStrokeWidthPx, trackStrokeWidthPx)
// 基础半径:容器宽的一半 - 笔触的一半,确保画笔不超出 Canvas 边界
val baseRadius = (size.minDimension - maxStroke) / 2f
// --- 绘制波浪 ---
wavePath.rewind() // 清空路径
val endAngle = 180f // 暂时画 180 度
for (i in 0..endAngle.toInt() step step) {
val currentAngle = i.toFloat()
val rad = Math.toRadians(currentAngle.toDouble()) // 角度转弧度
val x = center.x + (baseRadius * cos(rad)).toFloat()
val y = center.y + (baseRadius * sin(rad)).toFloat()
if (i == 0) {
wavePath.moveTo(x, y)
} else {
wavePath.lineTo(x, y)
}
}
drawPath(
path = wavePath,
color = waveColor,
style = Stroke(width = waveStrokeWidthPx, cap = StrokeCap.Round)
)
// --- 绘制轨道 ---
trackPath.rewind()
val trackStartAngle = endAngle
val trackEndAngle = 360f
for (i in trackStartAngle.toInt()..trackEndAngle.toInt() step step) {
val currentAngle = i.toFloat()
val rad = Math.toRadians(currentAngle.toDouble())
val x = center.x + (baseRadius * cos(rad)).toFloat()
val y = center.y + (baseRadius * sin(rad)).toFloat()
if (i == trackStartAngle.toInt()) {
// 移到起始点
trackPath.moveTo(x, y)
} else {
trackPath.lineTo(x, y)
}
}
drawPath(
path = trackPath,
color = trackColor,
style = Stroke(width = trackStrokeWidthPx, cap = StrokeCap.Round)
)
}
}
注意:
- 为了避免在重组时,频繁创建
Path对象导致内存抖动。我们使用了remember{ Path() }来复用路径对象。不过要在每次绘制前,调用rewind()清空已有路径。 sin()和cos()接收的都是弧度,需要调用Math.toRadians(),将角度转为弧度。
运行效果:

绘制波浪线
接下来,我们来给圆加上"褶皱"。原理也很简单,在计算半径时,叠加一个正弦函数即可。
公式: <math xmlns="http://www.w3.org/1998/Math/MathML"> R = 基础半径 + 振幅 × sin ( 弧度 × 频率 ) R = \text{基础半径} + \text{振幅} \times \sin(\text{弧度} \times \text{频率}) </math>R=基础半径+振幅×sin(弧度×频率)
kotlin
@Composable
fun CircularWavyProgressIndicatorStep2(
modifier: Modifier = Modifier,
waveColor: Color = MaterialTheme.colorScheme.primary,
trackColor: Color = MaterialTheme.colorScheme.secondaryContainer,
waveStrokeWidth: Dp = 4.dp,
trackStrokeWidth: Dp = 4.dp,
amplitude: Dp = 1.2.dp, // 振幅:决定波浪起伏的高度
wavelength: Dp = 15.dp, // 波长:决定波浪的密集程度
) {
val wavePath = remember { Path() }
val trackPath = remember { Path() }
val density = LocalDensity.current
val waveStrokeWidthPx = with(density) { waveStrokeWidth.toPx() }
val trackStrokeWidthPx = with(density) { trackStrokeWidth.toPx() }
val amplitudePx = with(density) { amplitude.toPx() }
val wavelengthPx = with(density) { wavelength.toPx() }
Canvas(modifier = modifier.size(48.dp)) {
val center = this.center
val step = 1
val maxStroke = maxOf(waveStrokeWidthPx, trackStrokeWidthPx)
// 注意:半径要额外减去振幅,防止波峰超出边界被截断
val baseRadius = (size.minDimension - maxStroke) / 2f - amplitudePx
// --- 绘制波浪 ---
wavePath.rewind()
// 计算波浪的总周长和频率
val circumference = 2 * PI * baseRadius
val frequency = circumference / wavelengthPx
val endAngle = 180f
for (i in 0..endAngle.toInt() step step) {
val currentAngle = i.toFloat()
val rad = Math.toRadians(currentAngle.toDouble())
// 叠加正弦波偏移
val waveOffset = amplitudePx * sin((rad * frequency))
val r = baseRadius + waveOffset
val x = center.x + (r * cos(rad)).toFloat()
val y = center.y + (r * sin(rad)).toFloat()
if (i == 0) wavePath.moveTo(x, y) else wavePath.lineTo(x, y)
}
drawPath(
path = wavePath,
color = waveColor,
style = Stroke(width = waveStrokeWidthPx, cap = StrokeCap.Round)
)
// --- 绘制轨道 ---
trackPath.rewind()
val trackStartAngle = endAngle
val trackEndAngle = 360f
for (i in trackStartAngle.toInt()..trackEndAngle.toInt() step step) {
val currentAngle = i.toFloat()
val rad = Math.toRadians(currentAngle.toDouble())
val x = center.x + (baseRadius * cos(rad)).toFloat()
val y = center.y + (baseRadius * sin(rad)).toFloat()
if (i == trackStartAngle.toInt()) trackPath.moveTo(x, y) else trackPath.lineTo(x, y)
}
drawPath(
path = trackPath,
color = trackColor,
style = Stroke(width = trackStrokeWidthPx, cap = StrokeCap.Round)
)
}
}
运行效果:

处理间隙
可以看到,轨道和波浪重叠了。此时,我们可以添加一个 gapSize(间隙)。
其实也就是将间隙对应的弧长转成角度,在绘制时,省略这部分角度罢了。
不过,要考虑到 StrokeCap.Round 圆头笔触向外延伸的半个笔触宽度的半圆。
所以最终的弧长 = 期望的间隙 + 一个笔触宽度(两个半圆)
kotlin
@Composable
fun CircularWavyProgressIndicatorStep3(
modifier: Modifier = Modifier,
waveColor: Color = MaterialTheme.colorScheme.primary,
trackColor: Color = MaterialTheme.colorScheme.secondaryContainer,
waveStrokeWidth: Dp = 4.dp,
trackStrokeWidth: Dp = 4.dp,
amplitude: Dp = 1.2.dp,
wavelength: Dp = 15.dp,
gapSize: Dp = 4.dp, // 间隙大小
) {
val wavePath = remember { Path() }
val trackPath = remember { Path() }
val density = LocalDensity.current
val waveStrokeWidthPx = with(density) { waveStrokeWidth.toPx() }
val trackStrokeWidthPx = with(density) { trackStrokeWidth.toPx() }
val amplitudePx = with(density) { amplitude.toPx() }
val wavelengthPx = with(density) { wavelength.toPx() }
val gapSizePx = with(density) { gapSize.toPx() }
Canvas(modifier = modifier.size(48.dp)) {
val center = this.center
val step = 1
val maxStroke = maxOf(waveStrokeWidthPx, trackStrokeWidthPx)
val baseRadius = (size.minDimension - maxStroke) / 2f - amplitudePx
// 物理弧长 = 视觉间隙 + 画笔(笔触)宽度
val effectiveGapLength = gapSizePx + maxStroke
// 将弧长转换为角度
val gapAngle = Math.toDegrees((effectiveGapLength / baseRadius).toDouble()).toFloat()
// --- 绘制波浪 ---
wavePath.rewind()
val circumference = 2 * PI * baseRadius
val frequency = circumference / wavelengthPx
val endAngle = 180f
for (i in 0..endAngle.toInt() step step) {
val currentAngle = i.toFloat()
val rad = Math.toRadians(currentAngle.toDouble())
val waveOffset = amplitudePx * sin((rad * frequency))
val r = baseRadius + waveOffset
val x = center.x + (r * cos(rad)).toFloat()
val y = center.y + (r * sin(rad)).toFloat()
if (i == 0) wavePath.moveTo(x, y) else wavePath.lineTo(x, y)
}
drawPath(
path = wavePath,
color = waveColor,
style = Stroke(width = waveStrokeWidthPx, cap = StrokeCap.Round)
)
// --- 绘制轨道 ---
trackPath.rewind()
// 轨道起点 = 波浪终点 + 间隙角度
val trackStartAngle = endAngle + gapAngle
// 轨道终点 = 360度 - 间隙角度
val trackEndAngle = 360f - gapAngle
// 只有当剩余空间足够时才绘制轨道
if (trackStartAngle < trackEndAngle) {
for (i in trackStartAngle.toInt()..trackEndAngle.toInt() step step) {
val currentAngle = i.toFloat()
val rad = Math.toRadians(currentAngle.toDouble())
val x = center.x + (baseRadius * cos(rad)).toFloat()
val y = center.y + (baseRadius * sin(rad)).toFloat()
if (i == trackStartAngle.toInt()) trackPath.moveTo(x, y) else trackPath.lineTo(x, y)
}
drawPath(
path = trackPath,
color = trackColor,
style = Stroke(width = trackStrokeWidthPx, cap = StrokeCap.Round)
)
}
}
}
运行效果:

添加动画
最后,我们来让这个进度条动起来。我们需要三个维度的动画:
-
SweepAngle(呼吸) :控制波浪进度条的长短。我们使用了
keyframes,实现了波浪慢吸快呼的非线性节奏。 -
Rotation(自转):控制整个圆环的旋转。我们自定义了贝塞尔曲线,实现了脉冲式的旋转。
-
PhaseShift(流动):控制波浪的流动。通过改变波的相位偏移来实现的,这样即使圆环整体不转,波浪看起来也像在向前流动。
kotlin
@Composable
fun CircularWavyProgressIndicatorStep4(
modifier: Modifier = Modifier,
waveColor: Color = MaterialTheme.colorScheme.primary,
trackColor: Color = MaterialTheme.colorScheme.secondaryContainer,
waveStrokeWidth: Dp = 4.dp,
trackStrokeWidth: Dp = 4.dp,
amplitude: Dp = 1.2.dp,
wavelength: Dp = 15.dp,
gapSize: Dp = 4.dp,
cycleDuration: Int = 1500, // 整体旋转周期
waveFlowDuration: Int = 3000 // 波浪流动周期
) {
val infiniteTransition = rememberInfiniteTransition(label = "WavyTransition")
// 呼吸动画: 使用 keyframes 实现非对称的伸缩
val sweepAngle by infiniteTransition.animateFloat(
initialValue = 30f,
targetValue = 30f,
animationSpec = infiniteRepeatable(
animation = keyframes {
durationMillis = 5000
// 3秒内缓慢张开
300f at 3000 using CubicBezierEasing(.42f, 0f, 1f, 1f)
// 2秒内快速收缩
30f at 5000 using FastOutSlowInEasing
},
repeatMode = RepeatMode.Restart
),
label = "SweepAngle"
)
// 自转动画: 整体旋转
val rotation by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = 360f,
animationSpec = infiniteRepeatable(
animation = tween(cycleDuration, easing = CubicBezierEasing(0.33f, 1f, 0.68f, 1f)),
),
label = "Rotation"
)
// 相位流动动画: 控制波浪纹理移动
val phaseShift by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = (2 * PI).toFloat(), // 移动一个完整波长,实现无缝循环
animationSpec = infiniteRepeatable(
animation = tween(waveFlowDuration, easing = LinearEasing)
),
label = "PhaseShift"
)
val wavePath = remember { Path() }
val trackPath = remember { Path() }
val density = LocalDensity.current
val waveStrokeWidthPx = with(density) { waveStrokeWidth.toPx() }
val trackStrokeWidthPx = with(density) { trackStrokeWidth.toPx() }
val amplitudePx = with(density) { amplitude.toPx() }
val wavelengthPx = with(density) { wavelength.toPx() }
val gapSizePx = with(density) { gapSize.toPx() }
Canvas(modifier = modifier.size(48.dp)) {
val center = this.center
val step = 1
val maxStroke = maxOf(waveStrokeWidthPx, trackStrokeWidthPx)
val baseRadius = (size.minDimension - maxStroke) / 2f - amplitudePx
val effectiveGapLength = gapSizePx + maxStroke
val gapAngle = Math.toDegrees((effectiveGapLength / baseRadius).toDouble()).toFloat()
// 使用 rotate 旋转整个画布
rotate(rotation) {
// --- 绘制波浪 ---
wavePath.rewind()
val circumference = 2 * PI * baseRadius
val frequency = circumference / wavelengthPx
// 结束角度由动画控制
val endAngle = sweepAngle
for (i in 0..endAngle.toInt() step step) {
val currentAngle = i.toFloat()
val rad = Math.toRadians(currentAngle.toDouble())
// 将 phaseShift 加到正弦函数中,实现波浪流动
val waveOffset = amplitudePx * sin((rad * frequency) + phaseShift)
val r = baseRadius + waveOffset
val x = center.x + (r * cos(rad)).toFloat()
val y = center.y + (r * sin(rad)).toFloat()
if (i == 0) wavePath.moveTo(x, y) else wavePath.lineTo(x, y)
}
drawPath(
path = wavePath,
color = waveColor,
style = Stroke(width = waveStrokeWidthPx, cap = StrokeCap.Round)
)
// --- 绘制轨道 ---
trackPath.rewind()
val trackStartAngle = sweepAngle + gapAngle
val trackEndAngle = 360f - gapAngle
if (trackStartAngle < trackEndAngle) {
for (i in trackStartAngle.toInt()..trackEndAngle.toInt() step step) {
val currentAngle = i.toFloat()
val rad = Math.toRadians(currentAngle.toDouble())
val x = center.x + (baseRadius * cos(rad)).toFloat()
val y = center.y + (baseRadius * sin(rad)).toFloat()
if (i == trackStartAngle.toInt()) trackPath.moveTo(
x,
y
) else trackPath.lineTo(x, y)
}
drawPath(
path = trackPath,
color = trackColor,
style = Stroke(width = trackStrokeWidthPx, cap = StrokeCap.Round)
)
}
}
}
}
以上就是最终的完整代码了,可以直接复制使用,当然你可以重命名为 CircularWavyProgressIndicator。
关于 Transition,可以看我的这篇博客:
关于贝塞尔曲线,可以看我的这篇博客:
运行效果:
