在 Voyager 中使用 SharedElement 共享元素动画

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,因此无法完成动画,这个问题不太容易解决,我试几个方案都不是很好。

相关推荐
工程师老罗9 小时前
如何在Android工程中配置NDK版本
android
崔庆才丨静觅9 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606110 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了10 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅10 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅10 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅11 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment11 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅11 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊11 小时前
jwt介绍
前端