28、Compose动画与共享元素:SharedTransitionScope、共享元素过渡在Compose中的实现、列表到详情页动画
共享元素过渡,说白了就是让一个UI组件从A位置“飞”到B位置。
比如你点开一张图片,它从列表的小图平滑放大到详情页的大图。这种效果在原生Android里用ActivityOptionsCompat做,挺麻烦的。到了Compose,Google终于给了我们一套更优雅的方案——SharedTransitionScope。
SharedTransitionScope 是什么?
它是Compose 1.5之后引入的一个接口。你不需要手动管理动画帧,也不需要操心坐标计算。你只需要告诉系统:“这两个组件是同一个东西”,剩下的过渡动画,框架帮你搞定。
我个人习惯把SharedTransitionScope理解成一个“动画舞台”。所有要参与共享过渡的组件,都得在这个舞台里注册。舞台负责协调入场、退场、位置变化、尺寸变化。
核心概念:
- SharedContentState:每个共享元素的状态,包含它的边界、大小、动画进度。
- SharedTransitionScope:提供
sharedElement和sharedBounds两个修饰符。 - AnimatedContent:通常与共享元素配合,实现页面级别的过渡。
共享元素过渡在Compose中的实现
我们先看一个最简单的例子:两个页面之间共享一张图片。
@Composable
fun SharedElementDemo() {
var showDetail by remember { mutableStateOf(false) }
SharedTransitionScope {
AnimatedContent(
targetState = showDetail,
transitionSpec = {
fadeIn() togetherWith fadeOut()
}
) { isDetail ->
if (!isDetail) {
ListScreen(
onItemClick = { showDetail = true }
)
} else {
DetailScreen(
onBack = { showDetail = false }
)
}
}
}
}
@Composable
fun ListScreen(onItemClick: () -> Unit) {
val sharedTransitionState = rememberSharedContentState(key = "avatar")
Image(
painter = painterResource(id = R.drawable.avatar),
contentDescription = null,
modifier = Modifier
.size(64.dp)
.sharedElement(
state = sharedTransitionState,
animatedVisibilityScope = this@AnimatedContent
)
.clickable { onItemClick() }
)
}
@Composable
fun DetailScreen(onBack: () -> Unit) {
val sharedTransitionState = rememberSharedContentState(key = "avatar")
Image(
painter = painterResource(id = R.drawable.avatar),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.height(300.dp)
.sharedElement(
state = sharedTransitionState,
animatedVisibilityScope = this@AnimatedContent
)
.clickable { onBack() }
)
}
注意看,两个Image用了同一个key = "avatar"。这就是共享元素的“身份证”。Compose通过这个key把两个组件关联起来。
小技巧:key不一定要是字符串,可以是任何可比较的对象。我在项目中用过数据类的id作为key,这样列表里每个item都能独立共享。
列表到详情页动画
这才是实际开发中最常见的场景。你有一个LazyColumn,点击某个item,跳转到详情页,item里的图片平滑过渡过去。
嗯,这里有个坑。LazyColumn会回收item,如果你在列表页和详情页之间切换,共享元素的引用可能会丢失。我曾经因为这个bug排查了半天——图片飞到一半突然消失了。
避坑指南:共享元素必须同时存在于两个页面中。如果列表页的item被回收了,详情页就找不到对应的共享元素。解决方案是:在列表页保持item的可见性,或者使用rememberSharedContentState时传入稳定的key。
下面是一个完整的列表到详情页的实现思路:
@Composable
fun SharedListToDetail() {
var selectedItem by remember { mutableStateOf<ItemData?>(null) }
SharedTransitionScope {
AnimatedContent(
targetState = selectedItem,
transitionSpec = {
fadeIn() togetherWith fadeOut()
}
) { item ->
if (item == null) {
ItemList(
onItemClick = { selectedItem = it }
)
} else {
ItemDetail(
item = item,
onBack = { selectedItem = null }
)
}
}
}
}
@Composable
fun ItemList(onItemClick: (ItemData) -> Unit) {
LazyColumn {
items(sampleItems) { item ->
val state = rememberSharedContentState(key = "item_${item.id}")
ListItemCard(
item = item,
sharedState = state,
onClick = { onItemClick(item) }
)
}
}
}
@Composable
fun ListItemCard(
item: ItemData,
sharedState: SharedContentState,
onClick: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(8.dp)
) {
Image(
painter = painterResource(id = item.imageRes),
contentDescription = null,
modifier = Modifier
.size(48.dp)
.sharedElement(
state = sharedState,
animatedVisibilityScope = this@AnimatedContent
)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = item.title)
}
}
你看,核心逻辑其实没几行。关键就是rememberSharedContentState和sharedElement修饰符的配合。
知识体系结构图
一些实战经验
我在项目中用过共享元素过渡做商品列表到商品详情。一开始觉得很简单,结果遇到了几个问题:
- 图片闪烁:因为列表页的图片是缩略图,详情页是大图。过渡时尺寸变化太大,导致图片闪烁。解决方案是给
sharedElement加上boundsTransform参数,自定义边界变化曲线。 - 文字共享:不只是图片可以共享,文字也可以。但文字共享时要注意字体大小和颜色的过渡,否则看起来很生硬。
- 多个共享元素:一个页面里可以有多个共享元素,只要key不同就行。比如头像、标题、背景图可以同时做过渡。
个人建议:共享元素过渡不要滥用。一个页面里超过3个共享元素,动画就会显得杂乱。我一般只共享最重要的视觉元素,比如主图或者标题。
总结
Compose的共享元素过渡,说白了就是给两个组件贴上同一个标签,然后让框架帮你算中间过程。你不需要关心坐标、插值器、动画时长——这些都可以通过参数自定义。
如果你之前做过View系统的共享元素动画,你会觉得Compose这套方案清爽很多。如果你没做过,那恭喜你,直接从Compose开始学,省去了很多弯路。
公众号:蓝海资料掘金营,微信deep3321