As transições de elementos compartilhados são uma maneira simples de fazer a transição entre elementos combináveis que têm conteúdo consistente entre eles. Eles geralmente são usados para navegação, permitindo conectar visualmente diferentes telas à medida que o usuário navega entre elas.
Por exemplo, no vídeo a seguir, você pode conferir a imagem e o título do lanche são compartilhados da página de detalhes para a de detalhes.
No Compose, existem 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 umSharedTransitionScope
. Os elementos combináveis precisam estar em umaSharedTransitionScope
para usar os modificadores de elementos compartilhados.Modifier.sharedElement()
: o modificador que sinaliza para oSharedTransitionScope
o elemento combinável que precisa ser combinado com outro elemento combinável.Modifier.sharedBounds()
: o modificador que sinaliza para oSharedTransitionScope
que os limites dessa função de composição precisam ser usados como os limites de contêiner em que a transição precisa ocorrer. Ao contrário desharedElement()
,sharedBounds()
foi projetado para conteúdo visualmente diferente.
Um conceito importante ao criar elementos compartilhados no Compose é como eles funcionam com sobreposições e recortes. Confira a seção Recorte e sobreposições para saber mais sobre esse tópico importante.
Uso básico
A transição abaixo será criada nesta seção, passando do item de "lista" menor para o item detalhado, que é maior:
A melhor maneira de usar o 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 é uma AnimatedContent
básica que já tem um
elemento MainContent
e DetailsContent
antes de adicionar elementos compartilhados:
Para que os elementos compartilhados sejam animados entre os dois layouts, coloque o elemento combinável
AnimatedContent
em volta comSharedTransitionLayout
. Os escopos deSharedTransitionLayout
eAnimatedContent
são transmitidos paraMainContent
eDetailsContent
: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 ) } } }
Adicione
Modifier.sharedElement()
à cadeia de modificadores de composição nos dois elementos combináveis correspondentes. Crie um objetoSharedContentState
e lembre-o comrememberSharedContentState()
. O objetoSharedContentState
está armazenando a chave exclusiva que determina os elementos compartilhados. Forneça uma chave exclusiva para identificar o conteúdo e userememberSharedContentState()
para o item a ser lembrado. OAnimatedContentScope
é transmitido para o 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()
para uma variável e consulte isMatchFound
.
O que resulta na seguinte animação automática:
Observe que a cor e o tamanho do plano de fundo de todo o contêiner ainda usam as configurações AnimatedContent
padrão.
Limites compartilhados x elemento compartilhado
Modifier.sharedBounds()
é semelhante a Modifier.sharedElement()
.
No entanto, os modificadores são diferentes das seguintes maneiras:
sharedBounds()
serve para conteúdo visualmente diferente, mas precisa compartilhar a mesma área entre estados, enquantosharedElement()
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, enquanto comsharedElement()
apenas o conteúdo de destino é renderizado nos limites de transformação.Modifier.sharedBounds()
tem os parâmetrosenter
eexit
para especificar como o conteúdo precisa fazer a transição, da mesma forma queAnimatedContent
funciona. - O caso de uso mais comum de
sharedBounds()
é o padrão de transformação de contêiner, enquanto parasharedElement()
o caso de uso de exemplo é uma transição principal. - Ao usar elementos combináveis
Text
, é preferível usarsharedBounds()
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()
a Row
e
Column
nos dois cenários diferentes vai permitir o compartilhamento dos limites dos
dois e a execução da animação de transição, permitindo que 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() ) // ... ) { // ... } } }
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 da
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 uma
AnimatedVisibilityScope
. Isso geralmente é fornecido usando AnimatedContent
para alternar entre elementos combináveis ou AnimatedVisibility
de forma direta, ou pela
função combinável NavHost
, a menos que você gerencie a visibilidade
manualmente. Para
usar vários escopos, salve os escopos necessários em um
CompositionLocal, use receptores de contexto em Kotlin ou transmita os
escopos como parâmetros para suas funções.
Use CompositionLocals
quando você tiver vários escopos para
acompanhar ou quando tiver uma hierarquia profundamente aninhada. Um CompositionLocal
permite escolher
os escopos exatos a serem salvos e usados. Por outro lado, quando você usa receptores de contexto,
outros layouts na 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 estiver profundamente aninhada, é possível transmitir 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
esses elementos compartilhados também funcionam com AnimatedVisibility
.
Por exemplo, neste exemplo de grade lenta, cada elemento é unido 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( state = rememberSharedContentState(key = snack.name), animatedVisibilityScope = this@AnimatedVisibility ), onClick = { selectedSnack = snack } ) } } } } // Contains matching AnimatedContent with sharedBounds modifiers. SnackEditDetails( snack = selectedSnack, onConfirmClick = { selectedSnack = null } ) }
Ordenação do modificador
Com Modifier.sharedElement()
e Modifier.sharedBounds()
, a ordem da cadeia do
modificador é importante,
assim como no restante do Compose. O posicionamento incorreto dos modificadores que afetam o tamanho
pode causar saltos visuais inesperados durante a correspondência do elemento compartilhado.
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 sem correspondência: observe como a animação do elemento compartilhado aparece um pouco fora do padrão, porque ele precisa ser redimensionado para os limites incorretos. |
---|---|
Os modificadores usados antes dos modificadores de elementos compartilhados fornecem restrições aos modificadores de elemento compartilhado, que são usados para derivar os limites iniciais e de destino e, em seguida, a animação dos limites.
Os modificadores usados depois dos modificadores de elementos compartilhados usam as restrições anteriores para medir e calcular o tamanho de destino do filho. Os modificadores de elementos compartilhados criam uma série de restrições animadas para transformar gradualmente o filho do tamanho inicial para o 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 mostra o filho usando as restrições de destino e usa
um fator de escalonamento para executar a animação em vez de mudar o tamanho
do layout.
Chaves exclusivas
Ao trabalhar com elementos compartilhados complexos, é recomendável criar uma chave que não seja uma string, já que elas podem apresentar erros de correspondência. Cada chave precisa ser exclusiva para que as correspondências ocorram. Por exemplo, no Jetsnack, temos os seguintes elementos compartilhados:
É possível criar um tipo enumerado para representar o tipo de elemento compartilhado. Nesse exemplo,
o card de lanche completo também pode aparecer em vários lugares diferentes na tela
inicial, como nas seções "Mais populares" e "Recomendados". É possível criar uma
chave que tenha o snackId
, o origin
("Popular" / "Recomendado") e o
type
do elemento compartilhado que será 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 a visibilidade dos elementos compartilhados manualmente
Nos casos em que você não estiver usando AnimatedVisibility
ou AnimatedContent
,
poderá gerenciar a visibilidade do elemento compartilhado por conta própria. Use
Modifier.sharedElementWithCallerManagedVisibility()
e forneça seu próprio
condicional que determina quando um item precisa ficar 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. Entre as principais:
- Não há suporte para interoperabilidade entre as visualizações e o Compose. Isso inclui
qualquer elemento combinável que envolva
AndroidView
, como umDialog
. - Não há suporte para animação automática:
- Elementos combináveis de imagens compartilhadas:
ContentScale
não é animado por padrão. Ele se ajusta à extremidadeContentScale
definida.
- Recorte 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 durante a transição do item.
- Para os casos não suportados, use
Modifier.sharedBounds()
em vez desharedElement()
e adicioneModifier.animateEnterExit()
aos itens.
- Elementos combináveis de imagens compartilhadas: