Übergänge für gemeinsame Elemente in Compose

Übergänge mit gemeinsam genutzten Elementen sind eine nahtlose Möglichkeit für den Übergang zwischen zusammensetzbaren Funktionen, deren Inhalte einheitlich sind. Sie werden häufig für die Navigation verwendet, sodass Sie verschiedene Bildschirme visuell verbinden können, wenn Nutzer zwischen ihnen wechseln.

Im folgenden Video sehen Sie beispielsweise, wie das Bild und der Titel des Snackangebots von der Eintragsseite an die Detailseite weitergegeben werden.

Abbildung 1. Demo zu geteilten Elementen von Jetsnack

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

  • SharedTransitionLayout: Das äußerste Layout, das zum Implementieren von Übergängen mit gemeinsam genutzten Elementen erforderlich ist. Er stellt ein SharedTransitionScope bereit. Zusammensetzbare Elemente müssen sich in einem SharedTransitionScope befinden, damit Modifikatoren für gemeinsam genutzte Elemente verwendet werden können.
  • Modifier.sharedElement(): Der Modifikator, mit dem SharedTransitionScope die zusammensetzbare Funktion gekennzeichnet wird, die mit einer anderen zusammensetzbaren Funktion abgeglichen werden soll.
  • Modifier.sharedBounds(): Der Modifikator, der SharedTransitionScope angibt, dass die Grenzen dieser zusammensetzbaren Funktion als Containergrenzen für den Ort verwendet werden sollen, an dem der Übergang stattfinden soll. Im Gegensatz zu sharedElement() ist sharedBounds() für optisch unterschiedliche Inhalte vorgesehen.

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

Grundlegende Nutzung

In diesem Abschnitt wird der folgende Übergang erstellt, der vom kleineren Listenelement zum umfangreicheren Element übergeht:

Abbildung 2: Einfaches Beispiel für einen Übergang von gemeinsam genutzten Elementen zwischen zwei zusammensetzbaren Funktionen.

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

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

Abbildung 3: AnimatedContent wird ohne Übergänge zwischen geteilten Elementen gestartet.

  1. Damit die gemeinsamen Elemente zwischen den beiden Layouts animiert werden, setzen Sie die zusammensetzbare AnimatedContent-Datei 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 Ihrer zusammensetzbaren Modifikatorkette Modifier.sharedElement() für die beiden zusammensetzbaren Funktionen hinzu, die übereinstimmen. Erstellen Sie ein SharedContentState-Objekt und merken Sie sich es mit rememberSharedContentState(). Das SharedContentState-Objekt speichert den eindeutigen Schlüssel, der bestimmt, welche Elemente gemeinsam genutzt werden. Geben Sie einen eindeutigen Schlüssel an, um den Inhalt zu identifizieren, und verwenden Sie rememberSharedContentState(), damit das Element gespeichert werden soll. Das AnimatedContentScope wird an den Modifikator übergeben, der zur 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 wissen möchten, ob eine Übereinstimmung mit einem gemeinsam genutzten Element vorliegt, extrahieren Sie rememberSharedContentState() in eine Variable und fragen Sie isMatchFound ab.

Dies führt zur folgenden automatischen Animation:

Abbildung 4: Einfaches Beispiel für einen Übergang von gemeinsam genutzten Elementen zwischen zwei zusammensetzbaren Funktionen.

Möglicherweise werden Sie feststellen, dass für die Hintergrundfarbe und -größe des gesamten Containers weiterhin die AnimatedContent-Standardeinstellungen verwendet werden.

Gemeinsame Grenzen im Vergleich zu gemeinsam genutzten Elementen

Modifier.sharedBounds() ähnelt Modifier.sharedElement(). Die Modifikatoren unterscheiden sich jedoch folgendermaßen:

  • sharedBounds() ist für Inhalte vorgesehen, die sich optisch unterscheiden, aber den gleichen Bereich zwischen den Status teilen sollen, während sharedElement() erwartet, dass der Inhalt identisch ist.
  • Bei sharedBounds() ist der Inhalt, der den Bildschirm ein- und wieder verlässt, während des Übergangs zwischen den beiden Zuständen sichtbar, während bei sharedElement() nur der Zielinhalt innerhalb der Transformationsgrenzen gerendert wird. Für Modifier.sharedBounds() gibt es die Parameter enter und exit, mit denen angegeben wird, wie der Inhalt übertragen werden soll, ähnlich wie bei AnimatedContent.
  • Der häufigste Anwendungsfall für sharedBounds() ist das Container-Transformationsmuster, während das Beispiel für sharedElement() ein Hero-Übergang ist.
  • Wenn Sie zusammensetzbare Text-Funktionen verwenden, wird sharedBounds() bevorzugt, um Änderungen der Schriftart wie den Wechsel zwischen Kursiv- und Fettdruck oder Farbänderungen zu unterstützen.

Wenn Sie im vorherigen Beispiel Modifier.sharedBounds() zu Row und Column in den beiden verschiedenen Szenarien hinzufügen, können wir die Grenzen der beiden gemeinsam nutzen und die Übergangsanimation ausführen, sodass sie miteinander 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 von zwei zusammensetzbaren Funktionen.

Bereiche verstehen

Damit Sie Modifier.sharedElement() verwenden können, muss sich die zusammensetzbare Funktion in einer SharedTransitionScope befinden. Die zusammensetzbare Funktion SharedTransitionLayout stellt das SharedTransitionScope bereit. Achten Sie darauf, sich an demselben Punkt der obersten Ebene in Ihrer UI-Hierarchie zu platzieren, der die freizugebenden Elemente enthält.

Im Allgemeinen sollten die zusammensetzbaren Funktionen auch in einem AnimatedVisibilityScope platziert werden. Dazu verwenden Sie in der Regel AnimatedContent, um zwischen zusammensetzbaren Funktionen zu wechseln, AnimatedVisibility direkt zu verwenden oder die zusammensetzbare Funktion NavHost, es sei denn, Sie verwalten die Sichtbarkeit manuell. Wenn Sie mehrere Bereiche verwenden möchten, speichern Sie die erforderlichen Bereiche in einer CompositionLocal, verwenden Sie Kontextempfänger in Kotlin oder übergeben Sie die Bereiche als Parameter an Ihre Funktionen.

Verwenden Sie CompositionLocals in einem Szenario, in dem Sie mehrere Bereiche im Auge behalten müssen oder wenn Sie eine tief verschachtelte Hierarchie haben. Mit CompositionLocal können Sie genau die Bereiche auswählen, die gespeichert und verwendet werden sollen. Wenn Sie andererseits Kontextempfänger verwenden, könnten die angegebenen Bereiche versehentlich durch andere Layouts in Ihrer Hierarchie überschrieben werden. 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
) {
}

Freigegebene Elemente für AnimatedVisibility

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

In diesem Beispiel für ein Lazy Grid ist jedes Element in AnimatedVisibility eingeschlossen. Wenn auf das Element geklickt wird, hat der Inhalt den visuellen Effekt, dass er aus der Benutzeroberfläche 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: Gemeinsam genutzte Elemente mit AnimatedVisibility

Modifikatorreihenfolge

Bei Modifier.sharedElement() und Modifier.sharedBounds() spielt die Reihenfolge der Modifikatorkette eine Rolle, genau wie beim Rest von Compose. Die falsche Platzierung von Modifikatoren, die die Größe beeinflussen, kann beim Abgleich von gemeinsam genutzten Elementen zu unerwarteten visuellen Sprüngen führen.

Wenn Sie beispielsweise einen Innenrandmodifikator an einer anderen Position für zwei gemeinsam genutzte 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: Beachten Sie, dass die Animation des gemeinsamen Elements etwas abseits angezeigt wird, da sie an die falschen Grenzen angepasst werden muss.

Die vor den Modifikatoren für gemeinsam genutzten Elemente verwendeten Modifizierer stellen Beschränkungen für die Modifizierer für gemeinsam genutzte Elemente bereit. Diese werden dann verwendet, um die Anfangs- und Zielgrenzen und anschließend die Begrenzungsanimation abzuleiten.

Bei den nach den Modifikatoren für gemeinsam genutzten Elemente angewendeten Modifikatoren werden die Einschränkungen von vorher verwendet, um die Zielgröße des untergeordneten Elements zu messen und zu berechnen. Die Modifikatoren für gemeinsame Elemente erstellen eine Reihe von animierten Einschränkungen, um das untergeordnete Element nach und nach von der anfänglichen Größe in die Zielgröße umzuwandeln.

Eine 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 Layout des untergeordneten Elements mithilfe der Zieleinschränkungen fest und verwendet stattdessen einen Skalierungsfaktor zur Ausführung der Animation, anstatt die Layoutgröße selbst zu ändern.

Eindeutige Schlüssel

Bei der Arbeit mit komplexen gemeinsam genutzten 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 auftreten. In Jetsnack gibt es beispielsweise die folgenden gemeinsamen Elemente:

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

Sie könnten eine Aufzählung erstellen, um den Typ des gemeinsamen Elements darzustellen. In diesem Beispiel kann die gesamte Snackkarte auch an 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 gemeinsam genutzten 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 geteilter Elemente manuell verwalten

Falls Sie AnimatedVisibility oder AnimatedContent nicht verwenden, können Sie die Sichtbarkeit der gemeinsam genutzten Elemente selbst verwalten. Verwenden Sie Modifier.sharedElementWithCallerManagedVisibility() und geben Sie eine eigene Bedingung an, die bestimmt, wann ein Element sichtbar sein soll 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

Diese APIs unterliegen einigen Einschränkungen. Vor allem:

  • Die Interoperabilität zwischen „Ansichten“ und „Schreiben“ wird nicht unterstützt. Dazu gehören alle zusammensetzbaren Funktionen, die AndroidView umschließen, z. B. Dialog.
  • In den folgenden Fällen werden automatische Animationen nicht unterstützt:
    • Zusammensetzbare Funktionen von freigegebenen Bildern:
      • ContentScale ist standardmäßig nicht animiert. Sie wird auf das festgelegte Ende ContentScale ausgerichtet.
    • Formabschneiden: 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 übergeht.
    • Verwenden Sie für die Fälle, in denen dies nicht unterstützt wird, Modifier.sharedBounds() anstelle von sharedElement() und fügen Sie den Elementen Modifier.animateEnterExit() hinzu.