Übergänge für freigegebene Elemente in der compose-Ansicht

Übergänge für freigegebene Elemente sind eine nahtlose Möglichkeit, zwischen Composables zu wechseln, deren Inhalte übereinstimmen. Sie werden häufig für die Navigation verwendet, sodass Sie verschiedene Bildschirme visuell miteinander verbinden können, während ein Nutzer zwischen ihnen wechselt.

Im folgenden Video sehen Sie beispielsweise, dass das Bild und der Titel des Snacks von der Listenseite auf die Detailseite übertragen werden.

Abbildung 1 Demo für freigegebene Elemente in Jetsnack

In Compose gibt es einige APIs auf hoher Ebene, mit denen Sie freigegebene Elemente erstellen können:

  • SharedTransitionLayout: Das äußerste Layout, das zum Implementieren von Übergängen für freigegebene Elemente erforderlich ist. Es bietet einen SharedTransitionScope. Composables müssen sich in einem SharedTransitionScope befinden, um die Modifizierer für freigegebene Elemente verwenden zu können.
  • Modifier.sharedElement(): Der Modifizierer, der dem SharedTransitionScope das Composable signalisiert, das mit einem anderen Composable abgeglichen werden soll.
  • Modifier.sharedBounds(): Der Modifizierer, der dem SharedTransitionScope signalisiert, dass die Grenzen dieses Composables als Containergrenzen für den Übergang verwendet werden sollen. Im Gegensatz zu sharedElement() ist sharedBounds() für visuell unterschiedliche Inhalte konzipiert.

Ein wichtiges Konzept beim Erstellen freigegebener Elemente in Compose ist, wie sie mit Überlagerungen und Clipping funktionieren. Weitere Informationen zu diesem wichtigen Thema finden Sie im Abschnitt Clipping und Überlagerungen.

Grundlegende Nutzung

In diesem Abschnitt wird der folgende Übergang erstellt, bei dem von einem kleineren Listenelement zu einem größeren detaillierten Element gewechselt wird:

Abbildung 2. Grundlegendes Beispiel für einen Übergang für freigegebene Elemente zwischen zwei Composables.

Modifier.sharedElement() wird am besten in Verbindung mit AnimatedContent, AnimatedVisibility oder NavHost verwendet, da dadurch der Übergang zwischen Composables automatisch verwaltet wird.

Ausgangspunkt ist ein vorhandenes einfaches AnimatedContent mit einem MainContent und einem DetailsContent Composable, bevor freigegebene Elemente hinzugefügt werden:

Abbildung 3. AnimatedContent ohne Übergänge für freigegebene Elemente.

  1. Damit die freigegebenen Elemente zwischen den beiden Layouts animiert werden, umschließen Sie das AnimatedContent Composable mit SharedTransitionLayout. Die Bereiche von SharedTransitionLayout und AnimatedContent werden an die MainContent und DetailsContent übergeben:

    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. Fügen Sie Modifier.sharedElement() der Modifikator-Kette Ihres Composables für die beiden übereinstimmenden Composables hinzu. Erstellen Sie ein SharedContentState-Objekt und speichern Sie es mit rememberSharedContentState(). Das SharedContentState-Objekt speichert den eindeutigen Schlüssel, der die freigegebenen Elemente bestimmt. Geben Sie einen eindeutigen Schlüssel an, um den Inhalt zu identifizieren, und verwenden Sie rememberSharedContentState(), damit das Element gespeichert wird. Der AnimatedContentScope wird an den Modifizierer übergeben, der zum Koordinieren der Animation verwendet wird.

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

Wenn Sie Informationen dazu erhalten möchten, ob eine Übereinstimmung für freigegebene Elemente gefunden wurde, extrahieren Sie rememberSharedContentState() in eine Variable und fragen Sie isMatchFound ab.

Das führt zu der folgenden automatischen Animation:

Abbildung 4. Grundlegendes Beispiel für einen Übergang für freigegebene Elemente zwischen zwei Composables.

Möglicherweise stellen Sie fest, dass die Hintergrundfarbe und Größe des gesamten Containers weiterhin die Standardeinstellungen von AnimatedContent verwendet.

Freigegebene Grenzen im Vergleich zu freigegebenen Elementen

Modifier.sharedBounds() ähnelt Modifier.sharedElement(). Die Modifizierer unterscheiden sich jedoch in folgenden Punkten:

  • sharedBounds() ist für Inhalte vorgesehen, die visuell unterschiedlich sind, aber zwischen den Zuständen denselben Bereich verwenden sollen. Bei sharedElement() müssen die Inhalte identisch sein.
  • Bei sharedBounds() sind die Inhalte, die auf dem Bildschirm ein- und ausgeblendet werden, während des Übergangs zwischen den beiden Zuständen sichtbar. Bei sharedElement() wird nur der Zielinhalt in den sich verändernden Grenzen gerendert. Modifier.sharedBounds() hat die Parameter enter und exit, mit denen Sie angeben können, wie der Übergang der Inhalte erfolgen soll, ähnlich wie bei AnimatedContent.
  • Der häufigste Anwendungsfall für sharedBounds() ist das Container-Übergangsmuster, während für sharedElement() der Anwendungsfall ein Hero-Übergang ist.
  • Bei Verwendung von Text-Composables wird sharedBounds() bevorzugt, um Schriftartänderungen wie den Übergang zwischen Kursiv und Fett oder Farbänderungen zu unterstützen.

Wenn wir im vorherigen Beispiel Modifier.sharedBounds() zu Row und Column in den beiden verschiedenen Szenarien hinzufügen, können wir die Grenzen der beiden Elemente freigeben und die Übergangsanimation ausführen, sodass sie zwischen den beiden Elementen wachsen können:

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

        ) {
            // ...
        }
    }
}

Abbildung 5 Freigegebene Grenzen zwischen zwei Composables.

Umfang

Damit Sie Modifier.sharedElement() verwenden können, muss sich das Composable in einem SharedTransitionScope befinden. Das SharedTransitionLayout Composable bietet den SharedTransitionScope. Platzieren Sie es an derselben Stelle auf oberster Ebene in Ihrer UI-Hierarchie, die die Elemente enthält, die Sie freigeben möchten.

Im Allgemeinen sollten die Composables auch in einem AnimatedVisibilityScope platziert werden. Dieser wird in der Regel durch die Verwendung von AnimatedContent zum Wechseln zwischen Composables oder durch die direkte Verwendung von AnimatedVisibility oder durch die NavHost-Composable-Funktion bereitgestellt, es sei denn, Sie verwalten die Sichtbarkeit manuell. Wenn Sie mehrere Bereiche verwenden möchten, speichern Sie die erforderlichen Bereiche in einem CompositionLocal, verwenden Sie Kontextempfänger in Kotlin oder übergeben Sie die Bereiche als Parameter an Ihre Funktionen.

Verwenden Sie CompositionLocals in Szenarien mit mehreren Bereichen oder einer tief verschachtelten Hierarchie. Mit CompositionLocal können Sie die genauen Bereiche auswählen, die gespeichert und verwendet werden sollen. Wenn Sie hingegen Kontextempfänger verwenden, können andere Layouts in Ihrer Hierarchie die bereitgestellten Bereiche versehentlich überschreiben. Wenn Sie beispielsweise mehrere verschachtelte AnimatedContent-Elemente haben, können die Bereiche überschrieben werden.

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

Wenn Ihre Hierarchie nicht tief verschachtelt ist, können Sie die Bereiche auch als Parameter übergeben:

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

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

Freigegebene Elemente mit AnimatedVisibility

In den vorherigen Beispielen wurde gezeigt, wie freigegebene Elemente mit AnimatedContent verwendet werden. Sie funktionieren aber auch mit AnimatedVisibility.

In diesem Beispiel für ein Lazy Grid ist jedes Element in AnimatedVisibility eingebettet. Wenn auf das Element geklickt wird, wird der Inhalt visuell aus der UI in eine dialogähnliche Komponente gezogen.

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

Abbildung 6 Freigegebene Elemente mit AnimatedVisibility.

Reihenfolge der Modifizierer

Bei Modifier.sharedElement() und Modifier.sharedBounds() ist die Reihenfolge Ihrer Modifiziererkette wichtig, wie bei den übrigen Compose-Elementen. Die falsche Platzierung von Modifizierern, die die Größe beeinflussen, kann bei der Übereinstimmung von freigegebenen Elementen zu unerwarteten visuellen Sprüngen führen.

Wenn Sie beispielsweise einen Padding-Modifizierer an einer anderen Position für zwei freigegebene Elemente platzieren, gibt es einen visuellen Unterschied in der Animation.

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

Übereinstimmende Grenzen

Nicht übereinstimmende Grenzen: Die Animation für freigegebene Elemente sieht etwas falsch aus, da die Größe an die falschen Grenzen angepasst werden muss.

Die Modifizierer, die vor den Modifizierern für freigegebene Elemente verwendet werden, legen Einschränkungen für die Modifizierer für freigegebene Elemente fest. Diese werden dann verwendet, um die anfänglichen und Zielgrenzen und anschließend die Animation der Grenzen abzuleiten.

Die Modifizierer, die nach den Modifizierern für freigegebene Elemente verwendet werden, verwenden die vorherigen Einschränkungen, um die Zielgröße des untergeordneten Elements zu messen und zu berechnen. Die Modifizierer für freigegebene Elemente erstellen eine Reihe animierter Einschränkungen, um das untergeordnete Element schrittweise von der ursprünglichen Größe in die Zielgröße zu transformieren.

Eine Ausnahme hiervon ist, wenn Sie resizeMode = ScaleToBounds() für die Animation oder Modifier.skipToLookaheadSize() für ein Composable verwenden. In diesem Fall legt Compose das untergeordnete Element mit den Zielbeschränkungen fest und verwendet stattdessen einen Skalierungsfaktor, um die Animation auszuführen, anstatt die Layoutgröße selbst zu ändern.

Eindeutige Schlüssel

Bei der Arbeit mit komplexen freigegebenen Elementen empfiehlt es sich, einen Schlüssel zu erstellen, der kein String ist, da Strings fehleranfällig sein können. Jeder Schlüssel muss eindeutig sein, damit Übereinstimmungen gefunden werden können. In Jetsnack haben wir beispielsweise die folgenden freigegebenen Elemente:

Abbildung 7. Bild von Jetsnack mit Anmerkungen für jeden Teil der UI.

Sie können ein Enum erstellen, um den Typ des freigegebenen Elements darzustellen. In diesem Beispiel kann die gesamte Snack-Karte auch an mehreren verschiedenen Stellen auf dem Startbildschirm angezeigt werden, z. B. in den Abschnitten „Beliebt“ und „Empfohlen“. Sie können einen Schlüssel erstellen, der die snackId, den origin („Beliebt“ / „Empfohlen“) und den type des freigegebenen Elements enthält:

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

Für Schlüssel werden Datenklassen empfohlen, da sie hashCode() und isEquals() implementieren.

Sichtbarkeit freigegebener Elemente manuell verwalten

Wenn Sie AnimatedVisibility oder AnimatedContent nicht verwenden, können Sie die Sichtbarkeit freigegebener Elemente selbst verwalten. Verwenden Sie Modifier.sharedElementWithCallerManagedVisibility() und geben Sie eine eigene Bedingung an, die bestimmt, wann ein Element sichtbar sein soll:

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

Aktuelle Beschränkungen

Diese APIs haben einige Einschränkungen. Die wichtigsten sind:

  • Es wird keine Interoperabilität zwischen Views und Compose unterstützt. Dazu gehören alle Composables, die AndroidView umschließen, z. B. ein Dialog oder ModalBottomSheet.
  • Für Folgendes wird keine automatische Animation unterstützt:
    • Composables für freigegebene Bilder:
      • ContentScale wird standardmäßig nicht animiert. Es wird auf die festgelegte End-ContentScale gesetzt.
    • Form-Clipping : Es gibt keine integrierte Unterstützung für die automatische Animation zwischen Formen, z. B. für die Animation von einem Quadrat zu einem Kreis, wenn das Element übergeht.
    • Verwenden Sie in den nicht unterstützten Fällen Modifier.sharedBounds() anstelle von sharedElement() und fügen Sie den Elementen Modifier.animateEnterExit() hinzu.