28、Compose动画与共享元素:SharedTransitionScope、共享元素过渡在Compose中的实现、列表到详情页动画

共享元素过渡,说白了就是让一个UI组件从A位置“飞”到B位置。

比如你点开一张图片,它从列表的小图平滑放大到详情页的大图。这种效果在原生Android里用ActivityOptionsCompat做,挺麻烦的。到了Compose,Google终于给了我们一套更优雅的方案——SharedTransitionScope

SharedTransitionScope 是什么?

它是Compose 1.5之后引入的一个接口。你不需要手动管理动画帧,也不需要操心坐标计算。你只需要告诉系统:“这两个组件是同一个东西”,剩下的过渡动画,框架帮你搞定。

我个人习惯把SharedTransitionScope理解成一个“动画舞台”。所有要参与共享过渡的组件,都得在这个舞台里注册。舞台负责协调入场、退场、位置变化、尺寸变化。

核心概念:

  • SharedContentState:每个共享元素的状态,包含它的边界、大小、动画进度。
  • SharedTransitionScope:提供sharedElementsharedBounds两个修饰符。
  • 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)
    }
}

你看,核心逻辑其实没几行。关键就是rememberSharedContentStatesharedElement修饰符的配合。

知识体系结构图

Compose共享元素过渡知识体系 SharedTransitionScope SharedContentState sharedElement修饰符 sharedBounds修饰符 rememberSharedContentState AnimatedContent配合 边界动画与裁剪 列表到详情页过渡 LazyColumn回收问题 多元素共享与组合 关键:稳定的key + 同时存在的组件引用

一些实战经验

我在项目中用过共享元素过渡做商品列表到商品详情。一开始觉得很简单,结果遇到了几个问题:

  • 图片闪烁:因为列表页的图片是缩略图,详情页是大图。过渡时尺寸变化太大,导致图片闪烁。解决方案是给sharedElement加上boundsTransform参数,自定义边界变化曲线。
  • 文字共享:不只是图片可以共享,文字也可以。但文字共享时要注意字体大小和颜色的过渡,否则看起来很生硬。
  • 多个共享元素:一个页面里可以有多个共享元素,只要key不同就行。比如头像、标题、背景图可以同时做过渡。

个人建议:共享元素过渡不要滥用。一个页面里超过3个共享元素,动画就会显得杂乱。我一般只共享最重要的视觉元素,比如主图或者标题。

总结

Compose的共享元素过渡,说白了就是给两个组件贴上同一个标签,然后让框架帮你算中间过程。你不需要关心坐标、插值器、动画时长——这些都可以通过参数自定义。

如果你之前做过View系统的共享元素动画,你会觉得Compose这套方案清爽很多。如果你没做过,那恭喜你,直接从Compose开始学,省去了很多弯路。


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