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. Elas são usadas com frequência para navegação, permitindo que você conecte visualmente diferentes telas à medida que um usuário navega entre elas.
Por exemplo, no vídeo abaixo, você pode ver que a imagem e o título do minijogo são compartilhados da página de listagem para a página de detalhes.
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 umSharedTransitionScope
. Os elementos combináveis precisam estar em umSharedTransitionScope
para usar os modificadores de elemento compartilhado.Modifier.sharedElement()
: o modificador que sinaliza para oSharedTransitionScope
o elemento combinável que precisa ser associado a outro elemento combinável.Modifier.sharedBounds()
: o modificador que sinaliza para oSharedTransitionScope
que os limites desse elemento combinável devem ser usados como os limites do contêiner em que a transição deve ocorrer. Ao contrário desharedElement()
, osharedBounds()
foi projetado para conteúdo visualmente diferente.
Um conceito importante ao criar elementos compartilhados no Compose é como eles funcionam com sobreposições e recortes. Consulte a seção Clipping e overlays para saber mais sobre esse importante tópico.
Uso básico
A transição a seguir será criada nesta seção, passando do item "lista" menor para o item detalhado maior:
A melhor maneira de usar Modifier.sharedElement()
é em conjunto com
AnimatedContent
, AnimatedVisibility
ou NavHost
, porque ele gerencia
a transição entre elementos combináveis automaticamente.
O ponto de partida é um AnimatedContent
básico existente que tem um
MainContent
e um elemento combinável DetailsContent
antes de adicionar elementos compartilhados:
Para fazer com que os elementos compartilhados sejam animados entre os dois layouts, envolva o elemento combinável
AnimatedContent
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 combináveis nos dois elementos combináveis correspondentes. Crie um objetoSharedContentState
e lembre-se dele 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 que o item seja lembrado. OAnimatedContentScope
é 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 uma correspondência de elemento compartilhado ocorreu, extraia
rememberSharedContentState()
em uma variável e consulte isMatchFound
.
O que resulta na seguinte animação automática:
A cor e o tamanho do plano de fundo 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 precisa compartilhar a mesma área entre os 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()
, somente o conteúdo de destino é renderizado nos limites de transformação.Modifier.sharedBounds()
tem parâmetrosenter
eexit
para especificar como o conteúdo deve fazer a transição, de forma semelhante ao funcionamento deAnimatedContent
. - O caso de uso mais comum de
sharedBounds()
é o padrão de transformação de contêiner, enquanto o exemplo de caso de uso desharedElement()
é uma transição de herói. - Ao usar elementos combináveis
Text
, osharedBounds()
é a melhor opção para oferecer suporte a mudanças de fonte, como a 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 nos 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() ) // ... ) { // ... } } }
Noções básicas sobre 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 em 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 escopos necessários em um
CompositionLocal, use recebedores 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 para salvar e usar. 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árias AnimatedContent
aninhadas, 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, você poderá 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
os elementos compartilhados também funcionam com AnimatedVisibility
.
Por exemplo, neste exemplo de grade lenta, cada elemento é envolvido em
AnimatedVisibility
. Quando o item é clicado, o conteúdo tem o
efeito visual de ser retirado da interface para 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 } ) }
Ordem dos modificadores
Com Modifier.sharedElement()
e Modifier.sharedBounds()
, a ordem da
cadeia de modificadores é importante,
assim como o restante do Compose. A colocação incorreta 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 não correspondentes: observe como a animação do elemento compartilhado parece um pouco fora do lugar, porque precisa ser redimensionada para os limites incorretos. |
---|---|
Os modificadores usados antes dos modificadores de elemento compartilhado fornecem restrições aos modificadores de elemento compartilhado, que são usados para derivar os limites iniciais e alvo e, em seguida, a animação dos limites.
Os modificadores usados após os modificadores de elemento compartilhado usam as restrições anteriores para medir e calcular o tamanho de destino da filha. Os modificadores de elemento compartilhado criam uma série de restrições animadas para transformar gradualmente o elemento filho do tamanho inicial no 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 exibe a criança usando as restrições de destino e, em vez disso, 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:
Você pode criar um tipo enumerado para representar o tipo de elemento compartilhado. Neste exemplo,
o card de informações 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" / "Recomendada") 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, já que implementam hashCode()
e
isEquals()
.
Gerenciar a visibilidade dos elementos compartilhados manualmente
Nos casos em que você não usa AnimatedVisibility
ou AnimatedContent
,
é possível gerenciar a visibilidade do elemento compartilhado. 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. Mais especificamente:
- Não há suporte para interoperabilidade entre Views e Compose. Isso inclui
qualquer elemento combinável que envolva
AndroidView
, como umDialog
. - Não há suporte para animação automática para o seguinte:
- Combináveis de imagem compartilhada:
ContentScale
não é animado por padrão. Ele é fixado no final definidoContentScale
.
- Recorte de forma: não há suporte integrado para animação automática entre formas, por exemplo, animando de um quadrado para um círculo conforme o item transita.
- Para os casos sem suporte, use
Modifier.sharedBounds()
em vez desharedElement()
e adicioneModifier.animateEnterExit()
aos itens.
- Combináveis de imagem compartilhada: