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,结果列表排序后直接「闪现」,完全没有动画效果。嗯,这个坑我替你们踩过了。

核心要点:animateItemPlacement 依赖 key 来追踪 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),再从数据源里移除。这样动画不会被打断,用户体验很顺滑。

注意:不要在 AnimatedVisibility 的 exit 动画还没播完时就移除数据源里的 item,否则 Compose 会直接销毁 Composable,动画被截断。

更优雅的方式:使用 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 动画知识体系 LazyColumn 动画 animateItemPlacement 增删动画 (AnimatedVisibility) animateItem (1.7+) 依赖 key 追踪 item 先隐藏,再移除数据 统一 fadeIn/fadeOut/placement 核心:声明式动画,只需描述「要什么」,不用管「怎么做」

实际项目中的避坑指南

我曾经在一个即时通讯项目里,用 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 的声明式特性让这些组合变得异常简单。

最后说一句:列表动画的终极目标,是让用户感觉不到「列表在动」,而是觉得「数据就该这样流动」。做到这一点,你的动画就成功了。


公众号:蓝海资料掘金营,微信deep3321