20、Compose动画之共享元素动画:SharedTransitionLayout、AnimatedVisibilityScope、共享元素转场

共享元素动画,说白了就是让一个UI元素在两个页面之间「飞过去」。

比如你点开一张小图,它放大变成大图。或者列表里的卡片,点进去变成详情页的头部。这种效果在View时代用ActivityOptionsCompat做,挺麻烦的。到了Compose,Google终于给了我们一套更优雅的方案。

我个人习惯把共享元素动画叫做「丝滑转场的灵魂」。没有它,页面跳转就是生硬的闪一下。有了它,用户体验直接上一个档次。

核心三件套

Compose的共享元素动画,主要靠三个东西配合:

  • SharedTransitionLayout:最外层的容器,负责管理共享元素的状态
  • AnimatedVisibilityScope:每个页面自己的作用域,标记哪些元素要参与动画
  • 共享元素转场:通过sharedElement()sharedBounds()两个Modifier实现

我刚开始学的时候,老把这三个东西搞混。后来想明白了一个道理:SharedTransitionLayout是舞台,AnimatedVisibilityScope是演员的化妆间,而那两个Modifier就是演员的动作指导。

关键理解:共享元素动画不是两个独立页面的动画,而是一个「共享容器」内的两个状态之间的过渡。两个页面其实共享同一个布局空间。

SharedTransitionLayout:舞台搭建

先看怎么用。最外层包一个SharedTransitionLayout

@Composable
fun SharedElementDemo() {
    SharedTransitionLayout {
        var showDetail by remember { mutableStateOf(false) }
        
        AnimatedContent(
            targetState = showDetail,
            transitionSpec = {
                // 这里定义转场动画
            }
        ) { isDetail ->
            if (isDetail) {
                DetailScreen(this)
            } else {
                ListScreen(this)
            }
        }
    }
}

注意看,AnimatedContenttransitionSpec里,我们需要用sharedElement()来指定共享元素的行为。这个this就是AnimatedVisibilityScope

我的习惯:SharedTransitionLayout放在最顶级的NavHost外面,或者直接放在Activity的setContent里。这样整个应用都能共享同一个过渡上下文。

AnimatedVisibilityScope:标记演员

每个页面拿到AnimatedVisibilityScope之后,就可以用它来标记共享元素了。看代码:

@Composable
fun AnimatedVisibilityScope.ListScreen() {
    // 列表中的每个卡片
    Card(
        modifier = Modifier
            .sharedElement(
                state = rememberSharedContentState(key = "avatar_${item.id}"),
                animatedVisibilityScope = this@ListScreen
            )
            .clickable { /* 跳转详情 */ }
    ) {
        AsyncImage(
            model = item.avatarUrl,
            contentDescription = null
        )
    }
}

@Composable
fun AnimatedVisibilityScope.DetailScreen() {
    // 详情页的大图
    AsyncImage(
        model = item.avatarUrl,
        contentDescription = null,
        modifier = Modifier
            .sharedElement(
                state = rememberSharedContentState(key = "avatar_${item.id}"),
                animatedVisibilityScope = this@DetailScreen
            )
    )
}

这里有个坑,我踩过好几次——key必须一致。列表页和详情页的共享元素,key要一模一样。否则动画不会触发,直接闪过去。

我曾经犯过的错:在列表页用了"avatar_1",详情页用了"avatar_01"。结果动画死活不生效,查了半天才发现是key不匹配。后来我统一用一个常量类来管理所有共享元素的key。

sharedElement vs sharedBounds

这两个Modifier有什么区别?我简单总结一下:

Modifier 适用场景 特点
sharedElement 元素本身在变化(比如小图变大图) 元素的内容会跟着一起缩放、移动
sharedBounds 元素的位置和大小在变化,但内容不变 只过渡边界,内容可以单独控制

举个例子:头像从圆形小图变成方形大图,用sharedElement。一个按钮从左边移到右边,但按钮文字不变,用sharedBounds

我个人习惯是:能选sharedElement就选它。因为它更自然,用户看到的是同一个东西在变化。sharedBounds更适合那种「同一个容器装不同内容」的场景。

过渡规格:控制动画细节

光有共享元素还不够,我们还得告诉Compose怎么过渡。在AnimatedContenttransitionSpec里配置:

AnimatedContent(
    targetState = showDetail,
    transitionSpec = {
        // 共享元素的过渡规格
        fadeIn() togetherWith fadeOut()
            .using(SizeTransform(clip = false))
    }
) { isDetail ->
    // ...
}

这里togetherWith表示进入和退出同时进行。SizeTransform控制尺寸变化时的裁剪行为。你想想看,如果不设置clip = false,元素在缩放过程中可能会被裁剪掉一部分,看起来就不够丝滑。

我建议:大多数情况下用fadeIn() togetherWith fadeOut()就够了。别搞太复杂的组合,容易出bug。共享元素动画本身已经够炫了,过渡动画简单点反而更干净。

知识体系一览

下面这张图,是我自己整理的共享元素动画知识结构。你看一眼就能明白各个组件之间的关系:

Compose共享元素动画知识体系 SharedTransitionLayout AnimatedContent AnimatedVisibilityScope transitionSpec sharedElement() / sharedBounds() fadeIn() togetherWith fadeOut() 关键:key一致 + 共享容器 + 合理过渡规格

实战中的避坑指南

嗯,这里要注意几个容易翻车的地方:

  • 共享元素不能嵌套:一个AnimatedVisibilityScope里,同一个key只能出现一次。如果你在列表里用了同一个key的多个元素,动画会乱掉。
  • 记得处理返回动画:从详情页返回列表时,共享元素应该反向动画回去。Compose默认支持,但如果你手动改了transitionSpec,要确保返回动画也配置了。
  • 图片加载要提前:如果共享元素是网络图片,建议在列表页就预加载好。否则详情页打开时图片还没加载完,动画会卡一下。

我曾经踩过的坑:有一次做商品详情页,共享元素是商品主图。列表页用的是缩略图,详情页用的是高清大图。结果动画过程中,缩略图先放大,然后突然闪一下变成高清图。后来我改成在列表页就加载高清图,只是显示为缩略图大小,问题就解决了。

总结

共享元素动画,说白了就是让用户感觉「同一个东西在移动」。实现起来不复杂,但细节决定成败。

记住三个核心点:SharedTransitionLayout包全局,AnimatedVisibilityScope标记元素,key保持一致。把这三点做好,你的页面转场就能丝滑得像德芙一样。

我个人觉得,共享元素动画是Compose动画里最值得花时间研究的一个。它带来的用户体验提升,远比花里胡哨的旋转缩放要实在得多。


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