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)
}
}
}
}
注意看,AnimatedContent的transitionSpec里,我们需要用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怎么过渡。在AnimatedContent的transitionSpec里配置:
AnimatedContent(
targetState = showDetail,
transitionSpec = {
// 共享元素的过渡规格
fadeIn() togetherWith fadeOut()
.using(SizeTransform(clip = false))
}
) { isDetail ->
// ...
}
这里togetherWith表示进入和退出同时进行。SizeTransform控制尺寸变化时的裁剪行为。你想想看,如果不设置clip = false,元素在缩放过程中可能会被裁剪掉一部分,看起来就不够丝滑。
我建议:大多数情况下用fadeIn() togetherWith fadeOut()就够了。别搞太复杂的组合,容易出bug。共享元素动画本身已经够炫了,过渡动画简单点反而更干净。
知识体系一览
下面这张图,是我自己整理的共享元素动画知识结构。你看一眼就能明白各个组件之间的关系:
实战中的避坑指南
嗯,这里要注意几个容易翻车的地方:
- 共享元素不能嵌套:一个
AnimatedVisibilityScope里,同一个key只能出现一次。如果你在列表里用了同一个key的多个元素,动画会乱掉。 - 记得处理返回动画:从详情页返回列表时,共享元素应该反向动画回去。Compose默认支持,但如果你手动改了
transitionSpec,要确保返回动画也配置了。 - 图片加载要提前:如果共享元素是网络图片,建议在列表页就预加载好。否则详情页打开时图片还没加载完,动画会卡一下。
我曾经踩过的坑:有一次做商品详情页,共享元素是商品主图。列表页用的是缩略图,详情页用的是高清大图。结果动画过程中,缩略图先放大,然后突然闪一下变成高清图。后来我改成在列表页就加载高清图,只是显示为缩略图大小,问题就解决了。
总结
共享元素动画,说白了就是让用户感觉「同一个东西在移动」。实现起来不复杂,但细节决定成败。
记住三个核心点:SharedTransitionLayout包全局,AnimatedVisibilityScope标记元素,key保持一致。把这三点做好,你的页面转场就能丝滑得像德芙一样。
我个人觉得,共享元素动画是Compose动画里最值得花时间研究的一个。它带来的用户体验提升,远比花里胡哨的旋转缩放要实在得多。
公众号:蓝海资料掘金营,微信deep3321