Compose 中的共享元素过渡

共享元素过渡是一种在内容之间具有一致的可组合项之间的无缝过渡方式。它们通常用于导航,当用户在这些屏幕之间导航时,您可以直观地将不同的屏幕连接起来。

例如,在以下视频中,您可以看到零食的图片和标题从商品详情页面分享到详情页面。

图 1.Jetsnack 共享元素演示

在 Compose 中,有几个高级 API 可帮助您创建共享元素:

  • SharedTransitionLayout:实现共享元素转换所需的最外层布局。它提供了 SharedTransitionScope。可组合项需要位于 SharedTransitionScope 中才能使用共享元素修饰符。
  • Modifier.sharedElement():向 SharedTransitionScope 标记应与另一个可组合项匹配的可组合项的修饰符。
  • Modifier.sharedBounds():此修饰符用于向 SharedTransitionScope 标记,指明此可组合项的边界应用作应发生过渡的容器边界。与 sharedElement() 相比,sharedBounds() 是针对外观不同的内容设计的。

在 Compose 中创建共享元素时,一个重要概念就是它们如何处理叠加层和裁剪。请查看剪裁和叠加层部分,详细了解这一重要主题。

基本用法

本部分将构建以下过渡,从较小的“列表”项转换到较大的详细项:

图 2.两个可组合项之间的共享元素过渡的基本示例。

使用 Modifier.sharedElement() 的最佳方式是与 AnimatedContentAnimatedVisibility 结合使用,因为这会自动为您管理可组合项之间的转换。

起点是现有的基本 AnimatedContent,它在添加共享元素之前具有 MainContentDetailsContent 可组合项:

图 3.AnimatedContent 开头,没有任何共享元素转换。

  1. 为了让共享元素在两个布局之间添加动画效果,请使用 SharedTransitionLayoutAnimatedContent 可组合项括起来。SharedTransitionLayoutAnimatedContent 中的作用域会传递到 MainContentDetailsContent

    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
                )
            }
        }
    }

  2. Modifier.sharedElement() 添加到匹配的两个可组合项的可组合项修饰符链。创建一个 SharedContentState 对象,并使用 rememberSharedContentState() 记住它。SharedContentState 对象会存储唯一键,该键决定了共享的元素。请提供用于标识内容的唯一键,并使用 rememberSharedContentState() 让系统记住相应项。AnimatedContentScope 会传递到用于协调动画的修饰符中。

    @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
                )
                // ...
            }
        }
    }

如需了解是否发生了共享元素匹配,请将 rememberSharedContentState() 提取到变量中,并查询 isMatchFound

这会产生以下自动动画:

图 4.两个可组合项之间的共享元素过渡的基本示例。

您可能会注意到,整个容器的背景颜色和大小仍然使用默认的 AnimatedContent 设置。

共享边界与共享元素

Modifier.sharedBounds() 类似于 Modifier.sharedElement()。不过,这些修饰符在以下几个方面有所不同:

  • sharedBounds() 适用于视觉上不同但在不同状态之间应共用相同区域的内容,而 sharedElement() 要求内容相同。
  • 使用 sharedBounds() 时,进入和退出屏幕的内容在两种状态之间的转换期间可见;而使用 sharedElement() 时,只有目标内容在转换边界中呈现。Modifier.sharedBounds() 具有 enterexit 参数,用于指定内容应如何过渡,类似于 AnimatedContent 的运作方式。
  • sharedBounds() 最常见的用例是容器转换模式,而 sharedElement() 最常见的用例是主角过渡。
  • 使用 Text 可组合项时,最好使用 sharedBounds() 来支持字体更改,例如在斜体和粗体之间转换或颜色更改。

在前面的示例中,在两种不同场景中,将 Modifier.sharedBounds() 添加到 RowColumn 可让我们共享两者的边界并执行过渡动画,从而使其在彼此之间扩展:

@Composable
private fun MainContent(
    onShowDetails: () -> Unit,
    modifier: Modifier = Modifier,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope
) {
    with(sharedTransitionScope) {
        Row(
            modifier = Modifier
                .padding(8.dp)
                .sharedBounds(
                    rememberSharedContentState(key = "bounds"),
                    animatedVisibilityScope = animatedVisibilityScope,
                    enter = fadeIn(),
                    exit = fadeOut(),
                    resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds()
                )
                // ...
        ) {
            // ...
        }
    }
}

@Composable
private fun DetailsContent(
    modifier: Modifier = Modifier,
    onBack: () -> Unit,
    sharedTransitionScope: SharedTransitionScope,
    animatedVisibilityScope: AnimatedVisibilityScope
) {
    with(sharedTransitionScope) {
        Column(
            modifier = Modifier
                .padding(top = 200.dp, start = 16.dp, end = 16.dp)
                .sharedBounds(
                    rememberSharedContentState(key = "bounds"),
                    animatedVisibilityScope = animatedVisibilityScope,
                    enter = fadeIn(),
                    exit = fadeOut(),
                    resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds()
                )
                // ...

        ) {
            // ...
        }
    }
}

图 5.两个可组合项之间的共享边界。

唯一键

使用复杂的共享元素时,最好创建非字符串的键,因为字符串在匹配时很容易出错。每个键都必须具有唯一性,才能进行匹配。例如,在 Jetsnack 中,我们具有以下共享元素:

图 6.显示 Jetsnack 以及界面每个部分的注解的图片。

您可以创建一个枚举来表示共享元素类型。在此示例中,整个零食卡片也可以从主屏幕上的多个不同位置显示,例如在“热门”和“推荐”版块中显示。您可以创建一个键,其中包含要共享的共享元素的 snackIdorigin(“热门”/“推荐”)和 type

data class SnackSharedElementKey(
    val snackId: Long,
    val origin: String,
    val type: SnackSharedElementType
)

enum class SnackSharedElementType {
    Bounds,
    Image,
    Title,
    Tagline,
    Background
}

@Composable
fun SharedElementUniqueKey() {
    // ...
            Box(
                modifier = Modifier
                    .sharedElement(
                        rememberSharedContentState(
                            key = SnackSharedElementKey(
                                snackId = 1,
                                origin = "latest",
                                type = SnackSharedElementType.Image
                            )
                        ),
                        animatedVisibilityScope = this@AnimatedVisibility
                    )
            )
            // ...
}

建议为键使用数据类,因为它们实现了 hashCode()isEquals()

了解范围

如需使用 Modifier.sharedElement(),可组合项必须位于 SharedTransitionScope 中。SharedTransitionLayout 可组合项提供 SharedTransitionScope。请务必将这些元素放置在界面层次结构中的同一顶层位置,其中包含要共享的元素。

通常,可组合项也应放置在 AnimatedVisibilityScope 内。除非手动管理可见性,否则通常通过以下方式提供:使用 AnimatedContent 在可组合项之间切换,或直接使用 AnimatedVisibility。为了使用多个作用域,您可能需要将所需的作用域保存在 CompositionLocal 中,使用 Kotlin 中的上下文接收器,或者将作用域作为参数传递给函数。

如果您要跟踪多个范围,或具有深层嵌套的层次结构,请使用 CompositionLocals。借助 CompositionLocal,您可以选择要保存和使用的确切范围。另一方面,当您使用上下文接收器时,层次结构中的其他布局可能会意外替换提供的范围。例如,如果您有多个嵌套的 AnimatedContent,则范围可能会被覆盖。

修饰符排序

使用 Modifier.sharedElement()Modifier.sharedBounds() 时,修饰符链的顺序很重要,与 Compose 的其余部分一样。在共享元素匹配期间,错误放置影响尺寸的修饰符可能会导致视觉上出现意外的跳跃。

例如,如果您在两个共享元素的不同位置放置内边距修饰符,动画便会产生视觉差异。

var selectFirst by remember { mutableStateOf(true) }
val key = remember { Any() }
SharedTransitionLayout(
    Modifier
        .fillMaxSize()
        .padding(10.dp)
        .clickable {
            selectFirst = !selectFirst
        }
) {
    AnimatedContent(targetState = selectFirst, label = "AnimatedContent") { targetState ->
        if (targetState) {
            Box(
                Modifier
                    .padding(12.dp)
                    .sharedBounds(
                        rememberSharedContentState(key = key),
                        animatedVisibilityScope = this@AnimatedContent
                    )
                    .border(2.dp, Color.Red)
            ) {
                Text(
                    "Hello",
                    fontSize = 20.sp
                )
            }
        } else {
            Box(
                Modifier
                    .offset(180.dp, 180.dp)
                    .sharedBounds(
                        rememberSharedContentState(
                            key = key,
                        ),
                        animatedVisibilityScope = this@AnimatedContent
                    )
                    .border(2.dp, Color.Red)
                    // This padding is placed after sharedBounds, but it doesn't match the
                    // other shared elements modifier order, resulting in visual jumps
                    .padding(12.dp)

            ) {
                Text(
                    "Hello",
                    fontSize = 36.sp
                )
            }
        }
    }
}

匹配的边界

不匹配的边界:请注意,由于需要将大小调整到不正确的边界,共享元素动画的显示效果有点偏离

共享元素修饰符之前使用的修饰符会对共享元素修饰符提供约束条件,后者随后用于派生初始边界和目标边界,随后用于派生边界动画。

共享元素修饰符之后使用的修饰符会使用之前的约束条件来测量和计算子项的目标大小。共享元素修饰符会创建一系列动画约束条件,将子元素从初始尺寸逐步转换为目标尺寸。

但是,如果您对动画使用 resizeMode = ScaleToBounds(),或在可组合项上使用 Modifier.skipToLookaheadSize(),则例外情况是。在这种情况下,Compose 使用目标约束条件对子项进行布局,并改用缩放比例来执行动画,而不是更改布局大小本身。

手动管理共享元素的可见性

如果您未使用 AnimatedVisibilityAnimatedContent,则可以自行管理共享元素的可见性。请使用 Modifier.sharedElementWithCallerManagedVisibility() 并提供您自己的条件,以确定应在何时显示某个项:

var selectFirst by remember { mutableStateOf(true) }
val key = remember { Any() }
SharedTransitionLayout(
    Modifier
        .fillMaxSize()
        .padding(10.dp)
        .clickable {
            selectFirst = !selectFirst
        }
) {
    Box(
        Modifier
            .sharedElementWithCallerManagedVisibility(
                rememberSharedContentState(key = key),
                !selectFirst
            )
            .background(Color.Red)
            .size(100.dp)
    ) {
        Text(if (!selectFirst) "false" else "true", color = Color.White)
    }
    Box(
        Modifier
            .offset(180.dp, 180.dp)
            .sharedElementWithCallerManagedVisibility(
                rememberSharedContentState(
                    key = key,
                ),
                selectFirst
            )
            .alpha(0.5f)
            .background(Color.Blue)
            .size(180.dp)
    ) {
        Text(if (selectFirst) "false" else "true", color = Color.White)
    }
}

当前的限制

这些 API 存在一些限制。最值得注意的是:

  • 不支持 View 与 Compose 之间的互操作性。
  • 以下各项不支持自动动画:
    • 共享图片可组合项
      • 默认情况下,ContentScale 没有动画效果。它会贴靠到设定的终点 ContentScale
    • 形状裁剪 - 没有内置支持,在形状之间自动创建动画,例如,在项过渡时将动画从正方形转换为圆形。
    • 对于不受支持的情况,请使用 Modifier.sharedBounds() 而不是 sharedElement(),并在列表项中添加 Modifier.animateEnterExit()