Jetpack Compose 实战:复刻 Material 3 圆形波浪进度条

效果

要实现的效果是 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)
        )
    }
}

注意:

  1. 为了避免在重组时,频繁创建 Path 对象导致内存抖动。我们使用了 remember{ Path() } 来复用路径对象。不过要在每次绘制前,调用 rewind() 清空已有路径。
  2. 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,可以看我的这篇博客:

关于贝塞尔曲线,可以看我的这篇博客:

运行效果:

相关推荐
介一安全12 小时前
【Frida Android】实战篇15:Frida检测与绕过——基于/proc/self/maps的攻防实战
android·网络安全·逆向·安全性测试·frida
hhy_smile13 小时前
Android 与 java 设计笔记
android·java·笔记
laocooon52385788613 小时前
C#二次开发中简单块的定义与应用
android·数据库·c#
似霰13 小时前
传统 Hal 开发笔记5 —— 添加硬件访问服务
android·framework·hal
恋猫de小郭14 小时前
Android 宣布 Runtime 编译速度史诗级提升:在编译时间上优化了 18%
android·前端·flutter
csj5014 小时前
安卓基础之《(4)—Activity组件》
android
游戏开发爱好者814 小时前
H5 混合应用加密 Web 资源暴露到 IPA 层防护的完整技术方案
android·前端·ios·小程序·uni-app·iphone·webview
2501_9151063215 小时前
最新版本iOS系统设备管理功能全面指南
android·macos·ios·小程序·uni-app·cocoa·iphone
走在路上的菜鸟15 小时前
Android学Dart学习笔记第十四节 库和导库
android·笔记·学习·flutter
姜西西_15 小时前
自动化测试框架pytest之fixture
android·java·pytest