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

作者:雨白日期:2025/11/24

效果

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

注意:

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

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

运行效果:


Jetpack Compose 实战:复刻 Material 3 圆形波浪进度条》 是转载文章,点击查看原文


相关推荐


鸿蒙ets实现强制蜂窝网络
波儿菜2025/11/23

在用户同时开启 wifi/cellular 时,期望强行使用 cellular 访问网络,这是个比较常规的诉求。 鸿蒙提供了大量的 ets 网络基础库,本来以为能简单实现这个能力,却有些令人不解的坑。 作为基建来说,规避 ets 而直接使用 C++ 可能才是最优解。 获取蜂窝网络 IP 第一个坑就是connection.getAllNets()接口在 wifi/cellular 同时开启时并不会返回 cellular 的NetHandle,仅开启 cellular 才能拿到其NetHandle。


计算机网络重要知识点
Eric_Makabaka2025/11/21

内容来源于飞天闪客,讲的非常通俗易懂。 网络是怎么传输的? https://www.bilibili.com/video/BV1tseyzCEoF?spm_id_from=333.788.videopod.sections&vd_source=c815609efed4d3ffbba6f23ca86d94ae 集线器(物理层) 两台电脑之间想要通信,可以直接通过一条网线进行连接。但是随着网线的增加,每台电脑的线太过混乱,不好管理。 所以就有了集线器这样的设备,把各自的网线都插到这


Docker 安装配置
ITVV2025/11/19

卸载旧版本(如有) sudo apt remove docker docker-engine docker.io containerd runc 安装依赖包 sudo apt update sudo apt install -y \ ca-certificates \ curl \ gnupg \ lsb-release 添加 Docker 官方 GPG 密钥 sudo mkdir -p /etc/apt/keyrings curl -fsSL htt


Lua 的标准库
hubenchang05152025/11/18

#Lua 的标准库 #变量 _G - Lua 的全局环境,包含所有全局变量(包括 _G 自身);Lua 本身并不使用这个变量,改变它的值不会有任何作用 _VERSION - Lua 的版本,例如 "Lua 5.4" #基础函数 assert - 断言 collectgarbage - 垃圾收集 dofile - 执行文件中的 Lua 代码 error - 主动抛出错误,终止当前代码的执行 getmetatable - 获取元表 ipairs - 遍历数组 load - 加载一个代码块 l


SeqXGPT:Sentence-Level AI-Generated Text Detection —— 把大模型的“波形”变成测谎仪
only-code2025/11/17

1. 论文基本信息 标题:  SeqXGPT: Sentence-Level AI-Generated Text Detection 作者:  Pengyu Wang, Linyang Li, Ke Ren, Botian Jiang, Dong Zhang, Xipeng Qiu 年份:  2023 机构:  Fudan University, Shanghai Key Laboratory of Intelligent Information Processing 领域关键


Python 的内置函数 open
IMPYLH2025/11/16

Python 内建函数列表 > Python 的内置函数 open Python 的内置函数 open() 是用于打开文件的重要函数,它提供了与文件系统交互的基本接口。该函数返回一个文件对象(file object),可用于读取、写入或追加文件内容。 函数签名 open(file, mode='r', buffering=-1, encoding=None, errors=None, newline=None, closefd=True, opener=None) 主要参数说明 f


word表格操作疑难杂症处理
weixin_438338512025/11/14

1. 从excel 复制一个表格粘贴到word中,表格超出word的宽度 第一种方式:点表格左上角的按钮全选表格, 表格工具---自动调整---根据窗口调整表格 或者 根据内容调整表格。 第二种方式:设置 页边距 和 纸张大小,默认A4纸,可能大小就是不够 第三种方式: 粘贴表格对象 复制表格后,在word中不要直接粘贴,而是在word中空白处 右键 ,选择最后一个图标“选择性粘贴”,选择 wps表格对象---确定, 这样就是一模一样的粘贴过来了,但是word中无法直接编辑该


HarmonyOS Web组件深度解析:构建高性能JavaScript交互的实践与创新
万少-2025/11/13

HarmonyOS Web组件深度解析:构建高性能JavaScript交互的实践与创新 引言 在万物互联的时代,HarmonyOS作为分布式操作系统,其应用生态的构建离不开Web技术的深度融合。Web网页组件(Web组件)作为连接Web生态与原生应用的关键桥梁,其JavaScript交互能力直接决定了应用的体验边界。传统的WebView交互往往局限于简单的URL加载和基础脚本执行,但在HarmonyOS的分布式架构下,我们需要重新思考JavaScript交互的深度与广度。 本文将深入探讨Harm


开源 Objective-C IOS 应用开发(二)Xcode安装
ajassi20002025/11/12

文章的目的为了记录使用Objective-C 进行IOS app 开发学习的经历。本职为嵌入式软件开发,公司安排开发app,临时学习,完成app的开发。开发流程和要点有些记忆模糊,赶紧记录,防止忘记。  推荐链接: 开源 Arkts 鸿蒙应用 开发(一)工程文件分析-CSDN博客 开源 Arkts 鸿蒙应用 开发(二)封装库.har制作和应用-CSDN博客 开源 Arkts 鸿蒙应用 开发(三)Arkts的介绍-CSDN博客 开源 Arkts 鸿蒙应用 开发(四)布局和常用控件-C


三角洲行动-java游戏程序
程序编程- Java2025/11/10

以下是一个简化版的三角洲行动游戏程序,使用Java编写。 import java.awt.*; import java.awt.event.*; import java.util.ArrayList; import java.util.Random; import javax.swing.*; public class DeltaForceAction extends JPanel implements ActionListener, KeyListener {     private st

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2025 聚合阅读