Übergänge für gemeinsame Elemente in Compose

Übergänge für gemeinsame Elemente sind eine nahtlose Möglichkeit für einen nahtlosen Übergang zwischen zusammensetzbaren Funktionen, deren Inhalt einheitlich ist. Sie werden häufig zur Navigation verwendet und ermöglichen es Ihnen, verschiedene Bildschirme visuell zu verbinden, während Nutzer zwischen ihnen wechseln.

Im folgenden Video sehen Sie beispielsweise, wie das Bild und der Titel des Snacks von der Eintragsseite auf die Detailseite geteilt werden.

Abbildung 1. Demo zu gemeinsam genutzten Jetsnack-Elementen

In Compose gibt es einige übergeordnete APIs, mit denen Sie gemeinsame Elemente erstellen können:

  • SharedTransitionLayout: Das äußerste Layout, das für die Implementierung von Übergängen gemeinsam genutzter Elemente erforderlich ist. Sie stellt ein SharedTransitionScope bereit. Zusammensetzbare Funktionen müssen sich in einem SharedTransitionScope befinden, damit die gemeinsamen Elementmodifikatoren verwendet werden können.
  • Modifier.sharedElement(): Der Modifikator, der der SharedTransitionScope die zusammensetzbare Funktion meldet, die mit einer anderen zusammensetzbaren Funktion abgeglichen werden soll.
  • Modifier.sharedBounds(): Der Modifikator, der an die SharedTransitionScope meldet, dass die Grenzen dieser zusammensetzbaren Funktion als Containergrenzen für den Übergang verwendet werden sollen. Im Gegensatz zu sharedElement() ist sharedBounds() für optisch andere Inhalte konzipiert.

Ein wichtiges Konzept beim Erstellen gemeinsam genutzter Elemente in Compose ist, wie sie mit Overlays und Beschnitten funktionieren. Weitere Informationen zu diesem wichtigen Thema finden Sie im Abschnitt Clipping und Overlays.

Grundlegende Verwendung

In diesem Abschnitt wird der folgende Übergang vom kleineren Listenelement zum größeren detaillierten Element erstellt:

Abbildung 2: Einfaches Beispiel für den Übergang eines gemeinsamen Elements zwischen zwei zusammensetzbaren Funktionen.

Am besten verwenden Sie Modifier.sharedElement() in Verbindung mit AnimatedContent, AnimatedVisibility oder NavHost, da der Übergang zwischen zusammensetzbaren Funktionen automatisch für Sie verwaltet wird.

Der Ausgangspunkt ist eine vorhandene einfache AnimatedContent mit einer zusammensetzbaren Funktion aus MainContent und DetailsContent, bevor gemeinsam genutzte Elemente hinzugefügt werden:

Abbildung 3: AnimatedContent wird ohne Übergänge für gemeinsame Elemente gestartet.

  1. Damit die gemeinsam genutzten Elemente in den beiden Layouts animiert werden, umschließen Sie die zusammensetzbare Funktion AnimatedContent mit SharedTransitionLayout. Die Bereiche aus 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ügen Sie der Kette der zusammensetzbaren Modifikatoren Modifier.sharedElement() bei den beiden übereinstimmenden zusammensetzbaren Funktionen hinzu. Erstellen Sie ein SharedContentState-Objekt und speichern Sie es mit rememberSharedContentState(). Das Objekt SharedContentState speichert den eindeutigen Schlüssel, der bestimmt, welche Elemente freigegeben werden. Geben Sie einen eindeutigen Schlüssel zum Identifizieren des Inhalts an und verwenden Sie rememberSharedContentState(), damit das Element gespeichert wird. Die AnimatedContentScope wird an den Modifikator übergeben, der für die Koordinierung 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 erfahren möchten, ob eine Übereinstimmung mit einem gemeinsamen Element aufgetreten ist, extrahieren Sie rememberSharedContentState() in eine Variable und fragen Sie isMatchFound ab.

Dies führt zu der folgenden automatischen Animation:

Abbildung 4: Einfaches Beispiel für den Übergang eines gemeinsamen Elements zwischen zwei zusammensetzbaren Funktionen.

Für die Hintergrundfarbe und -größe des gesamten Containers werden weiterhin die AnimatedContent-Standardeinstellungen verwendet.

Gemeinsame Grenzen und gemeinsames Element

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

  • sharedBounds() ist für Inhalte vorgesehen, die sich optisch unterscheiden, aber denselben Bereich zwischen den Bundesstaaten teilen sollten. sharedElement() erwartet dagegen, dass der Inhalt identisch ist.
  • Mit sharedBounds() ist der Inhalt, der den Bildschirm erreicht und verlässt, während des Übergangs zwischen den beiden Zuständen sichtbar, während bei sharedElement() nur der Zielinhalt innerhalb der sich transformierenden Grenzen gerendert wird. Modifier.sharedBounds() hat die Parameter enter und exit, mit denen angegeben wird, wie der Inhalt übergehen soll, ähnlich wie bei AnimatedContent.
  • Der häufigste Anwendungsfall für sharedBounds() ist das Container-Transformationsmuster. Für sharedElement() ist der Anwendungsfall ein Hero-Übergang.
  • Bei der Verwendung von zusammensetzbaren Funktionen von Text wird sharedBounds() bevorzugt, um Schriftänderungen wie den Wechsel zwischen Kursiv- und Fettdruck 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 Szenarien teilen und die Übergangsanimation ausführen, sodass die beiden Szenarien größer werden:

@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 Grenzen zwischen zwei zusammensetzbaren Funktionen.

Informationen zu Bereichen

Wenn Sie Modifier.sharedElement() verwenden möchten, muss sich die zusammensetzbare Funktion in einer SharedTransitionScope befinden. Die zusammensetzbare Funktion SharedTransitionLayout stellt die SharedTransitionScope bereit. Achten Sie darauf, in Ihrer UI-Hierarchie den obersten Punkt der obersten Ebene mit den Elementen zu platzieren, die Sie teilen möchten.

Im Allgemeinen sollten die zusammensetzbaren Funktionen auch innerhalb eines AnimatedVisibilityScope platziert werden. Dazu verwenden Sie normalerweise AnimatedContent, um zwischen zusammensetzbaren Funktionen zu wechseln, oder wenn Sie AnimatedVisibility direkt verwenden. Alternativ können Sie auch die zusammensetzbare Funktion NavHost verwenden, sofern Sie die Sichtbarkeit nicht manuell verwalten. 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 verfolgen möchten oder eine tief verschachtelte Hierarchie haben. Mit CompositionLocal können Sie genau die Bereiche auswählen, die gespeichert und verwendet werden sollen. Wenn Sie jedoch Kontextempfänger verwenden, könnten andere Layouts in Ihrer Hierarchie die angegebenen Bereiche versehentlich überschreiben. 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
) {
}

Gemeinsame Elemente mit AnimatedVisibility

In den vorherigen Beispielen wurde gezeigt, wie gemeinsam genutzte Elemente mit AnimatedContent verwendet werden. Freigegebene Elemente funktionieren aber auch mit AnimatedVisibility.

In diesem Beispiel mit Lazy Grid wird jedes Element von AnimatedVisibility umschlossen. Wenn auf das Element geklickt wird, hat der Inhalt den visuellen Effekt, dass er aus der UI in eine dialogorientierte 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() ist wie beim Rest von „Compose“ die Reihenfolge der Modifikatorkette wichtig. Die falsche Platzierung von Modifikatoren, die die Größe beeinflussen, kann zu unerwarteten visuellen Sprüngen beim Abgleich geteilter Elemente führen.

Wenn Sie beispielsweise einen Padding-Modifikator an einer anderen Position auf zwei gemeinsam genutzten Elementen 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 der gemeinsam genutzten Elemente erscheint etwas ungenau, da die Größe an die falschen Grenzen angepasst werden muss.

Die Modifikatoren, die vor den Modifikatoren gemeinsam verwendeter Elemente verwendet werden, bieten Einschränkungen für die Modifikatoren für gemeinsam genutzte Elemente. Diese werden dann verwendet, um die Anfangs- und Zielgrenzen und anschließend die Begrenzungsanimationen abzuleiten.

Die Modifikatoren, die nach den Modifikatoren des gemeinsam genutzten Elements verwendet werden, verwenden die zuvor festgelegten Einschränkungen, um die Zielgröße des untergeordneten Elements zu messen und zu berechnen. Die gemeinsam genutzten Elementmodifikatoren erstellen eine Reihe animierter Einschränkungen, um das untergeordnete Element nach und nach von der ursprünglichen Größe in die Zielgröße umzuwandeln.

Die Ausnahme hiervon ist, wenn Sie resizeMode = ScaleToBounds() für die Animation oder Modifier.skipToLookaheadSize() für eine zusammensetzbare Funktion verwenden. In diesem Fall legt Compose das untergeordnete Element anhand der Zieleinschränkungen fest und verwendet stattdessen einen Skalierungsfaktor zur Durchführung der Animation, anstatt die Layoutgröße selbst zu ändern.

Eindeutige Schlüssel

Wenn Sie mit komplexen gemeinsam genutzten Elementen arbeiten, 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. In Jetsnack gibt es beispielsweise die folgenden gemeinsam genutzten Elemente:

Abbildung 7: Bild von Jetsnack mit Anmerkungen für jeden Teil der Benutzeroberfläche

Sie könnten eine Enum erstellen, um den gemeinsamen Elementtyp darzustellen. In diesem Beispiel kann die gesamte Snackkarte auch an verschiedenen Stellen auf dem Startbildschirm angezeigt werden, z. B. in den Abschnitten „Beliebt“ und „Empfohlen“. Sie können einen Schlüssel mit dem snackId, dem origin („Beliebt“/„Empfohlen“) und dem type des gemeinsam genutzten Elements erstellen:

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 gemeinsam verwendeter Elemente manuell verwalten

Falls Sie AnimatedVisibility oder AnimatedContent nicht verwenden, können Sie die Sichtbarkeit der gemeinsam genutzten Elemente selbst festlegen. Verwenden Sie Modifier.sharedElementWithCallerManagedVisibility() und geben Sie Ihre eigene Bedingung an, die bestimmt, wann ein Element sichtbar ist oder nicht:

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

Für diese APIs gelten einige Einschränkungen. Vor allem:

  • Es wird keine Interoperabilität zwischen „Views“ und „Compose“ unterstützt. Dies schließt alle zusammensetzbaren Funktionen ein, die AndroidView umschließen, z. B. Dialog.
  • Für die folgenden Elemente werden keine automatischen Animationen unterstützt:
    • Zusammensetzbare Funktionen von freigegebenen Bildern:
      • ContentScale wird nicht standardmäßig animiert. Sie rastet am festgelegten Ende ContentScale ein.
    • Formen ausschneiden: Es gibt keine integrierte Unterstützung für automatische Animationen zwischen Formen, z. B. die Animation von einem Quadrat zu einem Kreis beim Übergang des Elements.
    • Verwenden Sie in den nicht unterstützten Fällen Modifier.sharedBounds() anstelle von sharedElement() und fügen Sie den Elementen Modifier.animateEnterExit() hinzu.