24、Compose动画与列表:LazyColumn动画、items动画、animateItemPlacement

列表动画,说实话,是Android开发里绕不开的一个坎。

我记得几年前做传统View开发时,给ListView加个删除动画,得写一堆ItemAnimator,还要处理各种边界情况。那时候我就想,要是能像iOS那样丝滑就好了。后来Compose出来了,我第一时间试了它的列表动画——嗯,这才是我想要的。

为什么列表动画这么重要?

你想想看,用户每天滑动列表,删除条目,新增数据,调整顺序。如果这些操作都是生硬的「闪现」,体验就很廉价。而加上动画后,整个App的质感会提升一个档次。

说白了,列表动画就是给数据变化加上「过渡」。数据变了,UI怎么从旧状态平滑到新状态?这就是我们要解决的问题。

LazyColumn的基础动画支持

Compose的LazyColumn默认就带了一些动画能力,但需要你主动开启。我个人习惯是,只要列表有增删改操作,就加上动画,成本很低,收益很高。

核心API: animateItemPlacement() 修饰符

这个修饰符是LazyColumn动画的基石。它告诉Compose:当item的位置发生变化时,请用动画过渡过去。

@Composable
fun AnimatedLazyColumn() {
    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没写对。

items动画:增删的优雅处理

光有位置动画还不够。删除一个item时,我们希望它先淡出,然后其他item再移动上来。新增时,希望它从某个方向滑入。

这就需要用到AnimatedVisibility了。把它和animateItemPlacement组合起来,效果就很完整。

@Composable
fun AnimatedListWithAddRemove() {
    val items = remember { mutableStateListOf("Item 1", "Item 2", "Item 3") }
    var counter by remember { mutableIntStateOf(4) }
    
    LazyColumn {
        items(items, key = { it }) { item ->
            AnimatedVisibility(
                visible = true,
                enter = slideInVertically() + fadeIn(),
                exit = slideOutVertically() + fadeOut()
            ) {
                ListItem(
                    text = item,
                    modifier = Modifier.animateItemPlacement()
                )
            }
        }
    }
}

注意看,AnimatedVisibility包裹了每个item,定义了进入和退出的动画。而animateItemPlacement负责处理item之间的位置调整。两者分工明确,互不干扰。

我的经验: enter动画用slideInVertically + fadeIn,exit用slideOutVertically + fadeOut,这是最自然的组合。太花哨的动画反而会让用户觉得拖沓。

animateItemPlacement 的底层逻辑

你可能会问:这个修饰符到底是怎么工作的?

其实原理不复杂。Compose在布局阶段会记录每个item的位置。当下一次重组时,如果某个item的key还在,但位置变了,Compose就会计算位置差值,然后用动画插值器把这个差值过渡掉。

说白了,就是「旧位置 → 新位置」的平滑过渡。没有魔法,只有扎实的差值计算。

animateItemPlacement 工作流程 数据变化触发重组 增/删/改操作 Compose 记录新旧位置 通过 key 匹配 item 计算位置差值 dx, dy 偏移量 动画插值器过渡 Spring / Tween 动画 UI 平滑移动到新位置 用户无感知过渡 布局稳定,动画结束 最终状态 整个过程在单个帧内完成计算,不影响滚动性能

性能考量:别让动画拖垮列表

动画虽好,但也要注意性能。LazyColumn本身是懒加载的,但加上动画后,Compose需要维护更多状态。

场景 性能影响 建议
少量item(<20) 几乎无影响 放心使用
中等数量(20-100) 轻微影响 注意动画时长,别太长
大量item(>100) 明显卡顿风险 考虑只对可见item加动画
注意: 不要在animateItemPlacement的item内部使用复杂的布局或大量Modifier。每个item的动画都会触发重组,复杂布局会让重组成本飙升。

实战:一个完整的可拖拽排序列表

说到列表动画,最经典的应用场景就是拖拽排序了。配合animateItemPlacement,实现起来比传统View简单太多。

@Composable
fun DraggableList() {
    val items = remember { mutableStateListOf("A", "B", "C", "D", "E") }
    var draggedItem by remember { mutableStateOf<String?>(null) }
    
    LazyColumn {
        items(items, key = { it }) { item ->
            val isDragged = draggedItem == item
            
            ListItem(
                text = item,
                modifier = Modifier
                    .fillMaxWidth()
                    .animateItemPlacement()  // 位置变化动画
                    .graphicsLayer {
                        scaleX = if (isDragged) 1.05f else 1f
                        scaleY = if (isDragged) 1.05f else 1f
                        shadowElevation = if (isDragged) 8f else 0f
                    }
            )
        }
    }
}

这里我用了graphicsLayer来给拖拽中的item加一点放大和阴影效果。注意,animateItemPlacement依然在正常工作——当拖拽导致其他item移位时,它们会平滑地让出位置。

常见坑点与避坑指南

  • key必须唯一且稳定——用数据本身的ID,不要用index。index变化会导致动画错乱。
  • 不要嵌套LazyColumn——嵌套滚动加动画,性能直接崩。我踩过这个坑。
  • 动画时长别太长——默认的Spring动画其实挺好,非要改的话,300ms以内就够了。
  • 注意item的measure——如果item高度不固定,动画可能会跳。尽量给固定高度或用height(IntrinsicSize.Min)
一个小技巧: 如果你发现动画不够流畅,试试把animateItemPlacement放到最外层的Modifier链上。越早应用,Compose的优化空间越大。

说实话,Compose的列表动画已经做得相当成熟了。相比传统View那套繁琐的ItemAnimator,Compose的思路更清晰——用修饰符声明式地描述动画意图,剩下的交给框架。

我个人建议,只要你的列表涉及数据变化,就加上animateItemPlacement。成本几乎为零,但用户体验的提升是实打实的。你想想看,用户删除一条笔记时,其他笔记平滑地补上来,那种「顺滑感」就是好App和普通App的分水岭。


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