Transições de elementos compartilhados no Compose

As transições de elementos compartilhados são uma maneira perfeita de fazer a transição entre elementos combináveis que têm conteúdo consistente entre eles. Eles são usados com frequência para navegação, permitindo que você conecte visualmente diferentes telas enquanto um usuário navega entre elas.

Por exemplo, no vídeo a seguir, é possível ver que a imagem e o título do lanche são compartilhados da página de listagem para a página de detalhes.

Figura 1. Demonstração de elemento compartilhado do Jetsnack.

No Compose, há algumas APIs de alto nível que ajudam a criar elementos compartilhados:

  • SharedTransitionLayout: o layout mais externo necessário para implementar transições de elementos compartilhados. Ele fornece um SharedTransitionScope. Os elementos combináveis precisam estar em um SharedTransitionScope para usar os modificadores de elemento compartilhado.
  • Modifier.sharedElement(): o modificador que sinaliza para o SharedTransitionScope o elemento combinável que precisa ser correspondido com outro elemento combinável.
  • Modifier.sharedBounds(): o modificador que sinaliza para o SharedTransitionScope que os limites desse elemento combinável precisam ser usados como os limites do contêiner em que a transição vai ocorrer. Ao contrário de sharedElement(), o sharedBounds() foi criado para conteúdo visualmente diferente.

Um conceito importante ao criar elementos compartilhados no Compose é como eles funcionam com sobreposições e recorte. Consulte a seção Recorte e sobreposições para saber mais sobre esse assunto importante.

Uso básico

A transição a seguir será criada nesta seção, passando do item menor da "lista" para o item detalhado maior:

Figura 2. Exemplo básico de uma transição de elemento compartilhado entre dois elementos combináveis.

A melhor maneira de usar Modifier.sharedElement() é em conjunto com AnimatedContent, AnimatedVisibility ou NavHost, já que isso gerencia a transição entre elementos combináveis automaticamente para você.

O ponto de partida é um AnimatedContent básico que tem um MainContent e um elemento combinável DetailsContent antes de adicionar elementos compartilhados:

Figura 3. Iniciando AnimatedContent sem transições de elementos compartilhados.

  1. Para animar os elementos compartilhados entre os dois layouts, envolva o elemento combinável AnimatedContent com SharedTransitionLayout. Os escopos de SharedTransitionLayout e AnimatedContent são transmitidos para 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. Adicione Modifier.sharedElement() à cadeia de modificadores combináveis nos dois elementos combináveis correspondentes. Crie um objeto SharedContentState e lembre-se dele com rememberSharedContentState(). O objeto SharedContentState armazena a chave exclusiva que determina os elementos compartilhados. Forneça uma chave exclusiva para identificar o conteúdo e use rememberSharedContentState() para que o item seja lembrado. O AnimatedContentScope é transmitido ao modificador, que é usado para coordenar a animação.

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

Para saber se ocorreu uma correspondência de elemento compartilhado, extraia rememberSharedContentState() em uma variável e consulte isMatchFound.

Isso resulta na seguinte animação automática:

Figura 4. Exemplo básico de uma transição de elemento compartilhado entre dois elementos combináveis.

Você vai notar que a cor de fundo e o tamanho de todo o contêiner ainda usam as configurações padrão de AnimatedContent.

Limites compartilhados x elemento compartilhado

Modifier.sharedBounds() é semelhante a Modifier.sharedElement(). No entanto, os modificadores são diferentes das seguintes maneiras:

  • sharedBounds() é para conteúdo visualmente diferente, mas que deve compartilhar a mesma área entre estados, enquanto sharedElement() espera que o conteúdo seja o mesmo.
  • Com sharedBounds(), o conteúdo que entra e sai da tela fica visível durante a transição entre os dois estados. Já com sharedElement(), apenas o conteúdo de destino é renderizado nos limites de transformação. Modifier.sharedBounds() tem parâmetros enter e exit para especificar como o conteúdo deve fazer a transição, semelhante a como AnimatedContent funciona.
  • O caso de uso mais comum para sharedBounds() é o padrão de transformação de contêiner, enquanto para sharedElement() o exemplo de caso de uso é uma transição de herói.
  • Ao usar elementos combináveis Text, o sharedBounds() é preferível para oferecer suporte a mudanças de fonte, como transição entre itálico e negrito ou mudanças de cor.

No exemplo anterior, adicionar Modifier.sharedBounds() ao Row e Column nos dois cenários diferentes permite compartilhar os limites dos dois e realizar a animação de transição, permitindo que eles cresçam entre si:

@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. Limites compartilhados entre dois elementos combináveis.

Entender escopos

Para usar Modifier.sharedElement(), o elemento combinável precisa estar em um SharedTransitionScope. O elemento combinável SharedTransitionLayout fornece o SharedTransitionScope. Coloque no mesmo ponto de nível superior na hierarquia da interface que contém os elementos que você quer compartilhar.

Em geral, os elementos combináveis também precisam ser colocados dentro de um AnimatedVisibilityScope. Isso geralmente é fornecido usando AnimatedContent para alternar entre elementos combináveis ou ao usar AnimatedVisibility diretamente, ou pela função combinável NavHost, a menos que você gerencie a visibilidade manualmente. Para usar vários escopos, salve os necessários em um CompositionLocal, use receptores de contexto no Kotlin ou transmita os escopos como parâmetros para suas funções.

Use CompositionLocals no cenário em que você tem vários escopos para acompanhar ou uma hierarquia profundamente aninhada. Um CompositionLocal permite escolher os escopos exatos a serem salvos e usados. Por outro lado, ao usar receptores de contexto, outros layouts na sua hierarquia podem substituir acidentalmente os escopos fornecidos. Por exemplo, se você tiver vários AnimatedContent aninhados, os escopos poderão ser substituídos.

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

Como alternativa, se a hierarquia não for muito aninhada, transmita os escopos como parâmetros:

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

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

Elementos compartilhados com AnimatedVisibility

Os exemplos anteriores mostraram como usar elementos compartilhados com AnimatedContent, mas eles também funcionam com AnimatedVisibility.

Por exemplo, neste exemplo de grade preguiçosa, cada elemento é agrupado em AnimatedVisibility. Quando o item é clicado, o conteúdo tem o efeito visual de ser extraído da interface em um componente semelhante a uma caixa de diálogo.

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. Elementos compartilhados com AnimatedVisibility.

Ordenação de modificadores

Com Modifier.sharedElement() e Modifier.sharedBounds(), a ordem da cadeia de modificadores é importante, assim como no restante do Compose. O posicionamento incorreto de modificadores que afetam o tamanho pode causar saltos visuais inesperados durante a correspondência de elementos compartilhados.

Por exemplo, se você colocar um modificador de padding em uma posição diferente em dois elementos compartilhados, haverá uma diferença visual na animação.

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

Limites correspondentes

Limites incompatíveis: observe como a animação do elemento compartilhado parece um pouco fora do lugar, já que precisa ser redimensionada para os limites incorretos.

Os modificadores usados antes dos modificadores de elemento compartilhado fornecem restrições a esses modificadores, que são usados para derivar os limites inicial e de destino e, posteriormente, a animação de limites.

Os modificadores usados depois dos modificadores de elemento compartilhado usam as restrições de antes para medir e calcular o tamanho de destino da criança. Os modificadores de elemento compartilhado criam uma série de restrições animadas para transformar gradualmente o filho do tamanho inicial para o tamanho de destino.

A exceção é se você usar resizeMode = ScaleToBounds() para a animação ou Modifier.skipToLookaheadSize() em um elemento combinável. Nesse caso, o Compose cria o layout do elemento filho usando as restrições de destino e usa um fator de escala para realizar a animação em vez de mudar o tamanho do layout em si.

Chaves exclusivas

Ao trabalhar com elementos compartilhados complexos, é recomendável criar uma chave que não seja uma string, porque as strings podem ser propensas a erros de correspondência. Cada chave precisa ser exclusiva para que as correspondências ocorram. Por exemplo, no Jetsnack, temos os seguintes elementos compartilhados:

Figura 7. Imagem mostrando o Jetsnack com anotações para cada parte da interface.

Você pode criar uma enumeração para representar o tipo de elemento compartilhado. Neste exemplo, o card de lanche inteiro também pode aparecer em vários lugares diferentes na tela inicial, por exemplo, em uma seção "Popular" e "Recomendado". É possível criar uma chave com o snackId, o origin ("Popular" / "Recomendado") e o type do elemento compartilhado:

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

As classes de dados são recomendadas para chaves porque implementam hashCode() e isEquals().

Gerenciar manualmente a visibilidade dos elementos compartilhados

Nos casos em que você não estiver usando AnimatedVisibility ou AnimatedContent, é possível gerenciar a visibilidade do elemento compartilhado por conta própria. Use Modifier.sharedElementWithCallerManagedVisibility() e forneça sua própria condição que determina quando um item deve estar visível ou não:

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

Limitações atuais

Essas APIs têm algumas limitações. Em especial:

  • Não há suporte para interoperabilidade entre Views e Compose. Isso inclui qualquer elemento combinável que encapsule AndroidView, como um Dialog ou ModalBottomSheet.
  • Não há suporte para animação automática nos seguintes casos:
    • Combináveis de imagem compartilhada:
      • ContentScale não é animado por padrão. Ele se ajusta ao final definido ContentScale.
    • Corte de forma: não há suporte integrado para animação automática entre formas. Por exemplo, animação de um quadrado para um círculo à medida que o item faz a transição.
    • Para os casos sem suporte, use Modifier.sharedBounds() em vez de sharedElement() e adicione Modifier.animateEnterExit() aos itens.