Transizioni degli elementi condivisi in Compose

Le transizioni degli elementi condivisi sono un modo semplice per passare da un composable all'altro con 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 seguente puoi vedere che l'immagine e il titolo dello snack vengono condivisi dalla pagina della scheda alla pagina dei dettagli.

Figura 1. Demo dell'elemento condiviso di Jetsnack

In Compose sono disponibili 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 valore SharedTransitionScope. I composabili devono essere contenuti 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 indica 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 crei elementi condivisi in Compose è il loro funzionamento con overlay e clipping. Consulta la sezione Taglio e overlay per saperne di più su questo importante argomento.

Utilizzo di base

In questa sezione verrà eseguita la seguente transizione, dal minore elemento "elenco" all'elemento dettagliato più grande:

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

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

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

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

  1. Per animare gli elementi condivisi tra i due layout, contorna il composable AnimatedContent con SharedTransitionLayout. Gli scopi di SharedTransitionLayout e AnimatedContent vengono trasmessi 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 sui due composabili corrispondenti. Crea un oggetto SharedContentState e ricordalo 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 l'elemento da ricordare. 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 sull'eventuale 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 composabili.

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() è destinato ai contenuti visivamente diversi, ma che devono condividere la stessa area tra gli stati, mentre sharedElement() si aspetta che i contenuti siano uguali.
  • Con sharedBounds(), i contenuti che entrano e escono dallo schermo sono visibili durante la transizione tra i due stati, mentre con sharedElement() solo i contenuti target vengono visualizzati negli intervalli di trasformazione. Modifier.sharedBounds() ha i parametri enter e exit per specificare la transizione dei contenuti, in modo simile a come funziona AnimatedContent.
  • Il caso d'uso più comune per sharedBounds() è il pattern di trasformazione del contenitore, mentre per sharedElement() il caso d'uso di esempio è una transizione dell'eroe.
  • Quando utilizzi i composabili Text, è preferibile sharedBounds() per supportare le modifiche dei caratteri, ad esempio il passaggio tra corsivo e grassetto o le modifiche di colore.

Nell'esempio precedente, l'aggiunta di Modifier.sharedBounds() a Row e Column nei due diversi scenari ci consentirà di condividere i limiti di entrambi e di 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 composable.

Informazioni sugli ambiti

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

In genere, i composabili 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 direttamente AnimatedVisibility oppure tramite la funzione composable NavHost, a meno che non gestisci la visibilità manualmente. Per utilizzare più ambiti, salva gli ambiti richiesti in un CompositionLocal, utilizza i ricipienti di contesto in Kotlin o passa gli ambiti come parametri alle 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 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 modo approfondito, 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 hanno mostrato 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 è racchiuso in AnimatedVisibility. Quando viene fatto clic sull'elemento, i contenuti hanno l'effetto visivo di essere estratti dall'interfaccia utente 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(
                            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.

Ordinamento dei modificatori

Con Modifier.sharedElement() e Modifier.sharedBounds(), l'ordine della catena di modi è 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 inserisci un modificatore di spaziatura in una posizione diversa su due elementi condivisi, l'animazione presenta una differenza visiva.

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

Contorni non corrispondenti: notate come l'animazione dell'elemento condiviso sia un po' fuori perché deve essere ridimensionata in base ai contorni errati

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

I modificatori utilizzati dopo i modificatori degli elementi condivisi utilizzano le limitazioni precedenti per misurare e calcolare le dimensioni target dell'elemento 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.

L'eccezione a questa regola è se utilizzi resizeMode = ScaleToBounds() per l'animazione o Modifier.skipToLookaheadSize() in un composable. In questo caso, Compose dispone il componente secondario utilizzando i vincoli di destinazione e, anziché modificare le dimensioni del layout, utilizza un fattore di scala per eseguire l'animazione.

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 dell'interfaccia utente.

Puoi creare un enum per rappresentare il tipo di elemento condiviso. In questo esempio, tutta la scheda snack può essere visualizzata anche in più punti della schermata iniziale, ad esempio nelle sezioni "Popolari" e "Consigliati". Puoi creare una chiave contenente il snackId, il 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 in quanto 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 fornisci il tuo 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 è supportata per i seguenti elementi:
    • Combinazioni di immagini condivise:
      • ContentScale non è animato per impostazione predefinita. Si aggancia all'estremità impostataContentScale.
    • Taglio delle forme: non è previsto il supporto integrato per l'animazione automatica tra le forme, ad esempio l'animazione da un quadrato a un cerchio durante le transizioni dell'elemento.
    • Per i casi non supportati, utilizza Modifier.sharedBounds() anziché sharedElement() e aggiungi Modifier.animateEnterExit() agli elementi.