Jetpack Compose 入门系列(四):动画基本使用

Jetpack Compose 入门系列(四):动画基本使用

学完上篇你已经能用 MVVM + 协程搞定网络请求列表了,但界面还是"硬邦邦"的------状态切换毫无过渡,列表展开瞬闪出现。本篇就解决一个问题:让你的 Compose 界面动起来


一、Compose 动画体系一览

在 XML 时代,写动画大致是这样:

kotlin 复制代码
// XML 动画:定义动画文件 → 加载 → 设置给 View → 手动启动
val anim = AnimationUtils.loadAnimation(context, R.anim.fade_in)
view.startAnimation(anim)

// 属性动画:创建 Animator → 设置目标 → 启动
ObjectAnimator.ofFloat(view, "alpha", 0f, 1f).apply {
    duration = 300
    start()
}

这套流程的痛点很明显:你要自己管动画的启动、取消、生命周期,还得和 View 的生命周期对齐,稍不留神就内存泄漏。

Compose 的做法完全不同------动画跟着状态走。你改变状态,框架自动算出动画;你不用手动启动,也不用管取消。

flowchart LR subgraph XML_动画 A[定义动画文件] --> B[代码加载动画] B --> C[手动 startAnimation] C --> D[手动取消/回收] end subgraph Compose_动画 E[状态变化] --> F[框架自动计算动画] F --> G[UI 自动更新] end XML_动画 -.->|vs| Compose_动画

Compose 动画 API 按复杂度递增,可以分成这几层:

层级 API 适用场景
简单属性动画 animate*AsState 单个属性值变化(颜色、透明度、圆角等)
可见性动画 AnimatedVisibility 组件的显示/隐藏
内容切换动画 AnimatedContentCrossfade 内容整体替换时的过渡
无限循环动画 rememberInfiniteTransition 加载指示器、呼吸灯等持续动画
多属性联动 updateTransition 多个属性跟随同一个状态变化
动画规格 tweenspringkeyframes 自定义动画曲线

本文按这个顺序从简单到复杂逐个讲解,每个 API 都有完整可运行代码。


二、最简单的动画:animate*AsState

2.1 它是什么

animate*AsState 是 Compose 中最简单的动画 API------你给它一个目标值,它自动从当前值动画过渡到目标值。

kotlin 复制代码
// 状态变化时,alpha 从当前值自动过渡到 1f
val alpha by animateFloatAsState(
    targetValue = if (isVisible) 1f else 0f
)

你不用管动画怎么启动、什么时候结束------改状态,动画自动来

2.2 可用的类型转换函数

函数 返回类型 典型场景
animateFloatAsState Float 透明度、旋转角度、进度
animateColorAsState Color 背景色切换、状态颜色
animateDpAsState Dp 尺寸变化、圆角变化
animateIntAsState Int 整数变化
animateSizeAsState Size 尺寸变化
animateValueAsState 任意 自定义类型(需提供 TwoWayConverter

2.3 完整示例:按钮点击变色 + 圆角变化

kotlin 复制代码
@Composable
fun AnimateAsStateDemo() {
    var isExpanded by remember { mutableStateOf(false) }

    // 跟随状态变化的动画值
    val cornerRadius by animateDpAsState(
        targetValue = if (isExpanded) 24.dp else 8.dp,
        animationSpec = tween(durationMillis = 300)
    )
    val backgroundColor by animateColorAsState(
        targetValue = if (isExpanded) Color(0xFF6200EE) else Color(0xFF03DAC5),
        animationSpec = tween(durationMillis = 300)
    )

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Box(
            modifier = Modifier
                .size(200.dp)
                .background(backgroundColor, RoundedCornerShape(cornerRadius))
                .clickable { isExpanded = !isExpanded },
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = if (isExpanded) "已展开" else "点击展开",
                color = Color.White,
                style = MaterialTheme.typography.titleLarge
            )
        }
    }
}

这段代码里发生了什么:

  1. isExpanded 是控制状态------点击时在 true/false 之间切换
  2. cornerRadius 跟随 isExpanded 变化,从 8.dp 平滑过渡到 24.dp
  3. backgroundColor 同理,从青色平滑过渡到紫色
  4. 你只管切换 isExpanded,动画完全自动

animate*AsState 的核心思想:状态驱动动画,你只管改状态,动画框架管过渡 。这和 XML 里手动 startAnimation() 完全是两个世界。


三、可见性动画:AnimatedVisibility

3.1 它是什么

AnimatedVisibility 专门处理组件出现/消失 的动画。在 XML 里你得写 fade_in.xml + fade_out.xml,再手动调用 startAnimation。Compose 一行搞定:

kotlin 复制代码
AnimatedVisibility(visible = showContent) {
    Text("我会淡入淡出地出现和消失")
}

3.2 进入和退出动画

AnimatedVisibility 支持 enterexit 两个参数,分别控制出现和消失的效果:

动画效果 进入(Enter) 退出(Exit)
淡入淡出 fadeIn() fadeOut()
滑入滑出(从下) slideInVertically() slideOutVertically()
滑入滑出(从左) slideInHorizontally() slideOutHorizontally()
缩放 scaleIn() scaleOut()
展开(垂直) expandVertically() shrinkVertically()
展开(水平) expandHorizontally() shrinkHorizontally()

这些效果可以用 + 组合使用:

kotlin 复制代码
// 同时淡入 + 从下方滑入
enter = fadeIn() + slideInVertically()

3.3 完整示例:卡片展开详情

kotlin 复制代码
@Composable
fun AnimatedVisibilityDemo() {
    var showDetail by remember { mutableStateOf(false) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        Card(
            modifier = Modifier.fillMaxWidth(),
            onClick = { showDetail = !showDetail }
        ) {
            Column(modifier = Modifier.padding(16.dp)) {
                Text(
                    text = "Jetpack Compose 动画",
                    style = MaterialTheme.typography.titleMedium
                )
                Spacer(modifier = Modifier.height(4.dp))
                Text(
                    text = if (showDetail) "点击收起" else "点击查看详情",
                    color = MaterialTheme.colorScheme.primary,
                    style = MaterialTheme.typography.bodySmall
                )
            }
        }

        // 详情区域:带动画地出现和消失
        AnimatedVisibility(
            visible = showDetail,
            enter = fadeIn() + expandVertically(),
            exit = fadeOut() + shrinkVertically()
        ) {
            Card(
                modifier = Modifier.fillMaxWidth(),
                colors = CardDefaults.cardColors(
                    containerColor = MaterialTheme.colorScheme.primaryContainer
                )
            ) {
                Column(modifier = Modifier.padding(16.dp)) {
                    Text(
                        text = "Compose 动画体系基于状态驱动,",
                        style = MaterialTheme.typography.bodyMedium
                    )
                    Text(
                        text = "你只需要改变状态,框架自动处理动画过渡。",
                        style = MaterialTheme.typography.bodyMedium
                    )
                }
            }
        }
    }
}

这段代码里发生了什么:

  1. 点击卡片切换 showDetail 状态
  2. AnimatedVisibility 监听 showDetail,自动触发进入/退出动画
  3. 进入时:同时淡入 + 垂直展开,看起来像从卡片下方"长出来"
  4. 退出时:同时淡出 + 垂直收缩,收回去

fadeIn() + expandVertically() 这种写法是 Compose 动画的特色------用 + 组合多个动画效果,比 XML 里定义两套动画文件简洁得多。


四、内容切换动画:AnimatedContent 与 Crossfade

4.1 Crossfade:最简单的内容切换

Crossfade 做一件事:内容切换时,旧内容淡出,新内容淡入,两层交叉过渡

kotlin 复制代码
var currentPage by remember { mutableStateOf(0) }

Crossfade(targetState = currentPage) { page ->
    when (page) {
        0 -> PageOne()
        1 -> PageTwo()
        2 -> PageThree()
    }
}

注意 Crossfade 的 lambda 参数 page------它是当前动画到的值,不是目标值。这保证了动画过程中显示的内容和过渡进度一致。

4.2 AnimatedContent:可自定义的内容切换

AnimatedContentCrossfade 的进阶版------你可以自定义进入和退出的过渡方式,不只是淡入淡出。

kotlin 复制代码
var count by remember { mutableStateOf(0) }

AnimatedContent(
    targetState = count,
    transitionSpec = {
        // 数字增大时从下方滑入,减小时从上方滑入
        if (targetState > initialState) {
            slideInVertically { height -> height } + fadeIn() togetherWith
                slideOutVertically { height -> -height } + fadeOut()
        } else {
            slideInVertically { height -> -height } + fadeIn() togetherWith
                slideOutVertically { height -> height } + fadeOut()
        }
    }
) { targetCount ->
    Text(
        text = "$targetCount",
        style = MaterialTheme.typography.displayLarge
    )
}

togetherWith 表示两个动画同时播放(进入和退出并行)。与之对应的还有 before(进入先播完再退出)和 after(退出先播完再进入),但 90% 的场景用 togetherWith 就够了。

4.3 完整示例:数字计数器

kotlin 复制代码
@Composable
fun AnimatedContentDemo() {
    var count by remember { mutableStateOf(0) }

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        AnimatedContent(
            targetState = count,
            transitionSpec = {
                if (targetState > initialState) {
                    slideInVertically { height -> height } + fadeIn() togetherWith
                        slideOutVertically { height -> -height } + fadeOut()
                } else {
                    slideInVertically { height -> -height } + fadeIn() togetherWith
                        slideOutVertically { height -> height } + fadeOut()
                }.using(SizeTransform(clip = false))
            },
            label = "counter"
        ) { targetCount ->
            Text(
                text = "$targetCount",
                style = MaterialTheme.typography.displayLarge,
                fontWeight = FontWeight.Bold,
                color = MaterialTheme.colorScheme.primary
            )
        }

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

        Row(
            horizontalArrangement = Arrangement.spacedBy(16.dp)
        ) {
            FilledTonalButton(onClick = { count-- }) {
                Text("- 1")
            }
            FilledTonalButton(onClick = { count++ }) {
                Text("+ 1")
            }
        }
    }
}

这段代码里发生了什么:

  1. 点击 +1 时,count 增大------新数字从下方滑入,旧数字向上滑出
  2. 点击 -1 时,count 减小------新数字从上方滑入,旧数字向下滑出
  3. SizeTransform(clip = false) 表示内容尺寸变化时不裁剪,让过渡更自然
  4. label 参数用于 Android Studio 动画预览工具的标识,方便调试

initialState 是动画开始前的状态值,targetState 是目标状态值。通过对比两者,你可以让动画方向跟着数据变化方向走。


五、无限循环动画:rememberInfiniteTransition

5.1 它是什么

前面几个动画都是"触发一次,播一次"。但有些场景需要持续不断的动画------加载指示器、呼吸灯、脉冲效果。

rememberInfiniteTransition 就是干这个的:

kotlin 复制代码
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
val pulse by infiniteTransition.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        animation = tween(durationMillis = 1000),
        repeatMode = RepeatMode.Reverse
    ),
    label = "pulseAlpha"
)

RepeatMode.Reverse 表示动画到终点后反向播放,形成来回循环。RepeatMode.Restart 则是到终点后跳回起点重新开始。

5.2 完整示例:呼吸灯 + 加载脉冲

kotlin 复制代码
@Composable
fun InfiniteTransitionDemo() {
    val infiniteTransition = rememberInfiniteTransition(label = "loading")

    // 呼吸灯:透明度 0.3 ↔ 1.0 循环
    val alpha by infiniteTransition.animateFloat(
        initialValue = 0.3f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 800, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "breathAlpha"
    )

    // 脉冲缩放:0.8f ↔ 1.2f 循环
    val scale by infiniteTransition.animateFloat(
        initialValue = 0.8f,
        targetValue = 1.2f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 600, easing = FastOutSlowInEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "pulseScale"
    )

    Column(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        // 呼吸灯效果
        Box(
            modifier = Modifier
                .size(80.dp)
                .graphicsLayer {
                    this.alpha = alpha
                    this.scaleX = scale
                    this.scaleY = scale
                }
                .background(
                    MaterialTheme.colorScheme.primary,
                    CircleShape
                ),
            contentAlignment = Alignment.Center
        ) {
            Icon(
                imageVector = Icons.Default.Favorite,
                contentDescription = null,
                tint = Color.White,
                modifier = Modifier.size(40.dp)
            )
        }

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

        Text(
            text = "加载中...",
            style = MaterialTheme.typography.bodyLarge,
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
    }
}

这段代码里发生了什么:

  1. rememberInfiniteTransition 创建一个无限动画控制器
  2. 两个 animateFloat 分别控制透明度和缩放,各自独立循环
  3. graphicsLayer 修改的是绘制层参数,比 Modifier.alpha() / Modifier.scale() 性能更好(不会触发重组,只在绘制阶段生效)

graphicsLayer vs Modifier.alpha()graphicsLayer 在绘制阶段应用变换,不触发重组;Modifier.alpha() 会触发重组。做动画时优先用 graphicsLayer,性能更好。


六、多属性联动:updateTransition

6.1 它是什么

当一个状态变化需要同时驱动多个属性 时,updateTransitionanimate*AsState 更合适------它把多个属性绑在同一个状态上,保证它们同步变化

flowchart LR S[状态变化<br/>Collapsed ↔ Expanded] --> T[Transition] T --> A[属性1: 高度] T --> B[属性2: 颜色] T --> C[属性3: 圆角]

如果用 animate*AsState 分别写三个,它们各自独立动画,可能出现不同步的情况。updateTransition 保证它们从同一次状态变化出发,完全同步。

6.2 完整示例:卡片展开/收起

kotlin 复制代码
// 用枚举定义状态,比 Boolean 更清晰
private enum class CardState { Collapsed, Expanded }

@Composable
fun TransitionDemo() {
    var cardState by remember { mutableStateOf(CardState.Collapsed) }

    val transition = updateTransition(
        targetState = cardState,
        label = "cardTransition"
    )

    // 所有属性绑定到同一个 transition
    val padding by transition.animateDp(
        transitionSpec = { tween(durationMillis = 300) },
        label = "padding"
    ) { state ->
        when (state) {
            CardState.Collapsed -> 16.dp
            CardState.Expanded -> 32.dp
        }
    }

    val cornerRadius by transition.animateDp(
        transitionSpec = { tween(durationMillis = 300) },
        label = "cornerRadius"
    ) { state ->
        when (state) {
            CardState.Collapsed -> 8.dp
            CardState.Expanded -> 24.dp
        }
    }

    val backgroundColor by transition.animateColor(
        transitionSpec = { tween(durationMillis = 300) },
        label = "bgColor"
    ) { state ->
        when (state) {
            CardState.Collapsed -> MaterialTheme.colorScheme.surfaceVariant
            CardState.Expanded -> MaterialTheme.colorScheme.primaryContainer
        }
    }

    val contentAlpha by transition.animateFloat(
        transitionSpec = { tween(durationMillis = 300) },
        label = "contentAlpha"
    ) { state ->
        when (state) {
            CardState.Collapsed -> 0f
            CardState.Expanded -> 1f
        }
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        Card(
            modifier = Modifier.fillMaxWidth(),
            onClick = {
                cardState = if (cardState == CardState.Collapsed) {
                    CardState.Expanded
                } else {
                    CardState.Collapsed
                }
            },
            shape = RoundedCornerShape(cornerRadius),
            colors = CardDefaults.cardColors(containerColor = backgroundColor)
        ) {
            Column(modifier = Modifier.padding(padding)) {
                Text(
                    text = "Jetpack Compose",
                    style = MaterialTheme.typography.titleMedium
                )
                // 展开时才显示的内容
                if (contentAlpha > 0f) {
                    Spacer(modifier = Modifier.height(8.dp))
                    Text(
                        text = "这是展开后的详细内容。Transition 保证了 padding、圆角、颜色、透明度四个属性同步动画。",
                        style = MaterialTheme.typography.bodyMedium,
                        modifier = Modifier.graphicsLayer { alpha = contentAlpha }
                    )
                }
            }
        }
    }
}

这段代码里发生了什么:

  1. 用枚举 CardState 定义状态,比 Boolean 更具可读性,未来加状态也方便
  2. updateTransition 创建一个 Transition 对象,所有属性通过它来驱动
  3. 四个属性(padding、圆角、颜色、透明度)绑定同一个 Transition,保证同步
  4. animateDp / animateColor / animateFloat 的 lambda 参数 state 是当前目标状态,你根据状态返回目标值即可

为什么用 updateTransition 而不是四个 animate*AsState?因为 Transition 保证所有属性从同一帧开始动画,不会出现"圆角已经变了但颜色还没变"的割裂感。


七、动画规格:AnimationSpec

前面所有示例都用到了 tween,但它不是唯一选择。Compose 提供了几种 AnimationSpec,决定了动画的"性格":

7.1 四种常用 AnimationSpec

AnimationSpec 特点 适用场景
tween 固定时长 + 缓动曲线 大多数场景,可控且可预测
spring 物理弹簧模型,可能超调 自然弹性的效果(如卡片回弹)
keyframes 关键帧,精确控制每个时间点的值 复杂多段动画
repeatable 重复播放(配合 RepeatMode 需要循环的动画

7.2 tween:固定时长 + 缓动曲线

kotlin 复制代码
tween<Float>(
    durationMillis = 300,           // 动画时长
    delayMillis = 0,                // 延迟开始
    easing = FastOutSlowInEasing    // 缓动曲线
)

常用 Easing 曲线:

Easing 效果 什么时候用
FastOutSlowInEasing 开始快、结束慢 默认首选,大部分过渡动画
LinearOutSlowInEasing 匀速开始、慢速结束 进入动画
FastOutLinearInEasing 快速开始、匀速结束 退出动画
LinearEasing 匀速 进度条、旋转

7.3 spring:物理弹簧

kotlin 复制代码
spring<Float>(
    dampingRatio = Spring.DampingRatioMediumBouncy,  // 阻尼比:越大越不弹
    stiffness = Spring.StiffnessMedium                // 刚度:越大越快
)
dampingRatio 效果
Spring.DampingRatioHighBouncy 弹很多次
Spring.DampingRatioMediumBouncy 适度弹跳(推荐)
Spring.DampingRatioLowBouncy 轻微弹跳
Spring.DampingRatioNoBouncy 不弹

为什么用 spring 而不是 tween?spring 是基于物理模型的,动画更自然 。Material Design 的官方建议是:对于位置/尺寸变化,优先用 spring;对于颜色/透明度变化,用 tween

7.4 keyframes:关键帧

kotlin 复制代码
keyframes<Float> {
    durationMillis = 600
    0f at 0 using LinearEasing         // 0ms 时值为 0f
    0.8f at 200 using FastOutSlowInEasing // 200ms 时值为 0.8f
    1f at 600                           // 600ms 时值为 1f
}

at 指定关键帧时间点,using 指定到达该帧的缓动曲线。帧与帧之间自动插值。

7.5 对比示例:同一个动画,四种规格

kotlin 复制代码
@Composable
fun AnimationSpecCompareDemo() {
    var isAnimated by remember { mutableStateOf(false) }

    // tween:300ms 固定时长
    val tweenOffset by animateFloatAsState(
        targetValue = if (isAnimated) 1f else 0f,
        animationSpec = tween(durationMillis = 500),
        label = "tween"
    )

    // spring:物理弹簧
    val springOffset by animateFloatAsState(
        targetValue = if (isAnimated) 1f else 0f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioMediumBouncy,
            stiffness = Spring.StiffnessMedium
        ),
        label = "spring"
    )

    // keyframes:关键帧
    val keyframesOffset by animateFloatAsState(
        targetValue = if (isAnimated) 1f else 0f,
        animationSpec = keyframes {
            durationMillis = 500
            0f at 0
            0.6f at 200 using FastOutSlowInEasing
            1f at 500
        },
        label = "keyframes"
    )

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(24.dp)
    ) {
        Text(
            text = "AnimationSpec 对比",
            style = MaterialTheme.typography.titleLarge
        )

        SpecRow("tween", tweenOffset)
        SpecRow("spring", springOffset)
        SpecRow("keyframes", keyframesOffset)

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

        Button(
            onClick = { isAnimated = !isAnimated },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("切换状态")
        }
    }
}

@Composable
private fun SpecRow(label: String, offset: Float) {
    Column {
        Text(text = label, style = MaterialTheme.typography.labelMedium)
        Box(
            modifier = Modifier
                .fillMaxWidth()
                .height(40.dp)
                .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(8.dp))
        ) {
            Box(
                modifier = Modifier
                    .size(40.dp)
                    .offset(x = (offset * 280).dp)
                    .background(MaterialTheme.colorScheme.primary, CircleShape)
            )
        }
    }
}

这段代码里发生了什么:

  1. 三种 AnimationSpec 驱动三个小球,同样的起止值,不同的动画"性格"
  2. tween 500ms 匀速到达,稳定可预测
  3. spring 有弹跳感,到终点后会微微超调再回来
  4. keyframes 在 200ms 时先快速到 0.6,再缓慢到 1.0,制造"先快后慢"的节奏感

八、综合实战:带动画的设置页面

这个 Demo 把前面所有知识点串起来------animate*AsStateAnimatedVisibilityAnimatedContentrememberInfiniteTransitionupdateTransition、各种 AnimationSpec,一个不落。

kotlin 复制代码
// ---------- 数据模型 ----------
private data class SettingItem(
    val title: String,
    val subtitle: String,
    val icon: ImageVector
)

// ---------- 状态枚举 ----------
private enum class ThemeMode { Light, Dark, Auto }

// ---------- 主页面 ----------
@Composable
fun AnimatedSettingsDemo() {
    // ---- 状态 ----
    var notificationsEnabled by rememberSaveable { mutableStateOf(true) }
    var themeMode by rememberSaveable { mutableStateOf(ThemeMode.Auto) }
    var expandedIndex by remember { mutableStateOf(-1) } // -1 = 全部收起

    // ---- 无限循环动画:通知铃铛摇晃 ----
    val infiniteTransition = rememberInfiniteTransition(label = "bell")
    val bellRotation by infiniteTransition.animateFloat(
        initialValue = -15f,
        targetValue = 15f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 500, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "bellRotation"
    )

    // ---- Transition:卡片展开联动 ----
    val settingsTransition = updateTransition(
        targetState = expandedIndex,
        label = "settingsTransition"
    )

    val cardElevation by settingsTransition.animateDp(
        transitionSpec = { spring(stiffness = Spring.StiffnessMedium) },
        label = "cardElevation"
    ) { index ->
        if (index >= 0) 8.dp else 2.dp
    }

    // ---- animateColorAsState:开关颜色 ----
    val switchTrackColor by animateColorAsState(
        targetValue = if (notificationsEnabled) Color(0xFF6200EE) else Color.Gray,
        animationSpec = tween(durationMillis = 300),
        label = "switchColor"
    )

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp)
    ) {
        // ---- 标题区域:AnimatedContent 切换主题名称 ----
        Text(
            text = "设置",
            style = MaterialTheme.typography.headlineMedium,
            fontWeight = FontWeight.Bold
        )

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

        // 通知开关卡片
        Card(
            modifier = Modifier.fillMaxWidth(),
            elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
        ) {
            Row(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(16.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                // 铃铛图标:无限循环摇晃
                Icon(
                    imageVector = Icons.Default.Notifications,
                    contentDescription = null,
                    tint = switchTrackColor,
                    modifier = Modifier.graphicsLayer {
                        rotationZ = if (notificationsEnabled) bellRotation else 0f
                    }
                )

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

                Column(modifier = Modifier.weight(1f)) {
                    Text(text = "通知", style = MaterialTheme.typography.titleMedium)
                    Text(
                        text = if (notificationsEnabled) "已开启" else "已关闭",
                        style = MaterialTheme.typography.bodySmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }

                Switch(
                    checked = notificationsEnabled,
                    onCheckedChange = { notificationsEnabled = it },
                    colors = SwitchDefaults.colors(
                        checkedTrackColor = switchTrackColor
                    )
                )
            }
        }

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

        // 主题选择卡片
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .clickable { expandedIndex = if (expandedIndex == 1) -1 else 1 },
            elevation = CardDefaults.cardElevation(
                defaultElevation = if (expandedIndex == 1) cardElevation else 2.dp
            )
        ) {
            Column(modifier = Modifier.padding(16.dp)) {
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Icon(
                        imageVector = Icons.Default.Palette,
                        contentDescription = null
                    )
                    Spacer(modifier = Modifier.width(16.dp))
                    Column(modifier = Modifier.weight(1f)) {
                        Text(text = "主题模式", style = MaterialTheme.typography.titleMedium)
                        // AnimatedContent:主题名称切换动画
                        AnimatedContent(
                            targetState = themeMode,
                            transitionSpec = {
                                slideInVertically { height -> height } + fadeIn() togetherWith
                                    slideOutVertically { height -> -height } + fadeOut()
                            },
                            label = "themeLabel"
                        ) { mode ->
                            Text(
                                text = when (mode) {
                                    ThemeMode.Light -> "浅色"
                                    ThemeMode.Dark -> "深色"
                                    ThemeMode.Auto -> "跟随系统"
                                },
                                style = MaterialTheme.typography.bodySmall,
                                color = MaterialTheme.colorScheme.onSurfaceVariant
                            )
                        }
                    }
                }

                // AnimatedVisibility:主题选项展开
                AnimatedVisibility(
                    visible = expandedIndex == 1,
                    enter = expandVertically() + fadeIn(),
                    exit = shrinkVertically() + fadeOut()
                ) {
                    Column(modifier = Modifier.padding(top = 12.dp)) {
                        ThemeMode.entries.forEach { mode ->
                            val isSelected = themeMode == mode
                            val optionColor by animateColorAsState(
                                targetValue = if (isSelected) MaterialTheme.colorScheme.primary
                                    else MaterialTheme.colorScheme.onSurface,
                                label = "optionColor_$mode"
                            )

                            Row(
                                modifier = Modifier
                                    .fillMaxWidth()
                                    .clickable { themeMode = mode }
                                    .padding(vertical = 8.dp),
                                verticalAlignment = Alignment.CenterVertically
                            ) {
                                RadioButton(
                                    selected = isSelected,
                                    onClick = { themeMode = mode }
                                )
                                Text(
                                    text = when (mode) {
                                        ThemeMode.Light -> "浅色"
                                        ThemeMode.Dark -> "深色"
                                        ThemeMode.Auto -> "跟随系统"
                                    },
                                    color = optionColor
                                )
                            }
                        }
                    }
                }
            }
        }

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

        // 关于卡片:点击展开详情
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .clickable { expandedIndex = if (expandedIndex == 2) -1 else 2 },
            elevation = CardDefaults.cardElevation(
                defaultElevation = if (expandedIndex == 2) cardElevation else 2.dp
            )
        ) {
            Column(modifier = Modifier.padding(16.dp)) {
                Row(verticalAlignment = Alignment.CenterVertically) {
                    Icon(imageVector = Icons.Default.Info, contentDescription = null)
                    Spacer(modifier = Modifier.width(16.dp))
                    Text(text = "关于", style = MaterialTheme.typography.titleMedium)
                }

                AnimatedVisibility(
                    visible = expandedIndex == 2,
                    enter = expandVertically() + fadeIn(
                        animationSpec = tween(durationMillis = 400)
                    ),
                    exit = shrinkVertically() + fadeOut(
                        animationSpec = tween(durationMillis = 200)
                    )
                ) {
                    Column(modifier = Modifier.padding(top = 12.dp)) {
                        Text(
                            text = "Jetpack Compose 动画示例 v1.0",
                            style = MaterialTheme.typography.bodyMedium
                        )
                        Text(
                            text = "本 Demo 涵盖了 animate*AsState、AnimatedVisibility、" +
                                    "AnimatedContent、rememberInfiniteTransition、" +
                                    "updateTransition 和 AnimationSpec。",
                            style = MaterialTheme.typography.bodySmall,
                            color = MaterialTheme.colorScheme.onSurfaceVariant
                        )
                    }
                }
            }
        }
    }
}

实战知识点对应表

实战中的效果 使用的 API 对应章节
开关颜色过渡 animateColorAsState
通知铃铛持续摇晃 rememberInfiniteTransition
主题名称文字切换 AnimatedContent
主题选项展开/收起 AnimatedVisibility
关于详情展开/收起 AnimatedVisibility
卡片展开时 elevation 变化 updateTransition
选项文字颜色过渡 animateColorAsState
铃铛旋转用 graphicsLayer graphicsLayer
Card elevation 用 spring spring
淡入淡出用 tween tween

九、总结

本篇你学到了 Compose 动画体系的核心 API:

  1. animate*AsState:最简单的属性动画,状态变了自动过渡
  2. AnimatedVisibility:组件出现/消失动画,支持淡入淡出、滑入滑出、缩放、展开,可组合使用
  3. AnimatedContent / Crossfade :内容切换时的过渡动画,AnimatedContent 支持自定义方向
  4. rememberInfiniteTransition:无限循环动画,适合加载指示器、呼吸灯
  5. updateTransition:多属性联动,保证同步动画
  6. AnimationSpectween(固定时长)、spring(物理弹簧)、keyframes(关键帧),各有适用场景
  7. graphicsLayer :动画中优先用 graphicsLayer 代替 Modifier,避免不必要的重组

核心原则:Compose 动画是状态驱动的------你只管改状态,动画框架管过渡 。和 XML 里手动 startAnimation() 的时代说再见吧。

下一篇我们将学习 自定义布局与 ConstraintLayout------当标准布局不够用时,如何自己掌控测量与排列。


如果你在学习过程中有任何疑问,欢迎在评论区留言,我会尽可能回复。

系列文章:

相关推荐
千里马学框架1 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台1 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone1 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc1 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo1 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077001 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼1 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone1 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen1 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone1 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui