Transizioni di elementi condivisi in Componi

Le transizioni degli elementi condivisi sono un modo semplice per passare da un composable all'altro con contenuti coerenti. Vengono spesso utilizzate per la navigazione, in quanto consentono di collegare visivamente schermate diverse mentre un utente naviga tra di esse.

Ad esempio, nel video seguente puoi vedere che l'immagine e il titolo dello snack vengono condivisi dalla pagina dell'elenco alla pagina dei dettagli.

Figura 1. Demo degli elementi condivisi di Jetsnack.

In Compose, esistono alcune API di alto livello che ti aiutano a creare elementi condivisi:

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

Un concetto importante quando si creano elementi condivisi in Compose è il modo in cui funzionano con le sovrapposizioni e il ritaglio. Consulta la sezione Ritaglio e sovrapposizioni per saperne di più su questo argomento importante.

Utilizzo di base

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

Figura 2. Esempio di base di una transizione di elementi condivisi tra due composable.

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

Il punto di partenza è un AnimatedContent di base esistente con un composable MainContent e DetailsContent prima di aggiungere elementi condivisi:

Figura 3. AnimatedContent iniziale senza transizioni di elementi condivisi.

  1. Per animare gli elementi condivisi tra i due layout, racchiudi il composable AnimatedContent con SharedTransitionLayout. Gli ambiti di 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 catena di modificatori composable nei due composable corrispondenti. Crea un oggetto SharedContentState e memorizzalo con rememberSharedContentState(). L'oggetto SharedContentState memorizza la chiave univoca che determina gli elementi condivisi. Fornisci una chiave univoca per identificare i contenuti e utilizza rememberSharedContentState() per memorizzare l'elemento. L'AnimatedContentScope viene passato al 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 ottenere informazioni su se si è verificata una corrispondenza di elementi condivisi, estrai rememberSharedContentState() in una variabile ed esegui una query su isMatchFound.

Il risultato è la seguente animazione automatica:

Figura 4. Esempio di base di una transizione di elementi condivisi tra due composable.

Potresti notare che il colore di sfondo e le dimensioni dell'intero contenitore utilizzano ancora le impostazioni predefinite di AnimatedContent.

Limiti condivisi rispetto a elementi condivisi

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

  • sharedBounds() è per i contenuti visivamente diversi, ma che devono condividere la stessa area tra gli stati, mentre sharedElement() prevede che i contenuti siano gli stessi.
  • Con sharedBounds(), i contenuti che entrano ed escono dalla schermata sono visibili durante la transizione tra i due stati, mentre con sharedElement() viene eseguito il rendering solo dei contenuti di destinazione nei limiti di trasformazione. Modifier.sharedBounds() ha parametri enter e exit per specificare come devono essere transizionati i contenuti, in modo simile al funzionamento di AnimatedContent.
  • Il caso d'uso più comune per sharedBounds() è il pattern di trasformazione del contenitore, mentre per sharedElement() l'esempio di caso d'uso è una transizione hero.
  • Quando si utilizzano i composable Text, è preferibile sharedBounds() per supportare le modifiche dei caratteri, ad esempio la transizione tra corsivo e grassetto o le modifiche dei colori.

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

@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 composable.

Informazioni sugli ambiti

Per utilizzare Modifier.sharedElement(), il composable deve trovarsi in un SharedTransitionScope. Il composable SharedTransitionLayout fornisce SharedTransitionScope. Assicurati di posizionarlo nello stesso punto di primo livello della gerarchia dell'UI che contiene gli elementi che vuoi condividere.

In genere, i composable devono essere inseriti anche in un AnimatedVisibilityScope. In genere, questo viene fornito utilizzando AnimatedContent per passare da un composable all'altro o quando si utilizza AnimatedVisibility direttamente, oppure da la funzione composable NavHost, a meno che tu non gestisca manualmente la visibilità. Per utilizzare più ambiti, salva gli ambiti richiesti in un CompositionLocal, utilizza i ricevitori di contesto in Kotlin o passa gli ambiti come parametri alle tue funzioni.

Utilizza CompositionLocals nello scenario in cui devi tenere traccia di più ambiti o di una gerarchia nidificata in profondità. Un CompositionLocal ti consente di scegliere gli ambiti esatti da salvare e utilizzare. D'altra parte, quando utilizzi i ricevitori di contesto, altri layout nella gerarchia potrebbero sostituire accidentalmente gli ambiti forniti. Ad esempio, se hai più AnimatedContent nidificati, gli ambiti potrebbero essere sostituiti.

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 è nidificata in profondità, puoi passare 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 pigra, ogni elemento è racchiuso in AnimatedVisibility. Quando si fa clic sull'elemento, i contenuti hanno l'effetto visivo di essere estratti dall'UI in un componente simile 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(
                            sharedContentState = 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.

Ordinamento dei modificatori

Con Modifier.sharedElement() e Modifier.sharedBounds(), l'ordine della catena di modificatori è importante, come per il 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 padding in una posizione diversa su due elementi condivisi, si verifica 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 corrispondenti: nota come l'animazione degli elementi condivisi sembra un po' fuori posto perché deve ridimensionarsi ai limiti errati

I modificatori utilizzati prima dei modificatori degli elementi condivisi forniscono vincoli ai modificatori degli elementi condivisi, che vengono poi utilizzati per derivare i limiti iniziali e di destinazione e, successivamente, l'animazione dei limiti.

I modificatori utilizzati dopo i modificatori degli elementi condivisi utilizzano i vincoli precedenti per misurare e calcolare le dimensioni di destinazione del figlio. I modificatori degli elementi condivisi creano una serie di vincoli animati per trasformare gradualmente il figlio dalle dimensioni iniziali alle dimensioni di destinazione.

L'eccezione è se utilizzi resizeMode = ScaleToBounds() per l'animazione o Modifier.skipToLookaheadSize() su un composable. In questo caso, Compose dispone il figlio 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 lavori con elementi condivisi complessi, è consigliabile creare una chiave che non sia una stringa, perché le stringhe possono essere soggette a errori di corrispondenza. Ogni chiave deve essere univoca per poter trovare corrispondenze. Ad esempio, in Jetsnack abbiamo i seguenti elementi condivisi:

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

Puoi creare un'enumerazione per rappresentare il tipo di elemento condiviso. In questo esempio, l'intera scheda dello snack può essere visualizzata anche da più posizioni diverse nella schermata Home, ad esempio in una sezione "Popolari" e in una sezione "Consigliati". Puoi creare una chiave che contenga snackId, origin ("Popolari" / "Consigliati") e il 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 perché implementano hashCode() e equals().

Gestire manualmente la visibilità degli elementi condivisi

Nei casi in cui potresti non utilizzare AnimatedVisibility o AnimatedContent, puoi gestire autonomamente la visibilità degli elementi condivisi. Utilizza Modifier.sharedElementWithCallerManagedVisibility() e fornisci la tua 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:

  • Non è supportata l'interoperabilità tra View e Compose. Sono inclusi tutti i composable che racchiudono AndroidView, come Dialog o ModalBottomSheet.
  • Non è disponibile il supporto per l'animazione automatica per quanto segue:
    • Composable di immagini condivise:
      • ContentScale non è animato per impostazione predefinita. Si aggancia a ContentScale finale impostato.
    • Ritaglio della forma : 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 dell'elemento.
    • Per i casi non supportati, utilizza Modifier.sharedBounds() anziché sharedElement() e aggiungi Modifier.animateEnterExit() agli elementi.