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

相关推荐
jerrywus11 分钟前
我用 GPT-6.1-sol 做了台会讲解的 3D 售货机:60 罐饮料,一口没喝上
前端·agent·claude
WythePlus1 小时前
Android 14 SELinux策略深度解析
android·安全策略·selinux策略
孟陬1 小时前
Foobar 技术周刊#159:当看不懂代码时,如何高效排障调试。脏页陷阱:一条只读查询,写入数 TB 数据。推测解码:大模型加速利器
前端·人工智能·后端
Zhao-Jichao2 小时前
AI Prompting for Everyone 课程笔记 - P17 图像生成 Imaging Generation
前端
Highcharts.js2 小时前
智能数据分析组件Orbit 正式发布与快速上手体验教程
前端·人工智能·信息可视化·数据分析系统·highcharts·orbit·智能可视化
螺丝钉tina3 小时前
[Playwright ][16]Playwright + TypeScript API 自动化测试实战:API测试
前端·javascript·typescript
小天源4 小时前
智叟云集系统 · 架构与职责分层
服务器·前端·数据库
俩个逗号。。4 小时前
Kotlin flow 详解
android·开发语言·kotlin
Highcharts.js5 小时前
React 中原生支持使用 JSX 组件配置自定义表格 Highcharts Grid
前端·javascript·react.js·前端框架·jsx·highcharts·自定义表格
薛定谔的猫-菜鸟程序员6 小时前
Electron开发超详细教程(从入门到放弃)
前端·javascript·electron