Ü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, sodass Sie verschiedene Bildschirme visuell miteinander verbinden können, wenn ein Nutzer zwischen ihnen wechselt.

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

Abbildung 1. Jetsnack-Demo für freigegebene Elemente

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

  • SharedTransitionLayout: Das äußerste Layout, das zum Implementieren von Übergängen für gemeinsame Elemente erforderlich ist. Er stellt einen SharedTransitionScope bereit. Composables müssen sich in einem SharedTransitionScope befinden, damit die Modifikatoren für gemeinsam genutzte Elemente verwendet werden können.
  • Modifier.sharedElement(): Der Modifikator, der dem SharedTransitionScope signalisiert, dass die zusammensetzbare Funktion mit einer anderen zusammensetzbaren Funktion abgeglichen werden soll.
  • Modifier.sharedBounds(): Der Modifier, der dem SharedTransitionScope signalisiert, dass die Grenzen dieser zusammensetzbaren Funktion als Containergrenzen für die Übergangsstelle verwendet werden sollen. Im Gegensatz zu sharedElement() ist sharedBounds() für visuell unterschiedliche Inhalte konzipiert.

Ein wichtiges Konzept beim Erstellen von gemeinsamen Elementen in Compose ist die Funktionsweise von Overlays und Clipping. Weitere Informationen zu diesem wichtigen Thema finden Sie im Abschnitt Beschneidung und Overlays.

Grundlegende Nutzung

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

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

Modifier.sharedElement() lässt sich am besten in Verbindung mit AnimatedContent, AnimatedVisibility oder NavHost verwenden, da der Übergang zwischen Composables dann automatisch für Sie verwaltet wird.

Der Ausgangspunkt ist ein vorhandener einfacher AnimatedContent mit einem MainContent und einem DetailsContent-Composable, bevor gemeinsame Elemente hinzugefügt werden:

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

  1. Damit die freigegebenen Elemente zwischen 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 Modifier.sharedElement() der Kette der zusammensetzbaren Modifikatoren der beiden übereinstimmenden zusammensetzbaren Funktionen hinzu. Erstellen Sie ein SharedContentState-Objekt und speichern Sie es mit rememberSharedContentState(). Im SharedContentState-Objekt wird der eindeutige Schlüssel gespeichert, der die freigegebenen Elemente bestimmt. Geben Sie einen eindeutigen Schlüssel zur Identifizierung des Inhalts an und verwenden Sie rememberSharedContentState() für das Element, das gespeichert werden soll. Der AnimatedContentScope wird an den Modifier ü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 mit einem gemeinsamen Element aufgetreten ist, extrahieren Sie rememberSharedContentState() in eine Variable und fragen Sie isMatchFound ab.

Daraus ergibt sich die folgende automatische Animation:

Abbildung 4: Einfaches Beispiel für einen Übergang mit gemeinsam genutzten Elementen zwischen zwei Composables.

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

Gemeinsame Grenzen im Vergleich zu gemeinsam genutzten Elementen

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

  • sharedBounds() ist für Inhalte, die sich visuell unterscheiden, aber denselben Bereich zwischen den Bundesstaaten teilen sollen, während bei sharedElement() erwartet wird, dass die Inhalte identisch sind.
  • Bei sharedBounds() ist der Inhalt, der auf den Bildschirm kommt und ihn verlässt, während des Übergangs zwischen den beiden Status sichtbar. Bei sharedElement() wird nur der Zielinhalt in den sich verändernden Grenzen gerendert. Modifier.sharedBounds() hat die Parameter enter und exit, mit denen angegeben wird, wie der Übergang des Inhalts erfolgen soll. Das funktioniert ähnlich wie bei AnimatedContent.
  • Der häufigste Anwendungsfall für sharedBounds() ist das Containertransformationsmuster. Für sharedElement() ist ein Beispielanwendungsfall ein Hero-Übergang.
  • Wenn Sie Text-Composables verwenden, 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 teilen und die Übergangsanimation ausführen, sodass sie sich gegenseitig vergrößern:

@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

Damit Sie Modifier.sharedElement() verwenden können, muss die Composable in einem SharedTransitionScope sein. Die zusammensetzbare Funktion SharedTransitionLayout stellt die SharedTransitionScope bereit. Achten Sie darauf, dass Sie den Link an derselben Stelle in der UI-Hierarchie platzieren, die die Elemente enthält, die Sie teilen möchten.

Im Allgemeinen sollten die Composables auch in einem AnimatedVisibilityScope platziert werden. Dies 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, 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 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 jedoch Kontext-Receiver verwenden, werden die bereitgestellten Bereiche möglicherweise versehentlich durch andere 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
) {
}

Mit AnimatedVisibility geteilte Elemente

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

In diesem Beispiel für ein Lazy Grid 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: Freigegebene Elemente mit AnimatedVisibility.

Reihenfolge der Modifizierer

Bei Modifier.sharedElement() und Modifier.sharedBounds() ist die Reihenfolge der Modifikatoren wichtig, genau wie beim Rest von Compose. Die falsche Platzierung von Größenänderungen kann zu unerwarteten visuellen Sprüngen beim Abgleich gemeinsamer Elemente führen.

Wenn Sie beispielsweise einen Padding-Modifikator an einer anderen Position in zwei gemeinsamen 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
                )
            }
        }
    }
}

Abgestimmte Grenzen

Nicht übereinstimmende Grenzen: Die Animation des gemeinsamen Elements sieht etwas seltsam aus, da die Größe an die falschen Grenzen angepasst werden muss.

Die Modifikatoren, die vor den Modifikatoren für das gemeinsame Element verwendet werden, stellen Einschränkungen für die Modifikatoren für das gemeinsame Element dar. Diese werden dann verwendet, um die anfänglichen und Zielgrenzen und anschließend die Animationsgrenzen abzuleiten.

Die Modifizierer, die nach den Modifizierern für das gemeinsame Element verwendet werden, verwenden die Einschränkungen von zuvor, um die Zielgröße des untergeordneten Elements zu messen und zu berechnen. Die Modifizierer für das gemeinsame Element 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 besteht, wenn Sie resizeMode = ScaleToBounds() für die Animation oder Modifier.skipToLookaheadSize() für eine zusammensetzbare Funktion verwenden. In diesem Fall wird das untergeordnete Element mit den Zielbeschränkungen gerendert und für die Animation wird ein Skalierungsfaktor verwendet, anstatt die Layoutgröße selbst zu ändern.

Eindeutige Schlüssel

Wenn Sie mit komplexen gemeinsamen Elementen arbeiten, ist es ratsam, 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 gemeinsamen Elemente:

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

Sie können ein Enum erstellen, um den gemeinsamen Elementtyp 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 mit dem snackId, dem origin („Beliebt“ / „Empfohlen“) und dem type des freigegebenen Elements erstellen, das freigegeben wird:

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 des freigegebenen Elements selbst verwalten. Verwenden Sie Modifier.sharedElementWithCallerManagedVisibility() und geben Sie eine eigene Bedingung an, die bestimmt, wann ein Artikel 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

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

  • Es wird keine Interoperabilität zwischen Views 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:
    • Composable-Funktionen für freigegebene Bilder:
      • ContentScale wird standardmäßig nicht animiert. Es wird am festgelegten Ende ContentScale ausgerichtet.
    • Formenbeschneidung: 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 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.