Transizioni degli elementi condivisi in Compose

Le transizioni agli elementi condivisi sono un modo semplice per passare tra componenti componibili che hanno contenuti coerenti tra loro. Vengono spesso utilizzati per la navigazione, in modo da consentire di collegare visivamente diverse schermate mentre l'utente passa da una all'altra.

Ad esempio, nel video che segue puoi vedere che l'immagine e il titolo dello snack vengono condivisi dalla pagina della scheda a quella dei dettagli.

Figura 1. Demo sugli elementi condivisi di Jetsnack

In Compose sono disponibili alcune API di alto livello che consentono di creare elementi condivisi:

  • SharedTransitionLayout: il layout più esterno necessario per implementare le transizioni degli elementi condivisi. Fornisce un valore SharedTransitionScope. I componibili devono essere in un SharedTransitionScope per utilizzare i modificatori degli elementi condivisi.
  • Modifier.sharedElement(): il modificatore che segnala all'elemento SharedTransitionScope il componibile che deve essere abbinato a un altro componibile.
  • Modifier.sharedBounds(): il modificatore che segnala a SharedTransitionScope che i limiti di questo componibile debbano essere utilizzati come limiti del container in cui deve avvenire la transizione. A differenza di sharedElement(), sharedBounds() è progettato per contenuti visivamente diversi.

Un concetto importante durante la creazione di elementi condivisi in Compose è il modo in cui questi elementi interagiscono con overlay e clip. Per saperne di più su questo importante argomento, consulta la sezione relativa a taglio e overlay.

Utilizzo di base

In questa sezione verrà creata la seguente transizione, dall'elemento "elenco" più piccolo all'elemento dettagliato più grande:

Figura 2. Esempio di base di transizione di un elemento condiviso tra due componibili.

Il modo migliore per utilizzare Modifier.sharedElement() è in combinazione con AnimatedContent, AnimatedVisibility o NavHost, che gestisce automaticamente la transizione tra i componenti componibili.

Il punto di partenza è un AnimatedContent di base esistente che ha un MainContent e un componibile DetailsContent prima di aggiungere elementi condivisi:

Figura 3. A partire dal giorno AnimatedContent senza transizioni di elementi condivisi.

  1. Per rendere gli elementi condivisi animati tra i due layout, circonda il componibile AnimatedContent con SharedTransitionLayout. Gli ambiti da SharedTransitionLayout e AnimatedContent vengono passati a MainContent e DetailsContent:

    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. Aggiungi Modifier.sharedElement() alla tua catena di modificatori componibili sui due componibili corrispondenti. Crea un oggetto SharedContentState e ricordalo con rememberSharedContentState(). L'oggetto SharedContentState sta memorizzando la chiave univoca che determina gli elementi condivisi. Fornisci una chiave univoca per identificare i contenuti e utilizza rememberSharedContentState() per l'elemento da ricordare. L'elemento AnimatedContentScope viene passato nel modificatore, che viene utilizzato per coordinare l'animazione.

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

Per sapere se si è verificata una corrispondenza di elementi condivisi, estrai rememberSharedContentState() in una variabile ed esegui una query su isMatchFound.

Il che determina la seguente animazione automatica:

Figura 4. Esempio di base di transizione di un elemento condiviso tra due componibili.

Puoi notare che il colore e la dimensione dello sfondo dell'intero contenitore continuano a utilizzare le impostazioni predefinite di AnimatedContent.

Limiti condivisi ed elemento condiviso

Modifier.sharedBounds() è simile a Modifier.sharedElement(). Tuttavia, i modificatori sono diversi nei seguenti modi:

  • sharedBounds() si rivolge a contenuti che sono visivamente diversi, ma che devono condividere la stessa area tra stati, mentre sharedElement() si aspetta che i contenuti siano gli stessi.
  • Con sharedBounds(), i contenuti che entrano ed escono dallo schermo sono visibili durante la transizione tra i due stati, mentre con sharedElement() viene visualizzato solo il contenuto di destinazione nei limiti di trasformazione. Modifier.sharedBounds() ha i parametri enter e exit per specificare la modalità di transizione dei contenuti, in modo simile al funzionamento di AnimatedContent.
  • Il caso d'uso più comune per sharedBounds() è il pattern di trasformazione del container, mentre per sharedElement() il caso d'uso di esempio è una transizione hero.
  • Quando utilizzi gli elementi componibili Text, è preferibile sharedBounds() per supportare le modifiche del carattere, come la transizione tra il corsivo e il grassetto o le modifiche di colore.

Dall'esempio precedente, l'aggiunta di Modifier.sharedBounds() a Row e Column nei due diversi scenari ci consentirà di condividere i limiti dei due due ed eseguire l'animazione di transizione, consentendo loro di crescere tra loro:

@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()
                )
                // ...

        ) {
            // ...
        }
    }
}

Figura 5. Limiti condivisi tra due componibili.

Informazioni sugli ambiti

Per utilizzare Modifier.sharedElement(), il componibile deve essere in un SharedTransitionScope. Il componibile SharedTransitionLayout fornisce SharedTransitionScope. Assicurati di collocarlo nello stesso punto di primo livello nella gerarchia UI che contiene gli elementi che vuoi condividere.

In genere, i componibili devono essere posizionati anche all'interno di un elemento AnimatedVisibilityScope. In genere, questa opzione viene fornita utilizzando AnimatedContent per passare da un elemento componibile all'altro o quando si utilizza direttamente AnimatedVisibility oppure tramite la funzione componibile NavHost, a meno che tu non gestisca la visibilità manualmente. Per utilizzare più ambiti, salva gli ambiti richiesti in ComposeLocal, utilizza i ricevitori di contesto in Kotlin o passa gli ambiti come parametri alle tue funzioni.

Utilizza CompositionLocals nello scenario in cui hai più ambiti da tenere traccia o una gerarchia profondamente nidificata. Un'istruzione CompositionLocal ti consente di scegliere gli ambiti esatti da salvare e utilizzare. D'altra parte, quando utilizzi ricevitori contestuali, altri layout nella gerarchia potrebbero sostituire accidentalmente gli ambiti forniti. Ad esempio, se disponi di più elementi AnimatedContent nidificati, potrebbe essere eseguito l'override degli ambiti.

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

In alternativa, se la gerarchia non è profondamente nidificata, puoi trasferire gli ambiti come parametri:

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

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

Elementi condivisi con AnimatedVisibility

Gli esempi precedenti mostravano come utilizzare gli elementi condivisi con AnimatedContent, ma gli elementi condivisi funzionano anche con AnimatedVisibility.

Ad esempio, in questo esempio di griglia lazy ogni elemento è aggregato in AnimatedVisibility. Quando l'utente fa clic sull'elemento, i contenuti hanno l'effetto visivo di essere estratti dall'interfaccia utente e trasformati in componenti simili a una finestra di dialogo.

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

Figura 6.Elementi condivisi con AnimatedVisibility.

Ordine del modificatore

Con Modifier.sharedElement() e Modifier.sharedBounds(), l'ordine della catena di modificatori è importante, come nel resto di Compose. Il posizionamento errato dei modificatori che influiscono sulle dimensioni può causare salti visivi imprevisti durante la corrispondenza degli elementi condivisi.

Ad esempio, se posizioni un modificatore di spaziatura interna in una posizione diversa su due elementi condivisi, viene rilevata una differenza visiva nell'animazione.

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

Limiti corrispondenti

Limiti non abbinati: puoi notare che l'animazione dell'elemento condiviso appare leggermente fuori luogo perché deve essere ridimensionata secondo i limiti errati.

I modificatori utilizzati prima dei modificatori degli elementi condivisi forniscono vincoli ai modificatori degli elementi condivisi, che vengono poi utilizzati per ricavare i limiti iniziale e target e, successivamente, l'animazione dei limiti.

I modificatori utilizzati dopo i modificatori degli elementi condivisi usano i vincoli di prima per misurare e calcolare le dimensioni target del publisher secondario. I modificatori di elementi condivisi creano una serie di vincoli animati per trasformare gradualmente il file secondario dalla dimensione iniziale alla dimensione di destinazione.

Fa eccezione il caso in cui utilizzi resizeMode = ScaleToBounds() per l'animazione o Modifier.skipToLookaheadSize() in un componibile. In questo caso, Compose traccia il layout secondario utilizzando i vincoli di destinazione e utilizza invece un fattore di scala per eseguire l'animazione anziché modificare le dimensioni del layout stesso.

Chiavi univoche

Quando si lavora con elementi condivisi complessi, è buona norma creare una chiave che non sia una stringa, perché le stringhe possono essere soggette a errori. Ogni chiave deve essere univoca affinché si verifichino corrispondenze. Ad esempio, in Jetsnack abbiamo i seguenti elementi condivisi:

Figura 7. Immagine che mostra Jetsnack con annotazioni per ogni parte della UI.

Puoi creare un'enumerazione per rappresentare il tipo di elemento condiviso. In questo esempio, l'intera scheda snack può anche essere visualizzata da più posizioni diverse sulla schermata Home, ad esempio in una sezione "Popolari" e una sezione "Consigliati". Puoi creare una chiave con snackId, origin ("Popolari" / "Consigliati") e type dell'elemento condiviso che verrà condiviso:

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

Le classi di dati sono consigliate per le chiavi poiché implementano hashCode() e isEquals().

Gestire manualmente la visibilità degli elementi condivisi

Nei casi in cui potresti non utilizzare AnimatedVisibility o AnimatedContent, puoi gestire autonomamente la visibilità dell'elemento condiviso. Utilizza Modifier.sharedElementWithCallerManagedVisibility() e specifica una condizione che determina quando un elemento deve essere visibile o meno:

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

Limitazioni attuali

Queste API presentano alcune limitazioni. In particolare:

  • L'interoperabilità tra Visualizzazioni e Compose non è supportata. Ciò include qualsiasi elemento componibile che aggrega AndroidView, ad esempio Dialog.
  • L'animazione automatica non supporta quanto segue:
    • Immagini componibili condivise:
      • ContentScale non è animato per impostazione predefinita. Si aggancia alla fine impostata ContentScale.
    • Taglio delle forme: non è disponibile il supporto integrato per l'animazione automatica tra le forme, ad esempio l'animazione da un quadrato a un cerchio durante la transizione degli elementi.
    • Per le richieste non supportate, utilizza Modifier.sharedBounds() anziché sharedElement() e aggiungi Modifier.animateEnterExit() agli elementi.