最清晰的 Compose 共享元素过渡入门文章
共享元素过渡是一种能够自然衔接不同页面中相同内容的过渡方式,通常用于导航,确保用户在切换不同屏幕时体验更加连贯流畅。比如上图的示例 app,可以看到 item 的图片和标题从主页丝滑地过渡到详情页,像吃了德芙 🍫 一样丝滑。
要在 Compose 里面实现共享元素过渡,主要涉及以下 3 个 API:
SharedTransitionLayout
:实现共享元素过渡所需的最外层布局,它提供了一个SharedTransitionScope
,可组合项必须位于SharedTransitionScope
作用域中,才能使用共享元素修饰符。Modifier.sharedElement()
:标记某个可组合项,以便SharedTransitionScope
将其与另一个可组合项匹配。Modifier.sharedBounds()
:标记某个可组合项的边界作为过渡的容器边界。与sharedElement()
不同,sharedBounds()
用于视觉上不同的内容。
截至发稿前,Compose 中有关共享元素过渡的相关 API 仍处于 Beta 阶段,🛠️ 所以别忘了手动引入依赖:
implementation("androidx.compose.animation:animation:1.7.0-beta06")
SharedTransitionLayout 和 sharedElement Modifier
实现共享元素过渡的前提是我们得有两个页面,而且它们都包含某些相同的内容,不然过渡个寂寞?🤷♂️
@Composable
fun BasicSharedElementDemo() {
var showDetails by remember { mutableStateOf(false) }
if (showDetails) {
DetailsScreen( onBack = { showDetails = false })
} else {
MainScreen(onShowDetails = { showDetails = true })
}
}
@Composable
private fun MainScreen(onShowDetails: () -> Unit, modifier: Modifier = Modifier) {
Box(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
Row(
modifier = modifier
.padding(all = 16.dp)
.border(width = 1.dp, color = Color.Gray.copy(alpha = 0.5f), shape = RoundedCornerShape(8.dp))
.background(color = LavenderLight, shape = RoundedCornerShape(8.dp))
.clickable { onShowDetails() }
.padding(all = 8.dp)
) {
Image(
painter = painterResource(id = R.drawable.avatar), contentDescription = null,
modifier = Modifier.size(size = 100.dp)
)
Text(text = "bqliang", fontSize = 21.sp)
}
}
}
@Composable
private fun DetailsScreen(onBack: () -> Unit, modifier: Modifier = Modifier) {
Box(modifier = modifier.fillMaxSize().systemBarsPadding(), contentAlignment = Alignment.Center) {
Column(
modifier = modifier
.padding(16.dp)
.border( width = 1.dp, color = Color.Gray.copy(alpha = 0.5f), shape = RoundedCornerShape(8.dp))
.background(color = RoseLight, shape = RoundedCornerShape(8.dp))
.clickable { onBack() }
.padding(8.dp)
) {
Image(
painter = painterResource(id = R.drawable.avatar), contentDescription = null,
modifier = Modifier.size(200.dp)
)
Text(text = "bqliang", fontSize = 28.sp)
Text(text = stringResource(R.string.lorem_ipsum))
}
}
}
OK,现在我们有两个包含一些相同内容的页面,但过渡非常生硬,接下来我们就看看如何在这两个页面中实现共享元素过渡。
首先我们得在最外面套一层 SharedTransitionLayout
,因为过渡的本质是:框架识别出共享元素的前后位置和大小,然后在叠加层逐帧绘制过渡的画面。🎨🖌️
@Composable
fun BasicSharedElementDemo() {
var showDetails by remember { mutableStateOf(false) }
+ SharedTransitionLayout {
if (showDetails) {
DetailsScreen(onBack = { showDetails = false })
} else {
MainScreen(onShowDetails = { showDetails = true })
}
+ }
}
第二步,我们要告诉框架:这两个页面中的哪些元素是“共享的” 🔗。想象一下,如果从一个列表页(很多图片)过渡到详情页,框架又怎么知道具体应该过渡哪张图片到详情页呢?其实很简单,我们给“共享的”两个元素指定同一个 Key 就行了。那么怎么指定呢?用 sharedElement()
修饰符。
// SharedTransitionScope.kt
interface SharedTransitionScope : LookaheadScope {
fun Modifier.sharedElement(
state: SharedContentState,
animatedVisibilityScope: AnimatedVisibilityScope,
...
): Modifier
}
可以看到修饰符有两个必填参数:
-
state
:可以通过rememberSharedContentState(key = "unique_key")
函数创建SharedContentState
实例,给参数传递一个唯一的 Key,通常是字符串,也可以是任何其他类型。 -
animatedVisibilityScope
:关于这个参数可能一时之间有点难以理解,AnimatedVisibilityScope
其实是个接口,我们不妨先看一下它的继承关系:看起来好像和
AnimatedVisibility
、AnimatedContent
有关。其实是这样的,🤝 共享元素过渡通常需要配合着AnimatedVisibility
或AnimatedContent
一起使用,比如两个页面整体内容原来是使用AnimatedVisibility
来淡入淡出切换的,那么就可以在此基础之上使用共享元素过渡,组件被sharedElement()
修饰后,框架会负责协调动画,它会通过animatedVisibilityScope
告知 AnimatedVisibility 📢:现在这个元素的过渡动画由我接管!你不需要再淡入淡出它了。// AnimatedContent.kt fun <S> AnimatedContent( ..., content: @Composable() AnimatedContentScope.(targetState: S) -> Unit // 📌 // 注意 content 拥有 AnimatedContentScope 上下文,这是 AnimatedVisibilityScope 的子接口 )
另外还需要注意:sharedElement()
修饰符是定义在 SharedTransitionScope
接口里面的,🔍 那这个 SharedTransitionScope
又要从哪里找呢?其实上一步的 SharedTransitionLayout
已经为你提供了,只是你没有察觉而已:
// SharedTransitionScope.kt
@Composable
fun SharedTransitionLayout(
...,
content: @Composable SharedTransitionScope.() -> Unit // 📌
// 注意 content 拥有 SharedTransitionScope 上下文
)
回头看我们的代码,为了能在 MainScreen
和 DetailsScreen
里面使用 sharedElement()
修饰符,我们将可组合项声明为 SharedTransitionScope
的拓展函数,这样可组合项就默认拥有了 SharedTransitionScope
上下文环境 。同时还给可组合项增加了参数 animatedVisibilityScope
,因为后续调用 sharedElement()
时需要用到。
将可组合项声明为
SharedTransitionScope
拓展函数是其中一种方式,你也可以以参数或者 CompositionLocal 的形式声明SharedTransitionScope
,后续再用 Context Receiver 的方式调用sharedElement()
修饰符。
@Composable
- private fun MainScreen(
+ private fun SharedTransitionScope.MainScreen(
onShowDetails: () -> Unit,
+ animatedVisibilityScope: AnimatedVisibilityScope,
modifier: Modifier = Modifier
) { ... }
@Composable
- private fun DetailsScreen(
+ private fun SharedTransitionScope.DetailsScreen(
onBack: () -> Unit,
+ animatedVisibilityScope: AnimatedVisibilityScope,
modifier: Modifier = Modifier
) { ... }
现在我们已经可以在 MainScreen
和 DetailsScreen
里面使用 sharedElement()
修饰符了:
@Composable
private fun SharedTransitionScope.MainScreen(onShowDetails: () -> Unit, animatedVisibilityScope: AnimatedVisibilityScope, modifier: Modifier = Modifier) {
Box(...) {
Row(...) {
Image(
painter = painterResource(id = R.drawable.avatar), contentDescription = null,
modifier = Modifier
+ .sharedElement(
+ state = rememberSharedContentState(key = "image"),
+ animatedVisibilityScope = animatedVisibilityScope
+ )
.size(size = 100.dp)
)
Text(
text = "bqliang", fontSize = 21.sp,
+ modifier = Modifier.sharedElement(
+ state = rememberSharedContentState(key = "title"),
+ animatedVisibilityScope = animatedVisibilityScope
+ )
)
}
}
}
@Composable
private fun SharedTransitionScope.DetailsScreen(onBack: () -> Unit, animatedVisibilityScope: AnimatedVisibilityScope, modifier: Modifier = Modifier) {
Box(...) {
Column(...) {
Image(
painter = painterResource(id = R.drawable.avatar), contentDescription = null,
modifier = Modifier
+ .sharedElement(
+ state = rememberSharedContentState(key = "image"),
+ animatedVisibilityScope = animatedVisibilityScope
+ )
.size(200.dp)
)
Text(
text = "bqliang", fontSize = 28.sp,
+ modifier = Modifier.sharedElement(
+ state = rememberSharedContentState(key = "title"),
+ animatedVisibilityScope = animatedVisibilityScope
+ )
)
Text(text = stringResource(R.string.lorem_ipsum))
}
}
}
最后,我们把 if else
切换页面换成 👉 AnimatedContent
,具体原因前面也说了,共享元素过渡通常要配合着 AnimatedVisibility
或 AnimatedContent
一起使用,另外别忘了将 animatedVisibilityScope
传递给两个可组合项。
@Composable
fun BasicSharedElementDemo() {
var showDetails by remember { mutableStateOf(false) }
SharedTransitionLayout {
+ AnimatedContent(targetState = showDetails) { inDetails ->
- if(showDetails) {
+ if (inDetails) {
DetailsScreen(
onBack = { showDetails = false },
+ animatedVisibilityScope = this@AnimatedContent
)
} else {
MainScreen(
onShowDetails = { showDetails = true },
+ animatedVisibilityScope = this@AnimatedContent
)
}
+ }
}
}
运行看看:
并不完美
好像还不错 😀,但仔细观察你会发现两个问题 🚨:
-
请注意两个页面中的内容背景部分 🟪🟥,也就是包裹主要内容的
Row
和Column
,它们的作用不仅仅是使内容按横向 / 纵向排列,更重要的是它们还充当了容器的角色(相当于Card
组件),拥有背景和边框,可以看到这两个“容器”现在的过渡效果是由AnimatedContent
决定的,也就是淡出淡入。我们其实可以还做得更好,在 Material Design 里面,针对容器类组件之间的过渡,有一种叫“容器转换”的设计,简单来说,就是将一个容器的边界逐渐扩展为另一个容器的边界。如果我们能在Row
与Column
之间实现这种容器转换的过渡效果,用户的视觉体验会更上一层楼。 -
第二个问题,👀 请仔细观察“标题”部分的过渡效果:内容是瞬间改变的,所占空间大小却是逐渐变化的,给人的感觉不是很连贯,🤔 为什么它的行为看起来和图片部分不一样,有种说不出来的奇怪?
在给出解决方案前,我打算先详细解释一下第二个问题发生的原因。
以主页向详情页过渡为例,在过渡期间,sharedElement()
只会渲染目标内容(也就是 200dp 的图片),但为什么我们看到的图片不是固定尺寸,而是在逐渐放大?因为 sharedElement()
修饰符会在过渡前对两个界面进行前瞻性测量,所以它知道小图的宽高为 100dp,大图宽高为 200dp,在过渡期间它会根据起始和目标宽高不断计算 Image
新的宽高约束(比如过渡进行到一半时,宽高约束就是 150dp),然后它会将这个约束条件传递给 Image
,强制要求 Image
在该尺寸限制下重新测量,有点类似于 requiredSize()
修饰符。我们可以写代码验证一下:
@Preview(showBackground = true)
@Composable
private fun ImageInTransition() {
Column {
Image(
painter = painterResource(id = R.drawable.avatar), contentDescription = null,
modifier = Modifier
.size(200.dp)
.border(width = 2.dp, color = Color.Yellow)
.requiredSize(100.dp) // 📌
)
Image(
painter = painterResource(id = R.drawable.avatar), contentDescription = null,
modifier = Modifier
.size(200.dp)
.border(width = 2.dp, color = Color.Green)
.requiredSize(150.dp) // 📌
)
Image(
painter = painterResource(id = R.drawable.avatar), contentDescription = null,
modifier = Modifier
.size(200.dp)
.border(width = 2.dp, color = Color.Red)
.requiredSize(200.dp) // 📌
)
}
}
前面说了,在过渡期间,页面上只会渲染目标内容(200dp 的图片),为了模拟该场景,上面 3 个 Image 都带有 size(200.dp)
修饰符。
因为过渡期间会不断计算约束尺寸,所以在后面又链式调用了 requiredSize(xx.dp)
,强制要求以此尺寸约束测量,size(200.dp)
会被覆盖掉,这是重点,换句话说,图像在绘制的时候,它根本不知道自己原来有 200dp 的大别墅,只知道有自己有 xxdp 可用空间(也就是下图线框区域),秉承着有多少用多少的原则,最终图像呈现出来当然只有 xxdp 大小了。在过渡期间,边框外的空白部分会被裁剪掉,剩下的内容是符合预期的:
同样地,我们用 Text
来试验一下:
@Preview(showBackground = true)
@Composable
private fun TextInTransition() {
val localDensity = LocalDensity.current
var startPxSize by remember { mutableStateOf(IntSize.Zero) }
var endPxSize by remember { mutableStateOf(IntSize.Zero) }
val destinationDpSize by remember {
derivedStateOf {
with(localDensity) {
val width = ((endPxSize.width - startPxSize.width) / 2 + startPxSize.width).toDp()
val height = ((endPxSize.height - startPxSize.height) / 2 + startPxSize.height).toDp()
DpSize(width, height)
}
}
}
Column {
Text(
text = "bqliang", fontSize = 21.sp,
modifier = Modifier
.background(Color.Yellow.copy(alpha = 0.5f))
.onSizeChanged { startPxSize = it }
)
Text(
text = "bqliang", fontSize = 28.sp,
modifier = Modifier
.background(Color.Green.copy(alpha = 0.5f))
.requiredSize(destinationDpSize)
)
Text(
text = "bqliang", fontSize = 28.sp,
modifier = Modifier
.background(Color.Red.copy(alpha = 0.5f))
.onSizeChanged { endPxSize = it }
)
}
}
这一次,因为我们事先并不知道 21sp 和 28sp 文字的宽高分别是多少,只能用 onSizeChanged()
获取初始和目标宽高,然后再计算过渡到一半时的宽高约束 destinationDpSize
,最后将其传递给 requiredSize()
,强制要求以此尺寸约束测量,再来看看效果:
注意观察绿色部分,这不就是我们在上面遇到的问题吗?
绿色和红色部分绘制的都是 28sp 文字(目标内容),红色部分是目标状态的宽高,这个宽高刚刚好能容纳下 28sp 的文字,绿色部分的宽高肯定比目标状态宽高要小,因为才过渡到一半呢。这里的关键是,Text
的绘制不像 Image
,外部给多少空间,就按这个空间来画出整张图片。绘制 Text
时,就算外部给的空间不够,要绘制的东西仍然是 28sp 大小的文字,Text
不会因为空间不够就把字号调小。所以在较小的绿色区域绘制 28sp 文字时,超出区域的部分内容也就被裁掉了。
不知道我解释得是否足够清楚?不管怎么样,接下来让我们看看,怎么去解决前面提到的两个问题吧。其实非常简单,我们可以用同一个方案同时解决以上两个问题——sharedBounds()
修饰符,它的使用方法和 shareElement()
修饰符类似。
sharedBounds Modifier
@Composable
private fun SharedTransitionScope.MainScreen(onShowDetails: () -> Unit, animatedVisibilityScope: AnimatedVisibilityScope, modifier: Modifier = Modifier) {
Box(...) {
Row(
modifier = Modifier
.padding(16.dp)
+ .sharedBounds(
+ sharedContentState = rememberSharedContentState(key = "bounds"),
+ animatedVisibilityScope = animatedVisibilityScope
+ )
...
) {
...
Text(
text = "bqliang", fontSize = 21.sp,
modifier = Modifier
- .sharedElement(
- state = rememberSharedContentState(key = "title"),
- animatedVisibilityScope = animatedVisibilityScope
- )
+ .sharedBounds(
+ sharedContentState = rememberSharedContentState(key = "title"),
+ animatedVisibilityScope = animatedVisibilityScope
+ )
)
}
}
}
@Composable
private fun SharedTransitionScope.DetailsScreen(onBack: () -> Unit, animatedVisibilityScope: AnimatedVisibilityScope, modifier: Modifier = Modifier) {
Box(...) {
Column(
modifier = Modifier
.padding(16.dp)
+ .sharedBounds(
+ sharedContentState = rememberSharedContentState(key = "bounds"),
+ animatedVisibilityScope = animatedVisibilityScope
+ )
...
) {
...
Text(
text = "bqliang", fontSize = 28.sp,
modifier = Modifier
- .sharedElement(
- state = rememberSharedContentState(key = "title"),
- animatedVisibilityScope = animatedVisibilityScope
- )
+ .sharedBounds(
+ sharedContentState = rememberSharedContentState(key = "title"),
+ animatedVisibilityScope = animatedVisibilityScope
+ )
)
}
}
}
我们使用 sharedBounds()
修饰符修饰 Row
和 Column
,同时将之前修饰标题的 sharedElement()
也改成 sharedBounds()
。
看起来比之前好很多,至少标题部分的过渡问题解决了,不过容器过渡效果似乎有点怪(稍后我再来解释)。
Modifier.sharedBounds()
类似于 Modifier.sharedElement()
,但这两个修饰符在以下方面有所不同:
sharedElement()
适用于内容完全相同的元素过渡;sharedBounds()
则适用于视觉上不同但在过渡期间共享同一块区域的内容,比如上图中的紫色容器和粉色容器,虽然彼此背景色不同,但在过渡期间,他俩共享的是同一块区域(这块区域的边界在两种状态之间变换)。- 在过渡期间,
sharedElement()
只会在变换边界内渲染目标内容;而sharedBounds()
会在变换边界内渲染初始内容和目标内容,你可以仔细观察上图,紫色方块和粉色方块在过渡时都是可见的。Modifier.sharedBounds()
具有enter
和exit
参数,用于指定内容如何过渡,默认是初始内容淡出,目标内容淡入。 sharedBounds()
的常见用例是容器变换;而sharedElement()
的常见用例是英雄动画(hero transitions),其实 Android 原生开发里面没有“Hero 动画”,这是 Flutter 里面的概念,简单来说就是视觉上完全相同的内容之间的过渡(只是被等比缩放了、调整了位置)。
在进行文字之间的过渡时,我们应该优先用 sharedBounds()
而不是 sharedElement()
。一方面是因为 sharedElement()
在过渡期间只会在变换边界内渲染目标内容(文字会突然变大变小,甚至在过渡期间部分字符无法显示),而 sharedBounds()
会在过渡期间渲染初始内容和目标内容,并默认应用淡入淡出效果。另一方面,很多时候,两个页面的文字虽然字符相同,但是样式不同(粗细、颜色、斜体),比如 abc 和 𝒂𝒃𝒄,从共享元素过渡的角度来看,这不属于相同内容,肯定不能用 sharedElement()
。
// SharedTransitionScope.kt
fun Modifier.sharedBounds(
sharedContentState: SharedContentState,
animatedVisibilityScope: AnimatedVisibilityScope,
enter: EnterTransition = fadeIn(),
exit: ExitTransition = fadeOut(),
boundsTransform: BoundsTransform = DefaultBoundsTransform,
resizeMode: ResizeMode = ScaleToBounds(ContentScale.FillWidth, Center),
...
): Modifier
另外有一个比较重要的参数 resizeMode
我觉得有必要提一下,sharedBounds()
的目的是共享元素边界,比如上面的 Row
和 Column
,那么在边界变换时,里边的内容(即背景色)怎么办呢?我知道效果是淡出淡入,可是内容与边界的关系是什么?是按比例缩放至填满横向宽度吗?还是按比例缩放至填满纵向高度?从 resizeMode
的默认参数 ScaleToBounds(ContentScale.FillWidth, Center)
不难看出,初始内容和目标内容会按原比例缩放至填满边界的横向宽度,并放置在区域的中心。
而正是参数 resizeMode
的默认值导致过渡动画看起来怪异,可能有些人还看不出来哪里有问题,请观察对比以下效果:
很明显,我们目前的效果是左边,预期的效果是右边。
左边看起来奇怪,是因为初始内容和目标内容是按各自的原比例缩放至填满边界的横向宽度,并放置在区域的中心,问题就在这里,初始内容和目标内容的原比例根本就不一样,这意味着两块内容的上下左右是不可能对齐的,我们想要右边的效果,在边界变换过程中,初始内容和目标内容都是完全填满边界的。
对于这个问题,其实有两种方法,一种方法是使用 RemeasureToBounds
:
@Composable
private fun SharedTransitionScope.MainScreen(onShowDetails: () -> Unit, animatedVisibilityScope: AnimatedVisibilityScope, modifier: Modifier = Modifier) {
Box(...) {
Row(
modifier = Modifier
.padding(16.dp)
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "bounds"),
animatedVisibilityScope = animatedVisibilityScope,
+ resizeMode = RemeasureToBounds
)
...
) { ... }
}
}
@Composable
private fun SharedTransitionScope.DetailsScreen(onBack: () -> Unit, animatedVisibilityScope: AnimatedVisibilityScope, modifier: Modifier = Modifier) {
Box(...) {
Column(
modifier = Modifier
.padding(16.dp)
.sharedBounds(
sharedContentState = rememberSharedContentState(key = "bounds"),
animatedVisibilityScope = animatedVisibilityScope,
+ resizeMode = RemeasureToBounds
)
...
) { ... }
}
}
从名字 RemeasureToBounds
就很容易看出来它的作用,在边界转换期间,它会将边界大小作为组件的尺寸约束,强制要求组件重新测量,和前面提到的 shareElement()
的行为差不多。
😭 终于... 💯 Perfect!
下面我要介绍第二种方法,既然 resizeMode
的默认参数是 ScaleToBounds(ContentScale.FillWidth, Center)
,内容默认按原比例缩放至填满边界横向宽度,那为什么不用 ScaleToBounds(ContentScale.FillBounds, Center)
直接让内容拉伸填满整个边界呢?代码我就不贴了,我们直接看看是什么效果:
如果不熟悉图片的各种缩放模式,可以看这里:Customize an image
你可以和 RemeasureToBounds
对比一下,注意看大卡片的详情文字部分,。因为 ScaleToBounds(ContentScale.FillBounds, Center)
不涉及重新测量,只是将内容进行缩放,所以详情文字在整个过程中是完整显示的,只整体被缩放了;而 RemeasureToBounds
会在空间变化时重新测量并摆放组件的内容,所以详情文字会随着空间的大小变化而逐行显示/消失。
我个人认为,对于目前这个场景,RemeasureToBounds
会更好一些,你觉得呢?
如果你感兴趣,可以看看 Doris Liu(Compose Animation APIs 开发成员)在 Twitter 上面发表的有关 ResizeMode 的帖子。
好了,本文的主要内容到这里就要结束了,完整示例代码可以从这里找到👉 BasicSharedElementSnippets
总结
最后总结一下今天学习到的东西吧 📝
- 元素共享修饰符和边界共享修饰符必须在
SharedTransitionScope
中调用,且通常需要与AnimatedVisibility
或AnimatedContent
一起配合使用。 sharedElement()
和sharedBounds()
的适用场景与区别:sharedElement()
适用于内容完全相同的元素过渡;sharedBounds()
则用于转换元素边界,通常适用于容器转换场景。- 在过渡期间,
sharedElement()
只会在变换边界内渲染目标内容;sharedBounds()
则会在变换边界内渲染初始内容与目标内容。 - 在过渡期间,
sharedElement()
会不断计算元素的宽高约束,强制要求元素重新测量。sharedBounds()
默认只是将初始内容与目标内容进行缩放,可以通过resizeMode
调整该行为。
碎碎念
其实在我写这篇文章之前,网上已经有很多人第一时间🦀用上了 Compose 的共享元素过渡也发了相关文章,但是自己照着把代码跑起来后,总是感觉效果不理想,研究的时候发现还挺多需要注意的坑 🕳的,于是就有了这篇文章。本来还想多写点内容的,没想到讲完共享元素和共享边界两个修饰符,篇幅已经差不多了,关于共享过渡是怎么做到前瞻性测量的一些比较深的内容,看情况再决定要不要写下一篇吧,如果对你有帮助,可以点个赞 ❤️,谢谢 🙏🏻
(总感觉自己写的东西没人看 😭)
参考: