Jetpack Compose Animation 前段时间终于支持了共享元素动画,这很大程度上提高了 Android 应用的用户体验,也降低了开发门槛,以前通过布局计算实现的方案既不优雅也很麻烦。
共享元素动画虽然和导航框架没关系,但是由于其使用上的一些限制,所以导航框架如果能直接支持是最方便的,目前在 Jetpack Navigation 中是已经支持的了,但是 Voyager 没有官方支持,好在 Voyager 的接口足够灵活,我们可以通过一些自定义的方式使其支持共享元素动画。
首先简单介绍一下共享元素动画。共享元素动画是指在不同的布局或者屏幕切换时共享其中的部分 UI 元素,被共享的元素会通过动画随着布局/页面切换过去,从而在视觉上提供一个很漂亮的专场。
在 Compose 中,有几个高级 API 可以帮助你创建共享元素:
- SharedTransitionLayout :实现共享元素转换所需的最外层布局,它提供了一个
SharedTransitionScope
,Composable 需要在SharedTransitionScope
中才能使用共享元素修饰符。 - Modifier.sharedElement() :这个修饰符用于向
SharedTransitionScope
标记该 Composable 应该与另一个 Composable 进行匹配。 - Modifier.sharedBounds() :这个修饰符用于向
SharedTransitionScope
标记该 Composable 的边界应该作为转换发生的容器边界。与sharedElement()
不同,sharedBounds()
是为视觉上不同的内容而设计的。
使用方式如下:
ini
var showDetails by remember { mutableStateOf(false) }
SharedTransitionLayout {
AnimatedContent(
showDetails,
label = "basic_transition"
) { targetState ->
if (!targetState) {
MainContent(
onShowDetails = {
showDetails = true
},
animatedVisibilityScope = this@AnimatedContent,
sharedTransitionScope = this@SharedTransitionLayout
)
} else {
DetailsContent(
onBack = {
showDetails = false
},
animatedVisibilityScope = this@AnimatedContent,
sharedTransitionScope = this@SharedTransitionLayout
)
}
}
}
@Composable
private fun MainContent(
onShowDetails: () -> Unit,
modifier: Modifier = Modifier,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope
) {
Row {
with(sharedTransitionScope) {
Image(
painter = painterResource(id = R.drawable.cupcake),
contentDescription = "Cupcake",
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = "image"),
animatedVisibilityScope = animatedVisibilityScope
)
.size(100.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
}
}
}
@Composable
private fun DetailsContent(
modifier: Modifier = Modifier,
onBack: () -> Unit,
sharedTransitionScope: SharedTransitionScope,
animatedVisibilityScope: AnimatedVisibilityScope
) {
Column {
with(sharedTransitionScope) {
Image(
painter = painterResource(id = R.drawable.cupcake),
contentDescription = "Cupcake",
modifier = Modifier
.sharedElement(
rememberSharedContentState(key = "image"),
animatedVisibilityScope = animatedVisibilityScope
)
.size(200.dp)
.clip(CircleShape),
contentScale = ContentScale.Crop
)
}
}
}
如何在 Voyager 中使用?
首先需要解决如何传递 SharedTransitionScope
以及 AnimatedVisibilityScope
的问题。因为 sharedElement
动画需要用到这两个 scope,如果一路传递下去很麻烦,因此我们可以通过过 CompositionLocalProvider
将其传递下去。
ini
val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }
因为 sharedElement
必须发生在 SharedTransitionLayout
内部,即使是不同的页面也需要这样,所以还需要在 Navigator
外层包一个 SharedTransitionLayout
。
scss
SharedTransitionLayout {
CompositionLocalProvider(
LocalSharedTransitionScope provides this
) {
Navigator(
screen = remember { FreadScreen() },
) { navigator ->
CurrentAnimatedScreen(navigator)
}
}
}
剩下的问题就是如何提供 AnimatedVisibilityScope
,我们可以使用 AnimatedContent
composable 来解决这个问题,并且 Voyager 的 Navigator
可以自定义 content
,那么我们可以在自定义 content
内加上 AnimatedContent
。
kotlin
@Composable
fun CurrentAnimatedScreen(navigator: Navigator) {
val currentScreen = navigator.lastItem
AnimatedContent(
targetState = currentScreen.key,
) { targetScreenKey ->
CompositionLocalProvider(
LocalAnimatedVisibilityScope provides this
) {
val targetScreen = navigator.items.lastOrNull { it.key == targetScreenKey }
if (targetScreen != null) {
navigator.saveableState("currentScreen", screen = targetScreen) {
targetScreen.Content()
}
}
}
}
}
这样我们就可以在给任意一个 composable 加上 sharedElement transitions 了。
当然,为了方便使用,我们还可以提供一个这样的扩展函数。
kotlin
@Composable
fun Modifier.sharedBoundsBetweenScreen(key: String): Modifier {
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
val sharedTransitionScope = LocalSharedTransitionScope.current
if (animatedVisibilityScope == null || sharedTransitionScope == null) return this
return with(sharedTransitionScope) {
sharedBounds(
sharedContentState = rememberSharedContentState(key),
animatedVisibilityScope = animatedVisibilityScope,
)
}
}
不过目前还发现了一个问题,在进入新的页面时动画是正常的,但是推出动画却不起作用,目测是 Voyager 本身 pop screen 的机制导致的,composition 没有捕获到退出前的那个 element,因此无法完成动画,这个问题不太容易解决,我试几个方案都不是很好。