Compose 中的共享元素过渡

共享元素过渡是在可组合项之间无缝过渡的方式 内容具有一致性的内容。它们通常用于 因此,您可以直观地将不同屏幕作为用户 可在它们之间导航

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

<ph type="x-smartling-placeholder">
图 1.Jetsnack 共享元素演示

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

  • SharedTransitionLayout:实现共享屏幕所需的最外层布局 元素过渡。它提供了一个 SharedTransitionScope。可组合项需要 位于 SharedTransitionScope 中,以使用共享元素修饰符。
  • Modifier.sharedElement():标记 SharedTransitionScope 应该与另一个 可组合项。
  • Modifier.sharedBounds():标记 SharedTransitionScope:此可组合项的边界应用作 用于指定转换应发生的位置的容器边界。与此相反, sharedElement()sharedBounds() 专为在视觉上有所不同 内容。

在 Compose 中创建共享元素时的一个重要概念是共享元素的运作方式 还有叠加和剪辑功能看一看剪辑和 叠加层 部分,详细了解此重要主题。

基本用法

我们将在本部分中进行以下过渡,即从 缩小“列表”更改为较大的明细项:

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

使用 Modifier.sharedElement() 的最佳方式是与 AnimatedContentAnimatedVisibilityNavHost,因为负责管理 自动在可组合项之间的过渡。

起点是现有的基本 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() 添加到 Row 上, Column 在两种不同场景中将使我们能够共享 然后执行过渡动画,让它们变大 相互通信:

@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(),可组合项需位于 SharedTransitionScopeSharedTransitionLayout 可组合项提供 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(
                            state = 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 存在一些限制。最值得注意的是:

  • View 和 Compose 之间不支持互操作性。这包括 任何封装 AndroidView 的可组合项,例如 Dialog
  • 以下各项不支持自动动画:
    • 共享 Image 可组合项
      • 默认情况下,ContentScale 没有动画效果。贴靠到设定的终点 ContentScale
    • 形状裁剪 - 没有对自动 不同形状之间的动画 - 例如,以动画形式呈现从正方形到 将对象转换为圆形
    • 对于不受支持的情况,请使用 Modifier.sharedBounds(),而不是 sharedElement() 并将 Modifier.animateEnterExit() 添加到项上。