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 小时前
Kotlin 协程深度解析②:生存指南——掌握结构化并发的生命线
android·kotlin
故渊at1 小时前
第四板块:Android 输入系统与触控事件 | 第十五篇:InputReader 与 InputDispatcher 的触控流水线
android·anr·输入系统·inputdispatcher·inputreader·触控事件·inputevent
方白羽1 小时前
Vibe Coding 四个核心阶段
android·前端·app
潘潘潘3 小时前
Android网络结构分析——有线网络
android
踏雪羽翼4 小时前
Android OpenGL实现十几种美颜功能
android
Android小码家5 小时前
BootAnimation+SE+开机MP4动画播放
android·framework
加农炮手Jinx5 小时前
Flutter for OpenHarmony:pub_updater 命令行工具自动更新专家(DevOps 运维必备) 深度解析与鸿蒙适配指南
android·运维·网络·flutter·华为·harmonyos·devops
2601_957418806 小时前
告别OTG碎片化!Android MTP协议深度解析与高性能通信方案
android