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 的做法完全不同------动画跟着状态走。你改变状态,框架自动算出动画;你不用手动启动,也不用管取消。
Compose 动画 API 按复杂度递增,可以分成这几层:
| 层级 | API | 适用场景 |
|---|---|---|
| 简单属性动画 | animate*AsState |
单个属性值变化(颜色、透明度、圆角等) |
| 可见性动画 | AnimatedVisibility |
组件的显示/隐藏 |
| 内容切换动画 | AnimatedContent、Crossfade |
内容整体替换时的过渡 |
| 无限循环动画 | rememberInfiniteTransition |
加载指示器、呼吸灯等持续动画 |
| 多属性联动 | updateTransition |
多个属性跟随同一个状态变化 |
| 动画规格 | tween、spring、keyframes |
自定义动画曲线 |
本文按这个顺序从简单到复杂逐个讲解,每个 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
)
}
}
}
这段代码里发生了什么:
isExpanded是控制状态------点击时在true/false之间切换cornerRadius跟随isExpanded变化,从 8.dp 平滑过渡到 24.dpbackgroundColor同理,从青色平滑过渡到紫色- 你只管切换
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 支持 enter 和 exit 两个参数,分别控制出现和消失的效果:
| 动画效果 | 进入(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
)
}
}
}
}
}
这段代码里发生了什么:
- 点击卡片切换
showDetail状态 AnimatedVisibility监听showDetail,自动触发进入/退出动画- 进入时:同时淡入 + 垂直展开,看起来像从卡片下方"长出来"
- 退出时:同时淡出 + 垂直收缩,收回去
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:可自定义的内容切换
AnimatedContent 是 Crossfade 的进阶版------你可以自定义进入和退出的过渡方式,不只是淡入淡出。
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时,count增大------新数字从下方滑入,旧数字向上滑出 - 点击
-1时,count减小------新数字从上方滑入,旧数字向下滑出 SizeTransform(clip = false)表示内容尺寸变化时不裁剪,让过渡更自然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
)
}
}
这段代码里发生了什么:
rememberInfiniteTransition创建一个无限动画控制器- 两个
animateFloat分别控制透明度和缩放,各自独立循环 graphicsLayer修改的是绘制层参数,比Modifier.alpha()/Modifier.scale()性能更好(不会触发重组,只在绘制阶段生效)
graphicsLayervsModifier.alpha():graphicsLayer在绘制阶段应用变换,不触发重组;Modifier.alpha()会触发重组。做动画时优先用graphicsLayer,性能更好。
六、多属性联动:updateTransition
6.1 它是什么
当一个状态变化需要同时驱动多个属性 时,updateTransition 比 animate*AsState 更合适------它把多个属性绑在同一个状态上,保证它们同步变化。
如果用 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 }
)
}
}
}
}
}
这段代码里发生了什么:
- 用枚举
CardState定义状态,比 Boolean 更具可读性,未来加状态也方便 updateTransition创建一个 Transition 对象,所有属性通过它来驱动- 四个属性(padding、圆角、颜色、透明度)绑定同一个 Transition,保证同步
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)
)
}
}
}
这段代码里发生了什么:
- 三种
AnimationSpec驱动三个小球,同样的起止值,不同的动画"性格" tween500ms 匀速到达,稳定可预测spring有弹跳感,到终点后会微微超调再回来keyframes在 200ms 时先快速到 0.6,再缓慢到 1.0,制造"先快后慢"的节奏感
八、综合实战:带动画的设置页面
这个 Demo 把前面所有知识点串起来------animate*AsState、AnimatedVisibility、AnimatedContent、rememberInfiniteTransition、updateTransition、各种 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:
animate*AsState:最简单的属性动画,状态变了自动过渡AnimatedVisibility:组件出现/消失动画,支持淡入淡出、滑入滑出、缩放、展开,可组合使用AnimatedContent/Crossfade:内容切换时的过渡动画,AnimatedContent支持自定义方向rememberInfiniteTransition:无限循环动画,适合加载指示器、呼吸灯updateTransition:多属性联动,保证同步动画AnimationSpec:tween(固定时长)、spring(物理弹簧)、keyframes(关键帧),各有适用场景graphicsLayer:动画中优先用graphicsLayer代替Modifier,避免不必要的重组
核心原则:Compose 动画是状态驱动的------你只管改状态,动画框架管过渡 。和 XML 里手动
startAnimation()的时代说再见吧。
下一篇我们将学习 自定义布局与 ConstraintLayout------当标准布局不够用时,如何自己掌控测量与排列。
如果你在学习过程中有任何疑问,欢迎在评论区留言,我会尽可能回复。
系列文章:
- Jetpack Compose 入门系列(一):从零搭建到基础控件使用
- Jetpack Compose 入门系列(二):布局到 State 的使用
- Jetpack Compose 入门系列(三):MVVM + 协程 实现网络请求列表
- Jetpack Compose 入门系列(四):动画基本使用(本文)