Compose 中的共享元素转换

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

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

图 1. Jetsnack 共享元素演示。

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

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

在 Compose 中创建共享元素时,一个重要的概念是它们如何与叠加层和剪裁配合使用。如需详细了解这一重要主题,请参阅剪裁和叠加部分。

基本用法

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

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

使用 Modifier.sharedElement() 的最佳方式是将其与 AnimatedContentAnimatedVisibilityNavHost 结合使用,因为这样可以自动管理可组合项之间的过渡。

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

图 3. 启动 AnimatedContent,不含任何共享元素过渡效果。

  1. 如需让共享元素在两个布局之间添加动画效果,请使用 SharedTransitionLayout 封装 AnimatedContent 可组合项。来自 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() 的用例示例是 Hero 过渡。
  • 使用 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. 两个可组合项之间的共享边界。

了解范围

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

一般来说,可组合项也应放置在 AnimatedVisibilityScope 内。除非您手动管理可见性,否则通常情况下,通过使用 AnimatedContent 在可组合项之间切换或直接使用 AnimatedVisibility,或者通过 NavHost 可组合函数来提供此功能。如需使用多个范围,请将所需范围保存在 CompositionLocal 中,在 Kotlin 中使用上下文接收器,或将范围作为参数传递给函数。

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

val LocalNavAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?> { null }
val LocalSharedTransitionScope = compositionLocalOf<SharedTransitionScope?> { null }

@Composable
private fun SharedElementScope_CompositionLocal() {
    // An example of how to use composition locals to pass around the shared transition scope, far down your UI tree.
    // ...
    SharedTransitionLayout {
        CompositionLocalProvider(
            LocalSharedTransitionScope provides this
        ) {
            // This could also be your top-level NavHost as this provides an AnimatedContentScope
            AnimatedContent(state, label = "Top level AnimatedContent") { targetState ->
                CompositionLocalProvider(LocalNavAnimatedVisibilityScope provides this) {
                    // Now we can access the scopes in any nested composables as follows:
                    val sharedTransitionScope = LocalSharedTransitionScope.current
                        ?: throw IllegalStateException("No SharedElementScope found")
                    val animatedVisibilityScope = LocalNavAnimatedVisibilityScope.current
                        ?: throw IllegalStateException("No AnimatedVisibility found")
                }
                // ...
            }
        }
    }
}

或者,如果您的层次结构嵌套不深,您可以将范围作为参数向下传递:

@Composable
fun MainContent(
    animatedVisibilityScope: AnimatedVisibilityScope,
    sharedTransitionScope: SharedTransitionScope
) {
}

@Composable
fun Details(
    animatedVisibilityScope: AnimatedVisibilityScope,
    sharedTransitionScope: SharedTransitionScope
) {
}

AnimatedVisibility 共享的元素

之前的示例展示了如何将共享元素与 AnimatedContent 搭配使用,但共享元素也可与 AnimatedVisibility 搭配使用。

例如,在此延迟网格示例中,每个元素都封装在 AnimatedVisibility 中。当点击该项时,内容会产生从界面中拉出到类似对话框的组件中的视觉效果。

var selectedSnack by remember { mutableStateOf<Snack?>(null) }

SharedTransitionLayout(modifier = Modifier.fillMaxSize()) {
    LazyColumn(
        // ...
    ) {
        items(listSnacks) { snack ->
            AnimatedVisibility(
                visible = snack != selectedSnack,
                enter = fadeIn() + scaleIn(),
                exit = fadeOut() + scaleOut(),
                modifier = Modifier.animateItem()
            ) {
                Box(
                    modifier = Modifier
                        .sharedBounds(
                            sharedContentState = rememberSharedContentState(key = "${snack.name}-bounds"),
                            // Using the scope provided by AnimatedVisibility
                            animatedVisibilityScope = this,
                            clipInOverlayDuringTransition = OverlayClip(shapeForSharedElement)
                        )
                        .background(Color.White, shapeForSharedElement)
                        .clip(shapeForSharedElement)
                ) {
                    SnackContents(
                        snack = snack,
                        modifier = Modifier.sharedElement(
                            sharedContentState = rememberSharedContentState(key = snack.name),
                            animatedVisibilityScope = this@AnimatedVisibility
                        ),
                        onClick = {
                            selectedSnack = snack
                        }
                    )
                }
            }
        }
    }
    // Contains matching AnimatedContent with sharedBounds modifiers.
    SnackEditDetails(
        snack = selectedSnack,
        onConfirmClick = {
            selectedSnack = null
        }
    )
}

图 6.AnimatedVisibility 共享的元素。

修饰符排序

对于 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 会使用目标约束条件来布局子项,并使用缩放比例来执行动画,而不是更改布局大小本身。

唯一键

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

图 7. 图片显示了 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()

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

如果您可能不使用 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 有一些限制。最值得注意的是:

  • 不支持视图和 Compose 之间的互操作性。这包括封装 AndroidView 的任何可组合项,例如 DialogModalBottomSheet
  • 以下内容不支持自动动画:
    • 共享图片可组合项
      • ContentScale 默认情况下不会显示动画效果。它会吸附到设置的结束时间 ContentScale
    • 形状剪裁 - 没有内置支持在形状之间自动添加动画效果的功能,例如在商品过渡时从正方形动画过渡到圆形。
    • 对于不支持的情况,请使用 Modifier.sharedBounds() 而不是 sharedElement(),并在项上添加 Modifier.animateEnterExit()