为什么 Compose 动画值得单独写
在传统 View 体系下,Android 动画分散在 ObjectAnimator、TransitionManager、MotionLayout 三条线;而 Compose 用声明式 API 统一了全部动画场景:
- 组件显隐动画(AnimatedVisibility)
- 值动画(animateFloatAsState / animateDpAsState)
- 布局变化动画(AnimatedContent)
- 手势联动动画(Animatable + draggable)
- 多属性协同动画(updateTransition)
本文聚焦 Compose 动画的工程化实战:从 API 选型、性能优化到复杂场景落地。
AnimatedVisibility:组件显隐的标准方案
最常用的动画场景:对话框弹出、Toast 浮现、列表项删除------都可以用 AnimatedVisibility 解决。
基础用法
kotlin
@Composable
fun ToastDemo() {
var visible by remember { mutableStateOf(false) }
Column {
Button(onClick = { visible = !visible }) {
Text("Toggle Toast")
}
AnimatedVisibility(visible = visible) {
Text(
"这是一条消息",
modifier = Modifier
.background(Color.Black.copy(alpha = 0.8f))
.padding(16.dp),
color = Color.White
)
}
}
}
自定义进入/退出动画
kotlin
AnimatedVisibility(
visible = visible,
enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(),
exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut()
) {
Text("从上方滑入")
}
列表项删除动画
kotlin
@Composable
fun DeleteAnimationDemo() {
var items by remember { mutableStateOf(listOf("A", "B", "C")) }
LazyColumn {
items(items, key = { it }) { item ->
AnimatedVisibility(
visible = true,
exit = shrinkVertically() + fadeOut()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(item)
Button(onClick = { items = items - item }) {
Text("删除")
}
}
}
}
}
}
animateXxxAsState:值动画的声明式封装
当你需要让某个值从 A 平滑过渡到 B(颜色、尺寸、透明度),用 animateXxxAsState。
尺寸动画
kotlin
@Composable
fun ExpandableCard() {
var expanded by remember { mutableStateOf(false) }
val size by animateDpAsState(
targetValue = if (expanded) 200.dp else 100.dp,
label = "card_size"
)
Box(
modifier = Modifier
.size(size)
.background(Color.Blue)
.clickable { expanded = !expanded }
)
}
颜色动画
kotlin
@Composable
fun ColorButton() {
var selected by remember { mutableStateOf(false) }
val bgColor by animateColorAsState(
targetValue = if (selected) Color.Blue else Color.Gray,
label = "bg_color"
)
Button(
onClick = { selected = !selected },
colors = ButtonDefaults.buttonColors(containerColor = bgColor)
) {
Text("点我变色")
}
}
自定义动画曲线
kotlin
val offset by animateDpAsState(
targetValue = if (visible) 0.dp else 100.dp,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "offset"
)
AnimatedContent:布局切换的完整动画
当内容本身变化时(如 Tab 切换、状态切换),AnimatedContent 会自动处理退场 + 入场动画。
Tab 切换动画
kotlin
@Composable
fun TabSwitcher() {
var selectedTab by remember { mutableStateOf(0) }
Column {
Row {
TextButton(onClick = { selectedTab = 0 }) { Text("首页") }
TextButton(onClick = { selectedTab = 1 }) { Text("我的") }
}
AnimatedContent(
targetState = selectedTab,
transitionSpec = {
slideInHorizontally { it } togetherWith slideOutHorizontally { -it }
},
label = "tab_switch"
) { tab ->
when (tab) {
0 -> Text("首页内容", Modifier.fillMaxSize())
1 -> Text("我的内容", Modifier.fillMaxSize())
}
}
}
}
手势联动动画:Animatable + draggable
当动画需要跟随手指拖拽时,Animatable + Modifier.draggable 是标准组合。
可拖拽的卡片
kotlin
@Composable
fun DraggableCard() {
val offsetX = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
.size(100.dp)
.background(Color.Red)
.draggable(
orientation = Orientation.Horizontal,
state = rememberDraggableState { delta ->
scope.launch { offsetX.snapTo(offsetX.value + delta) }
},
onDragStopped = {
scope.launch {
offsetX.animateTo(
targetValue = 0f,
animationSpec = spring()
)
}
}
)
)
}
updateTransition:多属性协同动画
当一个状态变化需要同时驱动多个动画(颜色 + 尺寸 + 透明度),用 updateTransition。
kotlin
@Composable
fun MultiPropertyButton() {
var pressed by remember { mutableStateOf(false) }
val transition = updateTransition(targetState = pressed, label = "button")
val scale by transition.animateFloat(label = "scale") { state ->
if (state) 0.9f else 1f
}
val alpha by transition.animateFloat(label = "alpha") { state ->
if (state) 0.7f else 1f
}
Box(
modifier = Modifier
.size(100.dp)
.scale(scale)
.alpha(alpha)
.background(Color.Blue)
.clickable { pressed = !pressed }
)
}
常见坑点与性能优化
| 坑点 | 表现 | 解决方案 |
|---|---|---|
| 动画过程中频繁重组 | 卡顿、掉帧 | 用 Modifier.graphicsLayer 替代 Modifier.offset / Modifier.scale |
| AnimatedVisibility 内容未被回收 | 内存泄漏 | 确保 visible = false 时内容被完全移除 |
| 手势动画抖动 | 拖拽不跟手 | 用 Animatable.snapTo 而非 animateTo |
| 复杂动画无法中断 | 用户操作被阻塞 | 所有协程动画用 launch 启动,而非 async |
性能优化:graphicsLayer
kotlin
// ❌ 每帧触发重组
Box(Modifier.offset { IntOffset(x.roundToInt(), 0) })
// ✅ 在合成层完成,不触发重组
Box(Modifier.graphicsLayer { translationX = x })
实战:下拉刷新动画
kotlin
@Composable
fun PullToRefresh(onRefresh: () -> Unit, content: @Composable () -> Unit) {
val pullOffset = remember { Animatable(0f) }
val scope = rememberCoroutineScope()
var refreshing by remember { mutableStateOf(false) }
Box(
modifier = Modifier
.fillMaxSize()
.draggable(
orientation = Orientation.Vertical,
state = rememberDraggableState { delta ->
if (!refreshing && delta > 0) {
scope.launch { pullOffset.snapTo((pullOffset.value + delta).coerceAtMost(200f)) }
}
},
onDragStopped = {
if (pullOffset.value > 100f) {
refreshing = true
onRefresh()
scope.launch {
pullOffset.animateTo(50f)
kotlinx.coroutines.delay(1000) // 模拟加载
refreshing = false
pullOffset.animateTo(0f)
}
} else {
scope.launch { pullOffset.animateTo(0f) }
}
}
)
) {
Column(Modifier.offset { IntOffset(0, pullOffset.value.roundToInt()) }) {
if (pullOffset.value > 0) {
Box(
Modifier
.fillMaxWidth()
.height((pullOffset.value / 2).dp),
contentAlignment = Alignment.Center
) {
if (refreshing) {
CircularProgressIndicator(Modifier.size(24.dp))
} else {
Text("下拉刷新", fontSize = 12.sp)
}
}
}
content()
}
}
}
总结
- 显隐动画 → AnimatedVisibility
- 值动画 → animateXxxAsState
- 布局切换 → AnimatedContent
- 手势联动 → Animatable + draggable
- 多属性协同 → updateTransition
- 性能优化 → graphicsLayer 替代 offset/scale
Compose 动画的核心思路是状态驱动:只需声明目标状态,框架自动处理过渡。这种模式让动画代码更清晰、更可维护。