稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

使用Jetpack Compose构建创意动画

本文译自「Animating Inside and Outside the Box with Jetpack Compose」,原文链接https://medium.com/proandroiddev/animating-inside-and-outside-the-box-with-jetpack-compose-a56eba1b6af6,由Nirbhay Pherwani发布于2023年12月13日。

缘起

动画能够让用户界面充满活力、引人入胜。在Android中,Jetpack Compose提供高级工具,让你轻松掌握这项强大功能,打造真正的动态UI。本文将深入探讨 Jetpack Compose 中动画的深层技术。

译注: 虽然原文是以Jetpack Compose为基础来写的,但其实动画这块并不涉及平台特性,也适用于Compose Multiplatform。

我们将涵盖一系列技巧,从创建流畅的、基于物理的动效(增添真实感)到创建复杂的编排序列(为界面增添叙事质感)。无论你是想提升技能,还是仅仅想探索无限可能,本教程都将提供实用的见解,帮助你的应用不仅运行流畅,还能让用户在每次交互中都感到愉悦。

让我们深入探索这些动画如何改变你的 UI设计方法,使其更加直观、响应迅速,并为用户带来愉悦的体验。

第 1 部分 — Jetpack Compose中的自定义动画

游戏角色的移动

利用自定义动画实现动态交互

在本节中,我们将探索如何在 Jetpack Compose中使用高级自定义动画来创建动态且可交互的 UI 元素。我们将重点介绍一个真实示例,该示例演示了用户交互如何以有意义的方式影响动画。

案例 - 交互式游戏角色移动

我们将通过一个示例来说明这一概念,其中游戏角色(由面部图标表示)沿着由用户可拖动控制点确定的路径移动。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
@Composable
fun GameCharacterMovement() {
    val startPosition = Offset(100f, 100f)
    val endPosition = Offset(250f, 400f)
    val controlPoint = remember { mutableStateOf(Offset(200f, 300f)) }
    val position = remember { Animatable(startPosition, Offset.VectorConverter) }

    LaunchedEffect(controlPoint.value) {
        position.animateTo(
            targetValue = endPosition,
            animationSpec = keyframes {
                durationMillis = 5000
                controlPoint.value at 2500 // 可拖动控制点控制的中间点
            }
        )
    }

    val onControlPointChange: (offset: Offset) -> Unit = {
        controlPoint.value = it
    }

    Box(modifier = Modifier.fillMaxSize()) {

        Icon(
            Icons.Filled.Face, contentDescription = "Localized description", modifier = Modifier
                .size(50.dp)
                .offset(x = position.value.x.dp, y = position.value.y.dp)
        )

        DraggableControlPoint(controlPoint.value, onControlPointChange)
    }
}

代码说明

  • GameCharacterMovement 为代表游戏角色的图标设置动画。动画路径由 controlPoint 控制,该控制点通过用户交互设置和更新。
  • Animatable 用于将图标的位置从 startPosition 平滑过渡到 endPosition。
  • LaunchedEffect 监听 controlPoint 值的变化,并在控制点移动时重新触发动画。
  • animationSpec — 这是一种配置项,用于定义动画的持续时间、延迟和缓动。它决定了动画值如何随时间变化。
  • keyframes — 允许你在动画的特定时间点指定值,从而控制动画的中间点。这对于创建复杂的、精心设计的动画特别有用。
  • keyframes 块将动画定义为一系列关键帧。在 2500 毫秒(中间点)时,角色到达控制点,然后继续移动到结束位置。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Composable
fun DraggableControlPoint(controlPoint: Offset, onControlPointChange: (Offset) -> Unit) {
    var localPosition by remember { mutableStateOf(controlPoint) }
    Box(
        modifier = Modifier
            .offset {
                IntOffset(
                    x = localPosition.x.roundToInt() - 15,
                    y = localPosition.y.roundToInt() - 15
                )
            }
            .size(30.dp)
            .background(Color.Red, shape = CircleShape)
            .pointerInput(Unit) {
                detectDragGestures(onDragEnd = {
                    onControlPointChange(localPosition)
                }) { _, dragAmount ->
                    // adjust based on screen bounds
                    val newX = (localPosition.x + dragAmount.x).coerceIn(0f, 600f)
                    val newY = (localPosition.y + dragAmount.y).coerceIn(0f, 600f)
                    localPosition = Offset(newX, newY)
                }
            }
    )
}

代码说明

  • DraggableControlPoint 是一个可组合项,允许用户以交互方式更改控制点的位置。
  • 拖动控制点会更新 localPosition,并在拖动手势完成(onDragEnd)后将其反馈回 GameCharacterMovement。此交互会改变动画图标的路径。

实际用例

  1. 交互式教育应用:在教育应用中,动画可用于提升学习的吸引力。例如,在天文学应用中,拖动行星沿其轨道运行即可查看不同的星座。
  2. 交互式故事叙述和游戏:在数字故事叙述或游戏应用中,允许用户通过可拖动元素来影响故事或游戏环境,可以创造更具沉浸感的体验。

第 2 部分 — 在 Jetpack Compose中编排复杂动画

同步多个元素以实现和谐效果

在本部分中,我们将深入探讨在 Jetpack Compose 中编排(Choreographing)复杂动画的艺术。我们专注于创建同步动画,使多个元素能够无缝交互,从而提升整体用户体验。

A) 连锁反应动画 — 多米诺骨牌效应

多米诺骨牌效应

通过设置一系列动画可以在 UI 中创建多米诺骨牌效应,其中一个动画的完成会触发下一个动画的开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Composable
fun DominoEffect() {
    val animatedValues = List(6) { remember { Animatable(0f) } }

    LaunchedEffect(Unit) {
        animatedValues.forEachIndexed { index, animate ->
            animate.animateTo(
                targetValue = 1f,
                animationSpec = tween(durationMillis = 1000, delayMillis = index * 100)
            )
        }
    }

    Box (modifier = Modifier.fillMaxSize()){
      animatedValues.forEachIndexed { index, value ->
        Box(
            modifier = Modifier
                .size(50.dp)
                .offset(x = ((index+1) * 50).dp, y = ((index+1) * 30).dp)
                .background(getRandomColor(index).copy(alpha = value.value))
        )
      }
    }
}

fun getRandomColor(seed: Int): Color {
    val random = Random(seed = seed).nextInt(256)
    return Color(random, random, random)
}

代码说明

  • animatedValues 是一个Animatable对象的列表,每个值控制一个框的 Alpha(不透明度)。
  • LaunchedEffect 会触发这些值的一系列动画,从而创建一种交错效果,即每个框在前一个框之后淡入,类似于多米诺骨牌倒下。
  • getRandomColor 函数会为每个框生成随机的灰色阴影,为序列中的每个组件添加独特的视觉元素。
  • 这些框沿屏幕对角线放置,增强了多米诺骨牌效应。

B) 交互式滚动时间轴

交互式滚动时间轴

在这个时间轴中,每个元素都会随着用户滚动而淡入并移动到位。我们将使用 LazyColumn来呈现可滚动列表,并使用Animatable来呈现动画。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
@Composable
fun InteractiveTimeline(timelineItems: List<String>) {
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        itemsIndexed(timelineItems) { index, item ->
            val animatableAlpha = remember { Animatable(0f) }
            val isVisible = remember {
                derivedStateOf {
                    scrollState.firstVisibleItemIndex <= index
                }
            }

            LaunchedEffect(isVisible.value) {
                if (isVisible.value) {
                    animatableAlpha.animateTo(
                        1f, animationSpec = tween(durationMillis = 1000)
                    )

                }
            }

            TimelineItem(
                text = item,
                alpha = animatableAlpha.value,
            )
        }
    }
}

@Composable
fun TimelineItem(text: String, alpha: Float) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .background(Color.DarkGray.copy(alpha = alpha))
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            text = text,
            color = Color.White,
            modifier = Modifier.fillMaxWidth(),
            textAlign = TextAlign.Center,
            fontSize = 18.sp,
            fontWeight = FontWeight.SemiBold
        )
    }
}

代码说明

  • animatableAlpha 控制每个时间轴项目的 Alpha(不透明度),初始设置为 0(完全透明)。
  • isVisible 状态源自当前滚动位置,用于确定项目是否可见。
  • 当用户滚动时,LaunchedEffect会触发进入视口的项目的淡入动画。

用例

此交互式时间轴非常适合那些希望以视觉吸引力十足的方式呈现一系列事件或步骤的应用。动画通过在项目进入视野时吸引用户的注意力来增强用户的参与度。

此类动画不仅引人入胜,还可以用来引导用户关注应用中的一系列事件或操作。

第 3 部分 — Jetpack Compose中基于物理的真实动画

弹性拖拽动画

利用物理原理增强UI动态效果

在本节中,我们将探索如何使用 Jetpack Compose将物理原理融入动画,为 UI 增添一层真实感和交互性。我们将重点介绍一个弹性拖拽交互示例。

拖拽时的弹性效果

此示例演示了图标上的弹性拖拽交互。垂直拖动时,图标会拉伸并回弹,产生弹性效果,模拟弹簧或橡皮筋​​的行为。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Composable
fun ElasticDraggableBox() {
    var animatableOffset by remember { mutableStateOf(Animatable(0f)) }

    Box(modifier = Modifier.fillMaxSize().background(Color(0xFFFFA732)), contentAlignment = Alignment.Center) {
        Box(
            modifier = Modifier
                .offset(y = animatableOffset.value.dp)
                .draggable(
                    orientation = Orientation.Vertical,
                    state = rememberDraggableState { delta ->
                        animatableOffset = Animatable(animatableOffset.value + delta)
                    },
                    onDragStopped = {
                        animatableOffset.animateTo(0f, animationSpec = spring())
                    }
                )
                .size(350.dp),
            contentAlignment = Alignment.Center
        ) {
            Icon(
                Icons.Filled.Favorite,
                contentDescription = "heart",
                modifier = Modifier.size(animatableOffset.value.dp + 150.dp),
                tint = Color.Red
            )
        }
    }
}

说明

  • 使用 draggable 修饰符将包含图标的 Box 可组合项设置为可拖动。
  • animatableOffset 跟踪图标因拖动而产生的垂直偏移。
  • 在拖动过程中,图标的大小会根据拖动量而变化,从而产生拉伸效果。
  • 当拖动停止(onDragStopped)时,animatableOffset 会使用弹簧动画返回到 0f,从而使图标弹回其原始大小和位置。

第 4 节 — Jetpack Compose 中的手势动画

通过响应式手势提升用户体验

在本部分中,我们将探索如何使用 Jetpack Compose 创建由用户手势控制的动画。我们将重点介绍两个示例——一个支持多点触控的可变形图像和一个由手势控制的音频波形。

A) 多点触控可变形图像

在本示例中,我们将创建一个图像视图,用户可以使用捏合、缩放和旋转等多点触控(Multi-touch)手势进行交互。

多点触控可变形图像

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
@Composable
fun TransformableImage(imageId: Int = R.drawable.android) {
    var scale by remember { mutableStateOf(1f) }
    var rotation by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(modifier = Modifier.fillMaxSize().background(Color.DarkGray), contentAlignment = Alignment.Center) {
        Image(
            painter = painterResource(id = imageId),
            contentDescription = "Transformable image",
            contentScale = ContentScale.Crop,
            modifier = Modifier
                .size(300.dp)
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    rotationZ = rotation,
                    translationX = offset.x,
                    translationY = offset.y
                )
                .pointerInput(Unit) {
                    detectTransformGestures { _, pan, zoom, rotate ->
                        scale *= zoom
                        rotation += rotate
                        offset += pan
                    }
                }
        )
    }
}

代码说明

  • Image 可组合项通过 graphicsLayer 进行修改,以应用缩放、旋转和平移等变换。
  • pointerInput 带有 detectTransformGestures 接口,用于处理多点触控手势,并相应地更新缩放、旋转和偏移。

B) 手势控制波形

这是一个波形可视化效果,它根据用户手势(例如滑动和捏合)改变外观,以控制幅度和频率等方面。

手势控制波形

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Composable
fun GestureControlledWaveform() {
    var amplitude by remember { mutableStateOf(100f) }
    var frequency by remember { mutableStateOf(1f) }

    Canvas(modifier = Modifier
        .fillMaxSize()
        .pointerInput(Unit) {
            detectDragGestures { _, dragAmount ->
                amplitude += dragAmount.y
                frequency += dragAmount.x / 500f
                // 根据拖拽调整频率
            }
        }
        .background(
            Brush.verticalGradient(
                colors = listOf(Color(0xFF003366), Color.White, Color(0xFF66B2FF))
            )
        )) {
        val width = size.width
        val height = size.height
        val path = Path()

        val halfHeight = height / 2
        val waveLength = width / frequency

        path.moveTo(0f, halfHeight)

        for (x in 0 until width.toInt()) {
            val theta = (2.0 * Math.PI * x / waveLength).toFloat()
            val y = halfHeight + amplitude * sin(theta.toDouble()).toFloat()
            path.lineTo(x.toFloat(), y)
        }

        val gradient = Brush.horizontalGradient(
            colors = listOf(Color.Blue, Color.Cyan, Color.Magenta)
        )

        drawPath(
            path = path,
            brush = gradient
        )
    }
}

代码说明

  • amplitude 和 frequency 是状态变量,分别控制波形的幅度和频率。
  • Canvas 可组合项用于绘制波形。Canvas 内部的绘制逻辑根据正弦函数计算每个 X 位置的 Y 位置,从而创建波浪效果。
  • detectDragGestures 修饰符用于根据用户拖动手势更新幅度和频率。水平拖动调整频率,垂直拖动调整幅度。
  • 当用户在屏幕上拖动时,波形的形状会相应变化,从而营造出互动体验。

注意事项

  • 这是一个基本的实现。为了获得更逼真的音频波形,你需要集成实际的音频数据。
  • 可以通过调整拖动过程中幅度和频率的修改方式来微调波形对手势的响应能力。

此示例演示了如何在 Compose 中创建基本的交互式波形,并且可以对其进行扩展或修改,以用于更复杂的用例或处理更复杂的手势。

第 5 节 — Jetpack Compose 中的状态驱动动画模式

带动画线图

基于数据和状态变化的UI动画

本部分重点介绍如何创建由数据或UI 状态变化驱动的动画,从而增强应用的交互性和响应能力。我们将探讨两个具体示例——数据图动画和在多状态 UI 中实现状态转换。

A) 数据驱动的图形动画

本示例演示了一个动画线图,其中图形的路径(Path)会随着数据集的变化而变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@Composable
fun AnimatedGraphExample() {
    var dataPoints by remember { mutableStateOf(generateRandomDataPoints(5)) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.DarkGray)
    ) {
        AnimatedLineGraph(dataPoints = dataPoints)

        Spacer(modifier = Modifier.height(16.dp))

        Button(
            onClick = {
                dataPoints = generateRandomDataPoints(5)
            },
            modifier = Modifier.align(Alignment.CenterHorizontally),
            colors = ButtonDefaults.buttonColors(containerColor = Color.Green)
        ) {
            Text(
                "Update Data",
                fontWeight = FontWeight.Bold,
                color = Color.DarkGray,
                fontSize = 18.sp
            )
        }
    }
}

@Composable
fun AnimatedLineGraph(dataPoints: List<Float>) {
    val animatableDataPoints = remember { dataPoints.map { Animatable(it) } }
    val path = remember { Path() }

    LaunchedEffect(dataPoints) {
        animatableDataPoints.forEachIndexed { index, animatable ->
            animatable.animateTo(dataPoints[index], animationSpec = TweenSpec(durationMillis = 500))
        }
    }

    Canvas(
        modifier = Modifier
            .fillMaxWidth()
            .height(400.dp)
    ) {
        path.reset()
        animatableDataPoints.forEachIndexed { index, animatable ->
            val x = (size.width / (dataPoints.size - 1)) * index
            val y = size.height - (animatable.value * size.height)
            if (index == 0) path.moveTo(x, y) else path.lineTo(x, y)
        }
        drawPath(path, Color.Green, style = Stroke(5f))
    }
}

fun generateRandomDataPoints(size: Int): List<Float> {
    return List(size) { Random.nextFloat() }
}

代码说明

  • AnimatedGraphExample 可组合项创建了一个可以更新折线图数据点的环境。
  • 该图表绘制在 Canvas 中,其中 drawPath 方法使用来自 animatableDataPoints 的动画值。
  • 对于图表中的每个数据点,我们需要计算其在画布上对应的 x(水平)和 y(垂直)位置。
  • x 计算 - x 位置是根据数据点的索引和画布的总宽度计算得出的。我们将数据点沿画布的宽度均匀分布。
1
val x = (size.width / (dataPoints.size - 1)) * index
  • y 计算——y 位置是根据数据点(animatable.value)的值和画布的高度计算的。
1
val y = size.height - (animatable.value * size.height)
  • 路径从第一个数据点开始,然后使用 lineTo 绘制一条线到每个后续点,从而创建图形线。
  • 路径基于数据点的动画值绘制,从而在数据发生变化时创建动画效果。

B) 多状态 UI 中的状态转换

可以使用 Animatable 在多状态 UI 中实现状态转换,从而在不同 UI 状态之间进行动画处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
enum class UIState { StateA, StateB, StateC }

@Composable
fun StateTransitionUI() {
    var currentState by remember { mutableStateOf(UIState.StateA) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(getBackgroundColorForState(currentState)),
        contentAlignment = Alignment.Center
    ) {
        AnimatedContent(currentState = currentState)

        Button(
            onClick = { currentState = getNextState(currentState) },
            modifier = Modifier.align(Alignment.BottomCenter)
        ) {
            Text("Next State")
        }
    }
}

@Composable
fun AnimatedContent(currentState: UIState) {
    AnimatedVisibility(
        visible = currentState == UIState.StateA,
        enter = fadeIn(animationSpec = tween(durationMillis = 2000)) + expandVertically(),
        exit = fadeOut(animationSpec = tween(durationMillis = 2000)) + shrinkVertically()
    ) {
        Text("This is ${currentState.name}", fontSize = 32.sp)
    }

    // 与B 和 C 的类似的代码块
}

fun getBackgroundColorForState(state: UIState): Color {
    return when (state) {
        UIState.StateA -> Color.Red
        UIState.StateB -> Color.Green
        UIState.StateC -> Color.Blue
    }
}

fun getNextState(currentState: UIState): UIState {
    return when (currentState) {
        UIState.StateA -> UIState.StateB
        UIState.StateB -> UIState.StateC
        UIState.StateC -> UIState.StateA
    }
}

代码说明

  • 在此示例中,AnimatedVisibility 用于为每个状态下内容的出现和消失添加动画效果。这会在状态变化时添加平滑的过渡效果。
  • 对于每个状态(StateA、StateB、StateC),都有一个 AnimatedVisibility 块,用于通过淡入淡出和展开/收缩动画控制其内容的可见性。
  • AnimatedVisibility 的进入和退出参数分别定义了内容可见或隐藏时的动画。

第 6 节 — 在 Compose中改变(Morphing)形状

形状变形

动画形状之间的变换涉及这些形状的属性的插值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@Composable
fun ShapeMorphingAnimation() {
    val animationProgress = remember { Animatable(0f) }

    LaunchedEffect(Unit) {
        animationProgress.animateTo(
            targetValue = 1f,
            animationSpec = infiniteRepeatable(
                animation = tween(2000, easing = LinearOutSlowInEasing),
                repeatMode = RepeatMode.Reverse
            )
        )
    }

    Canvas(modifier = Modifier.padding(40.dp).fillMaxSize()) {
        val sizeValue = size.width.coerceAtMost(size.height) / 2
        val squareRect = Rect(center = center, sizeValue)

        val morphedPath = interpolateShapes(progress = animationProgress.value, squareRect = squareRect)
        drawPath(morphedPath, color = Color.Blue, style = Fill)
    }
}

fun interpolateShapes(progress: Float, squareRect: Rect): Path {
    val path = Path()

    val cornerRadius = CornerRadius(
        x = lerp(start = squareRect.width / 2, stop = 0f, fraction = progress),
        y = lerp(start = squareRect.height / 2, stop = 0f, fraction = progress)
    )

    path.addRoundRect(
        roundRect = RoundRect(rect = squareRect, cornerRadius = cornerRadius)
    )

    return path
}

fun lerp(start: Float, stop: Float, fraction: Float): Float {
    return (1 - fraction) * start + fraction * stop
}

代码说明

  • ShapeMorphingAnimation 设置了一个无限动画,将 animationProgress 的值在 0 和 1 之间切换。
  • Canvas 可组合项用于绘制形状。在这里,我们根据画布大小定义正方形 (squareRect) 的尺寸。
  • interpolateShapes 接收当前动画进度和正方形的矩形,在圆形和正方形之间进行插值。它使用 lerp(线性插值)逐步调整圆角矩形的 cornerRadius,该矩形代表我们的变形形状。
  • 当 progress 为 0 时,cornerRadius 是矩形大小的一半,使形状变为圆形。当 progress 为 1 时,cornerRadius 为 0,使形状变为正方形。

实际用例

  • 加载和进度指示器——变形形状可用于创建更具吸引力的加载或进度指示器,以视觉上引人入胜的方式指示进度或加载状态。
  • UI 中的图标过渡——变形图标可用于根据用户操作提供视觉反馈。例如,点击播放按钮时会变形为暂停按钮,汉堡菜单图标会变形为后退箭头。
  • 数据可视化——在复杂的数据可视化中,变形可以帮助在不同视图或数据状态之间过渡,使用户更容易跟踪和理解随时间或类别变化的变化。

想看雪花特效吗?

我们将演示一个简单的粒子系统(Particle system)来创建雪花效果。

雪花特效

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
data class Snowflake(
    var x: Float,
    var y: Float,
    var radius: Float,
    var speed: Float
)

@Composable
fun SnowfallEffect() {
    val snowflakes = remember { List(100) { generateRandomSnowflake() } }
    val infiniteTransition = rememberInfiniteTransition(label = "")

    val offsetY by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 1000f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 5000, easing = LinearEasing),
            repeatMode = RepeatMode.Restart
        ), label = ""
    )

    Canvas(modifier = Modifier.fillMaxSize().background(Color.Black)) {
        snowflakes.forEach { snowflake ->
            drawSnowflake(snowflake, offsetY % size.height)
        }
    }
}

fun generateRandomSnowflake(): Snowflake {
    return Snowflake(
        x = Random.nextFloat(),
        y = Random.nextFloat() * 1000f,
        radius = Random.nextFloat() * 2f + 2f, // Snowflake size
        speed = Random.nextFloat() * 1.2f + 1f  // Falling speed
    )
}

fun DrawScope.drawSnowflake(snowflake: Snowflake, offsetY: Float) {
    val newY = (snowflake.y + offsetY * snowflake.speed) % size.height
    drawCircle(Color.White, radius = snowflake.radius, center = Offset(snowflake.x * size.width, newY))
}

代码说明

  • SnowfallEffect 设置了一个包含多个雪花(Snowflake 对象)的粒子系统。
  • 每个雪花都具有位置 (x, y)、半径(大小)和速度等属性。
  • rememberInfiniteTransition 和 animateFloat 用于创建连续的垂直运动效果,模拟降雪。
  • Canvas 可组合函数用于绘制每片雪花。drawSnowflake 函数根据每片雪花的速度和动画的 offsetY 计算其新的位置。
  • 雪花从底部落下后会重新出现在顶部,从而产生循环降雪效果。

总结

随着我们对 Jetpack Compose 动画的探索逐渐深入,我们清楚地认识到,动画不仅仅是视觉上的点缀。它们是打造引人入胜、直观且赏心悦目的用户体验的关键工具。

拥抱互动性

从动态游戏角色运动到交互式时间轴,我们见证了动画如何让用户交互更具吸引力和信息量。

打造逼真的体验

雪花飘落效果和变形形状展现了该工具包将真实感和流畅性带入数字世界的能力。这些动画有助于打造与用户产生共鸣的沉浸式体验。

简化复杂性

无论是编排多个元素还是制作状态转换动画,其简单易用性都令人瞩目。

结束语

如果你喜欢本文,请随时留下宝贵的反馈或赞赏。我一直期待与其他开发者一起学习、合作、共同成长。

如有任何疑问,请随时给我留言!

在 Medium 上关注我,获取更多文章 — Medium 个人资料(链接:https://medium.com/@pherwani37%EF%BC%89

LinkedIn(链接:https://linkedin.com/in/nirbhaypherwani%EF%BC%89%E5%92%8CTwitter(链接:https://twitter.com/nirbhayph%EF%BC%89%E4%B8%8A%E4%B8%8E%E6%88%91%E8%81%94%E7%B3%BB%EF%BC%8C%E4%BB%A5%E4%BE%BF%E6%88%91%E4%BB%AC%E8%BF%9B%E8%A1%8C%E5%90%88%E4%BD%9C%E3%80%82

祝你动画制作愉快!

Comments