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

相关推荐
林太白10 分钟前
CommonJS和ES Modules篇
前端·面试
李明卫杭州20 分钟前
响应式图片加载:srcset 和 sizes 的结合使用
前端
Running_C21 分钟前
HTTP 断点续传与大文件上传,现在面试必问吧
前端·面试
大前端helloworld22 分钟前
记录从离职到找工作这段时间的故事
前端
麦田里的守望者江25 分钟前
KMP & CMP 开发桌面 App - 构建
前端
之梦29 分钟前
Electron + Vue3开源跨平台壁纸工具实战(七)进程通信
前端·electron
之梦32 分钟前
Electron + Vue3开源跨平台壁纸工具实战(八)主进程-核心功能
前端·electron
我叫黑大帅34 分钟前
从刷不到底的朋友圈说起:手把手教你搞懂 "下拉加载更多"
前端·javascript
前端服务区35 分钟前
Map与WeakMap
前端·javascript
用户38022585982436 分钟前
vue3源码解析:编译之编译器代码生成过程
前端·vue.js·源码阅读