Compose 动画之列表动画:LazyColumn 的动画、animateItemPlacement、item 的增删动画
列表动画,说实话,是 Compose 动画里最让我觉得「嗯,真香」的部分之一。
为什么?因为以前用 RecyclerView 做列表增删动画,你得写 ItemAnimator,还得处理各种 notifyItemInserted、notifyItemRemoved 的时机。稍不留神,动画就崩了。我自己就踩过这个坑——有一次增删动画和 DiffUtil 配合不好,列表直接闪白,被测试小姐姐追着改了一下午。
到了 Compose 这里,LazyColumn 的动画支持,说白了就是「声明式」的胜利。你只需要告诉 Compose:这个 item 要移动、要出现、要消失,剩下的它自己搞定。
LazyColumn 动画的核心:animateItemPlacement
先讲最常用的——animateItemPlacement。这个 Modifier 的作用是:当 LazyColumn 里的某个 item 位置发生变化时,自动播放一个平移动画。
举个例子,你有一个列表,用户点击某个 item,它排序上升了。在 RecyclerView 时代,你得自己算偏移量,然后调用 animateTo。在 Compose 里,你只需要在 item 的 Modifier 链里加上 animateItemPlacement()。
@Composable
fun AnimatedListExample() {
val items = remember { mutableStateListOf("A", "B", "C", "D", "E") }
LazyColumn {
items(items, key = { it }) { item ->
Text(
text = item,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.animateItemPlacement() // 就这一行
)
}
}
}
你看,就一行代码。但这里有个关键点:必须设置 key。没有 key,Compose 不知道哪个 item 是哪个,动画就失效了。我刚开始用的时候也忘了写 key,结果列表排序后直接「闪现」,完全没有动画效果。嗯,这个坑我替你们踩过了。
item 的增删动画:AnimatedVisibility + LazyColumn
增删动画,说白了就是 item 出现和消失时的效果。在 LazyColumn 里,你不能直接用 AnimatedVisibility 包裹整个 item——因为 LazyColumn 会复用 item,直接包裹会导致动画状态错乱。
正确的做法是:在 item 的 Composable 内部,用 AnimatedVisibility 包裹内容,并且把 visible 参数绑定到 item 是否存在的状态上。
@Composable
fun AnimatedAddRemoveExample() {
var items by remember { mutableStateOf(listOf("A", "B", "C")) }
LazyColumn {
items(items, key = { it }) { item ->
var visible by remember { mutableStateOf(true) }
AnimatedVisibility(
visible = visible,
enter = slideInVertically() + fadeIn(),
exit = slideOutVertically() + fadeOut()
) {
Text(
text = item,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.clickable {
visible = false
// 延迟移除数据
CoroutineScope(Dispatchers.Default).launch {
delay(300) // 等待动画结束
items = items.filter { it != item }
}
}
)
}
}
}
}
这里有个小技巧:先隐藏,再移除数据。我习惯在点击后先把 visible 设为 false,等动画播完(比如 300ms),再从数据源里移除。这样动画不会被打断,用户体验很顺滑。
更优雅的方式:使用 Modifier.animateItem()
如果你用的是 Compose 1.7+,还有一个更简洁的 API——Modifier.animateItem()。它把 placement 动画和增删动画合并在一起了。
LazyColumn {
items(items, key = { it }) { item ->
Text(
text = item,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.animateItem(
fadeInSpec = tween(300),
fadeOutSpec = tween(300),
placementSpec = spring(dampingRatio = 0.5f)
)
)
}
}
我个人比较喜欢这个写法,因为参数更直观。你可以分别控制出现、消失、移动的动画规格。比如 placement 用 spring 弹性效果,增删用 tween 线性过渡,组合起来效果很自然。
知识体系结构图
下面这张图,是我梳理的 LazyColumn 动画核心脉络。你可以看到,所有动画都围绕「item 身份追踪」展开。
实际项目中的避坑指南
我曾经在一个即时通讯项目里,用 LazyColumn 做消息列表。消息频繁增删,还涉及置顶操作。一开始我直接用了 animateItemPlacement,结果发现:当列表滚动到中间时,突然插入一条消息,整个列表会「跳一下」。
排查下来,原因是:插入 item 时,LazyColumn 会重新计算所有 item 的位置,如果滚动偏移没处理好,就会产生视觉跳跃。
解决方案是:在插入数据前,先记录当前第一个可见 item 的 key 和偏移量,插入后再恢复。代码大概长这样:
val listState = rememberLazyListState()
// 插入前保存状态
val firstVisibleItem = listState.firstVisibleItemIndex
val firstVisibleOffset = listState.firstVisibleItemScrollOffset
// 插入数据
items.add(index, newItem)
// 插入后恢复
LaunchedEffect(Unit) {
listState.scrollToItem(firstVisibleItem, firstVisibleOffset)
}
嗯,这个技巧我后来写进了团队的技术规范里。说白了,列表动画不只是「加一行代码」那么简单,你得考虑滚动状态、数据一致性、动画时序。但一旦你掌握了这些细节,LazyColumn 的动画体验绝对比 RecyclerView 好一个档次。
LazyColumn + animateItemPlacement + 手势检测的组合。Compose 的声明式特性让这些组合变得异常简单。
最后说一句:列表动画的终极目标,是让用户感觉不到「列表在动」,而是觉得「数据就该这样流动」。做到这一点,你的动画就成功了。