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

Übergänge mit gemeinsam genutzten Elementen sind eine nahtlose Möglichkeit, zwischen Composables mit konsistenten Inhalten zu wechseln. Sie werden häufig für die Navigation verwendet, um verschiedene Bildschirme visuell zu verbinden, während ein Nutzer zwischen ihnen navigiert.

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

Abbildung 1. Demo für freigegebene Elemente in Jetsnack

In Compose gibt es einige High-Level-APIs, mit denen Sie gemeinsame Elemente erstellen können:

  • SharedTransitionLayout: Das äußerste Layout, das für die Implementierung von Übergängen mit gemeinsam genutzten Elementen erforderlich ist. Sie bietet eine SharedTransitionScope. Composables müssen sich in einem SharedTransitionScope befinden, damit die Modifikatoren für freigegebene Elemente verwendet werden können.
  • Modifier.sharedElement(): Der Modifikator, der dem SharedTransitionScope das Composable kennzeichnet, das mit einem anderen Composable abgeglichen werden soll.
  • Modifier.sharedBounds(): Der Modifikator, 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 von freigegebenen Elementen in Compose ist, wie sie mit Overlays und Clipping funktionieren. Weitere Informationen zu diesem wichtigen Thema finden Sie im Abschnitt Clipping und Overlays.

Grundlegende Nutzung

In diesem Abschnitt wird der folgende Übergang erstellt, bei dem vom kleineren „Listenelement“ zum größeren detaillierten Element gewechselt wird:

Einfacher Übergang zwischen zwei Composables in Jetsnack
Abbildung 2. Einfaches Beispiel für einen Übergang mit gemeinsam genutzten Elementen zwischen zwei Composables.

Am besten verwenden Sie Modifier.sharedElement() in Verbindung mit AnimatedContent, AnimatedVisibility oder NavHost, da so der Übergang zwischen Composables automatisch verwaltet wird.

Der Ausgangspunkt ist ein vorhandenes einfaches AnimatedContent mit einer MainContent und einer DetailsContent-Komposition, bevor freigegebene Elemente hinzugefügt werden:

Animation, die den standardmäßigen Übergang mit Überblendung und Verschiebung ohne gemeinsame Elemente zeigt
Abbildung 3. AnimatedContent wird ohne Übergänge für freigegebene Elemente gestartet.

  1. Damit die freigegebenen Elemente zwischen den beiden Layouts animiert werden, umschließen Sie die AnimatedContent-Komposition 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 Modifier.sharedElement() zur Modifikatorkette der beiden übereinstimmenden komponierbaren Funktionen hinzu. Erstellen Sie ein SharedContentState-Objekt und speichern Sie es mit rememberSharedContentState(). Das Objekt SharedContentState 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() für das Element, das gespeichert werden soll. Der AnimatedContentScope wird an den Modifikator übergeben, der zur Koordination 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 wissen möchten, ob eine Übereinstimmung mit einem freigegebenen Element vorliegt, extrahieren Sie rememberSharedContentState() in eine Variable und fragen Sie isMatchFound ab.

Dadurch wird die folgende automatische Animation erstellt:

Animation, die zeigt, wie sich das Bild und der Text nahtlos über die Bildschirme ausdehnen
Abbildung 4. Einfaches Beispiel für einen Übergang mit gemeinsam genutzten Elementen zwischen zwei Composables.

Die Hintergrundfarbe und die Größe des gesamten Containers entsprechen weiterhin den Standardeinstellungen für AnimatedContent.

Gemeinsame Grenzen im Vergleich zu gemeinsamen Elementen

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

  • sharedBounds() ist für Inhalte gedacht, die sich visuell unterscheiden, aber zwischen den Zuständen denselben Bereich einnehmen sollen, während sharedElement() erwartet, dass die Inhalte identisch sind.
  • 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() werden dagegen nur die Zielinhalte in den sich transformierenden Grenzen gerendert. Modifier.sharedBounds() hat die Parameter enter und exit, mit denen Sie angeben können, wie der Inhalt übergehen soll, ähnlich wie bei AnimatedContent.
  • Der häufigste Anwendungsfall für sharedBounds() ist das Containertransformationsmuster, während für sharedElement() der Beispielanwendungsfall ein Hero-Übergang ist.
  • Wenn Sie kombinierbare Text-Funktionen verwenden, wird sharedBounds() bevorzugt, um Schriftartänderungen wie den Wechsel zwischen Kursiv- und Fettschrift 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 teilen und die Übergangsanimation ausführen, sodass sie zwischeneinander 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. Gemeinsame Grenzen zwischen zwei Composables.

Umfang

Wenn Sie Modifier.sharedElement() verwenden möchten, muss sich die zusammensetzbare Funktion in einem SharedTransitionScope befinden. Das Compose-Element SharedTransitionLayout stellt die SharedTransitionScope bereit. Achten Sie darauf, dass Sie es auf derselben obersten Ebene in der UI-Hierarchie platzieren, die die Elemente enthält, die Sie freigeben möchten.

Im Allgemeinen sollten die Composables auch in einem AnimatedVisibilityScope platziert werden. Dies wird normalerweise durch die Verwendung von AnimatedContent zum Wechseln zwischen zusammensetzbaren Funktionen oder durch die direkte Verwendung von AnimatedVisibility oder durch die zusammensetzbare Funktion NavHost erreicht, 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 Kontextparameter 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 die genauen Bereiche auswählen, die gespeichert und verwendet werden sollen. Wenn Sie dagegen Kontextparameter verwenden, können andere Layouts in Ihrer Hierarchie die bereitgestellten 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 alternativ als Parameter übergeben:

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

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

Geteilte Elemente mit AnimatedVisibility

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

In diesem LazyColumn-Beispiel ist jedes Element in AnimatedVisibility eingeschlossen. Wenn auf das Element geklickt wird, wird der Inhalt visuell aus der Benutzeroberfläche 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: Elemente mit AnimatedVisibility geteilt.

Reihenfolge der Modifikatoren

Bei Modifier.sharedElement() und Modifier.sharedBounds() ist die Reihenfolge der Modifikatoren wichtig, genau wie im Rest von Compose. Die falsche Platzierung von größenbeeinflussenden Modifikatoren kann zu unerwarteten visuellen Sprüngen beim Abgleich von gemeinsam genutzten Elementen führen.

Wenn Sie beispielsweise einen Padding-Modifikator an einer anderen Position auf zwei freigegebenen 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 des freigegebenen Elements sieht etwas unnatürlich aus, da die Größe an die falschen Grenzen angepasst 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 Zielgrenzen und anschließend die Grenzenanimation abzuleiten.

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

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

Eindeutige Schlüssel

Wenn Sie mit komplexen freigegebenen 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 können. In Jetsnack gibt es beispielsweise die folgenden freigegebenen Elemente:

Jetsnack-Benutzeroberfläche mit Anmerkungen, die gemeinsame UI-Elemente kennzeichnen
Abbildung 7. Bild von Jetsnack mit Anmerkungen zu jedem Teil der Benutzeroberfläche.

Sie können ein Enum erstellen, um den Typ des freigegebenen Elements darzustellen. In diesem Beispiel kann die gesamte Snackkarte auch an mehreren verschiedenen Stellen auf dem Startbildschirm angezeigt werden, z. B. in den Bereichen „Beliebt“ und „Empfohlen“. Sie können einen Schlüssel mit dem snackId, dem origin („Beliebt“/„Empfohlen“) und dem type des freigegebenen Elements erstellen, das freigegeben 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 equals() 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 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. Insbesondere:

  • Es wird keine Interoperabilität zwischen Ansichten und Compose unterstützt. Dazu gehören alle Composables, die AndroidView umschließen, z. B. Dialog oder ModalBottomSheet.
  • Für Folgendes gibt es keine automatische Animationsunterstützung:
    • Gemeinsam genutzte Bild-Kompositionen:
      • ContentScale ist standardmäßig nicht animiert. Sie rastet am festgelegten Ende ein ContentScale.
    • Formen beschneiden: Es gibt keine integrierte Unterstützung für die automatische Animation zwischen Formen, z. B. die Animation von einem Quadrat zu einem Kreis, wenn das Element wechselt.
    • Verwenden Sie für die nicht unterstützten Fälle Modifier.sharedBounds() anstelle von sharedElement() und fügen Sie den Elementen Modifier.animateEnterExit() hinzu.