Übergänge für gemeinsame Elemente in Compose

Übergänge mit gemeinsamen Elementen sind eine nahtlose Möglichkeit, zwischen Composables zu wechseln, die konsistente Inhalte haben. Sie werden häufig für die Navigation verwendet, sodass Sie verschiedene Bildschirme visuell verbinden können, wenn sich ein Nutzer zwischen ihnen bewegt.

Im folgenden Video sehen Sie beispielsweise, wie das Bild und der Titel des Snacks von der Seite „Eintrag“ auf die Detailseite übernommen werden.

Abbildung 1: Demo für freigegebene Elemente in Jetsnack

In Compose gibt es einige APIs auf oberster 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 stellt einen SharedTransitionScope bereit. Zusammensetzbare Elemente müssen sich in einem SharedTransitionScope befinden, um die Modifikatoren für freigegebene Elemente verwenden zu können.
  • Modifier.sharedElement(): Der Modifikator, der dem SharedTransitionScope das Composeable kennzeichnet, das mit einem anderen Composeable abgeglichen werden soll.
  • Modifier.sharedBounds(): Mit diesem Modifikator wird dem SharedTransitionScope signalisiert, dass die Begrenzungen dieses Composeables als Containerbegrenzungen für den Übergang verwendet werden sollen. Im Gegensatz zu sharedElement() ist sharedBounds() für visuell unterschiedliche Inhalte konzipiert.

Ein wichtiges Konzept beim Erstellen gemeinsamer Elemente in Compose ist die Funktionsweise mit Overlays und Clipping. Weitere Informationen zu diesem wichtigen Thema findest du im Abschnitt Zuschneiden und Overlays.

Grundlegende Verwendung

In diesem Abschnitt wird die folgende Überleitung erstellt, die vom kleineren Listenelement zum größeren Detailelement führt:

Abbildung 2: Einfaches Beispiel für einen Übergang zwischen zwei Composables mit freigegebenem Element.

Modifier.sharedElement() eignet sich am besten in Kombination mit AnimatedContent, AnimatedVisibility oder NavHost, da der Übergang zwischen den Composeables so automatisch für Sie verwaltet wird.

Ausgangspunkt ist eine vorhandene grundlegende AnimatedContent mit einer MainContent und einer DetailsContent, die zusammengesetzt werden kann, bevor freigegebene Elemente hinzugefügt werden:

Abbildung 3: AnimatedContent ohne Übergänge mit gemeinsamen Elementen beginnen.

  1. Wenn Sie die gemeinsamen Elemente zwischen den beiden Layouts animieren möchten, umschließen Sie das AnimatedContent-Element mit SharedTransitionLayout. Die Bereiche von SharedTransitionLayout und AnimatedContent werden an 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üge der Kette mit den Composeable-Modifizierern bei den beiden übereinstimmenden Composeables Modifier.sharedElement() hinzu. Erstellen Sie ein SharedContentState-Objekt und merken Sie sich die ID mit rememberSharedContentState(). Im SharedContentState-Objekt wird der eindeutige Schlüssel gespeichert, anhand dessen die freigegebenen Elemente bestimmt werden. Geben Sie einen eindeutigen Schlüssel zur Identifizierung der Inhalte an und verwenden Sie rememberSharedContentState() für das zu merkende Element. Die AnimatedContentScope wird an den Modifier übergeben, mit dem die Animation koordiniert 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 wissen möchten, ob eine Übereinstimmung mit einem gemeinsamen Element stattgefunden hat, extrahieren Sie rememberSharedContentState() in eine Variable und fragen Sie isMatchFound ab.

Das führt zur folgenden automatischen Animation:

Abbildung 4: Einfaches Beispiel für einen Übergang zwischen zwei Composables mit freigegebenem Element.

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

Gemeinsam genutzte Begrenzungen im Vergleich zu gemeinsam genutzten Elementen

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

  • sharedBounds() ist für visuell unterschiedliche Inhalte gedacht, die in den einzelnen Status aber denselben Bereich abdecken sollen. Bei sharedElement() hingegen müssen die Inhalte identisch sein.
  • Bei sharedBounds() sind die Inhalte, die den Bildschirm betreten und verlassen, während des Übergangs zwischen den beiden Zuständen sichtbar. Bei sharedElement() wird nur der Zielinhalt in den transformierenden Grenzen gerendert. Modifier.sharedBounds() hat die Parameter enter und exit, mit denen angegeben werden kann, wie die Inhalte übergehen sollen. Das funktioniert ähnlich wie bei AnimatedContent.
  • Der häufigste Anwendungsfall für sharedBounds() ist das Container-Transformationsmuster, während für sharedElement() ein Hero-Übergang als Beispiel verwendet wird.
  • Wenn Sie Text-Kompositionen verwenden, wird sharedBounds() bevorzugt, um Schriftartenänderungen wie den Wechsel zwischen Kursiv- und Fettdruck oder Farbänderungen zu unterstützen.

Wenn wir in den beiden verschiedenen Szenarien Modifier.sharedBounds() zu Row und Column hinzufügen, können wir die Grenzen der beiden Elemente teilen und die Übergangsanimation ausführen, sodass sie sich gegenseitig überlagern:

@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 Gemeinsame Begrenzungen zwischen zwei Composeable-Objekten.

Umfang

Wenn du Modifier.sharedElement() verwenden möchtest, muss sich das Element in einer SharedTransitionScope befinden. Die SharedTransitionLayout-Komposition stellt die SharedTransitionScope bereit. Achten Sie darauf, dass Sie die Elemente, die Sie freigeben möchten, an derselben Stelle in der UI-Hierarchie platzieren.

Im Allgemeinen sollten die Composeables auch in einer AnimatedVisibilityScope platziert werden. Das ist in der Regel möglich, wenn Sie mit AnimatedContent zwischen den Komponenten wechseln oder AnimatedVisibility direkt verwenden oder die Funktion NavHost verwenden, 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, wenn Sie mehrere Bereiche im Blick behalten müssen oder eine tief verschachtelte Hierarchie haben. Mit einem CompositionLocal können Sie genau festlegen, welche Bereiche gespeichert und verwendet werden sollen. Wenn Sie hingegen Kontextempfänger verwenden, werden die angegebenen Bereiche möglicherweise versehentlich von anderen Layouts in Ihrer Hierarchie überschrieben. Wenn Sie beispielsweise mehrere verschachtelte AnimatedContent 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
) {
}

Für AnimatedVisibility freigegebene Elemente

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-Layout ist beispielsweise jedes Element in AnimatedVisibility eingehüllt. Wenn auf das Element geklickt wird, entsteht der visuelle Effekt, dass der Inhalt aus der Benutzeroberfläche in eine dialogähnliche Komponente gezogen wird.

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

Abbildung 6: Gemeinsame Elemente mit AnimatedVisibility

Reihenfolge der Modifikatoren

Bei Modifier.sharedElement() und Modifier.sharedBounds() spielt die Reihenfolge der Modifikatoren eine Rolle, genau wie beim Rest von Compose. Die falsche Platzierung von Modifikatoren, die sich auf die Größe auswirken, kann zu unerwarteten visuellen Sprüngen beim Abgleich gemeinsamer Elemente führen.

Wenn Sie beispielsweise einen Padding-Modifikator an einer anderen Position auf zwei gemeinsamen Elementen platzieren, ist das in der Animation visuell zu sehen.

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 Ränder: Die Animation des freigegebenen Elements wirkt etwas seltsam, da sie auf die falschen Ränder skaliert werden muss.

Die Modifikatoren, die vor den Modifikatoren für freigegebene Elemente verwendet werden, legen Einschränkungen für die Modifikatoren für freigegebene Elemente fest. Diese werden dann verwendet, um die Anfangs- und Zielbegrenzungen und anschließend die Begrenzungsanimation abzuleiten.

Die Modifikatoren, die nach den Modifikatoren für gemeinsame Elemente verwendet werden, verwenden die zuvor festgelegten Einschränkungen, um die Zielgröße des untergeordneten Elements zu messen und zu berechnen. Mit den Modifikatoren für freigegebene Elemente werden eine Reihe von animierten Einschränkungen erstellt, um das untergeordnete Element schrittweise von der ursprünglichen Größe in die Zielgröße zu transformieren.

Eine Ausnahme ist, wenn Sie resizeMode = ScaleToBounds() für die Animation oder Modifier.skipToLookaheadSize() für ein Composeable verwenden. In diesem Fall ordnet Compose das untergeordnete Element anhand der Zieleinschränkungen an und verwendet 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 gibt es beispielsweise die folgenden gemeinsamen Elemente:

Abbildung 7. Bild von Jetsnack mit Anmerkungen zu jedem Teil der Benutzeroberfläche

Sie können ein Enum erstellen, um den gemeinsamen Elementtyp darzustellen. In diesem Beispiel kann die gesamte Snackkarte auch an mehreren Stellen auf dem Startbildschirm angezeigt werden, z. B. in den Bereichen „Beliebt“ und „Empfohlen“. Sie können einen Schlüssel mit der snackId, der origin („Beliebt“/„Empfohlen“) und der type des freigegebenen Elements erstellen, das geteilt werden soll:

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

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

Sichtbarkeit freigegebener Elemente manuell verwalten

Wenn Sie AnimatedVisibility oder AnimatedContent nicht verwenden, können Sie die Sichtbarkeit des freigegebenen Elements selbst verwalten. Verwenden Sie Modifier.sharedElementWithCallerManagedVisibility() und geben Sie eine eigene Bedingung an, die festlegt, 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 Einschränkungen

Diese APIs haben einige Einschränkungen. Insbesondere:

  • Die Interoperabilität zwischen Google Tabellen und Google Docs wird nicht unterstützt. Dazu gehören alle Elemente, die AndroidView umschließen, z. B. Dialog.
  • Für Folgendes wird keine automatische Animation unterstützt:
    • Gemeinsam genutzte Bild-Kompositionen:
      • ContentScale ist standardmäßig nicht animiert. Sie wird an das festgelegte Ende ContentScale angedockt.
    • Formen-Clipping: Es gibt keine integrierte Unterstützung für automatische Animationen zwischen Formen, z. B. eine Animation von einem Quadrat zu einem Kreis beim Übergang des Artikels.
    • Verwenden Sie in den nicht unterstützten Fällen Modifier.sharedBounds() anstelle von sharedElement() und fügen Sie den Elementen Modifier.animateEnterExit() hinzu.