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 telas diferentes à medida que um usuário navega entre elas.
Por exemplo, no vídeo a seguir, é possível ver que a imagem e o título do snack 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 umSharedTransitionScopepara usar os modificadores de elementos compartilhados.Modifier.sharedElement(): o modificador que sinaliza para oSharedTransitionScopeo elemento combinável que precisa ser correspondido a outro.Modifier.sharedBounds(): o modificador que sinaliza para oSharedTransitionScopeque os limites desse elemento combinável precisam ser usados como os limites do contêiner para onde 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 recorte. Consulte a seção Recorte e sobreposições para saber mais sobre esse tópico importante.
Uso básico
A transição a seguir será criada nesta seção, fazendo a transição do item "lista" menor para o item detalhado maior:
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.
O ponto de partida é um AnimatedContent básico que tem um elemento combinável MainContent e DetailsContent antes de adicionar elementos compartilhados:
AnimatedContent sem transições de elementos compartilhados.Para animar os elementos compartilhados entre os dois layouts, envolva o elemento combinável
AnimatedContentcomSharedTransitionLayout. Os escopos deSharedTransitionLayouteAnimatedContentsão transmitidos paraMainContenteDetailsContent: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 objetoSharedContentStatee lembre-se dele comrememberSharedContentState(). O objetoSharedContentStatearmazena 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 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 receber informações sobre se uma correspondência de elemento compartilhado ocorreu, extraia rememberSharedContentState() em uma variável e consulte isMatchFound.
Isso 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 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()é para conteúdo visualmente diferente, mas que 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 parâmetrosentereexitpara especificar como o conteúdo precisa fazer a transição, semelhante ao funcionamento deAnimatedContent. - O caso de uso mais comum para
sharedBounds()é o padrão de transformação de contêiner, enquanto parasharedElement()o exemplo de caso de uso é uma transição de elemento principal. - Ao usar elementos combináveis
Text, osharedBounds()é melhor para aceitar 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 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() ) // ... ) { // ... } } }
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.
Geralmente, os elementos combináveis também precisam ser colocados dentro de um AnimatedVisibilityScope. Isso normalmente é fornecido usando AnimatedContent
para alternar entre elementos combináveis ou ao usar AnimatedVisibility diretamente, ou por
a 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 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 aninhada. Um CompositionLocal permite escolher os escopos exatos para salvar e usar. Por outro lado, ao usar 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 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
esses elementos também funcionam com AnimatedVisibility.
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 e inserido 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 } ) }
AnimatedVisibility.Ordem do modificador
Com Modifier.sharedElement() e Modifier.sharedBounds(), a ordem da sua
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 preenchimento 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 de elementos compartilhados 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 obter 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 do elemento secundário. Os modificadores de elemento compartilhado criam uma série de restrições animadas para transformar gradualmente o elemento secundário 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 cria o layout do elemento secundário usando as restrições de destino e usa
um fator de escalonamento 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 estar sujeitas 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 uma enumeração para representar o tipo de elemento compartilhado. Nesse exemplo, o card de snack inteiro também pode aparecer em vários lugares 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, já que implementam hashCode() e isEquals().
Gerenciar a visibilidade de elementos compartilhados manualmente
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 determine 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 visualizações e Compose. Isso inclui qualquer elemento combinável que envolva
AndroidView, como umDialogouModalBottomSheet. - Não há suporte para animação automática para o seguinte:
- Elementos combináveis de imagem compartilhada:
ContentScalenão é animado por padrão. Ele se ajusta aoContentScalefinal definido.
- Recorte de forma : não há suporte integrado para animação automática entre formas. Por exemplo, animar de um quadrado para um círculo à medida que o item faz a transição.
- Para os casos não aceitos, use
Modifier.sharedBounds()em vez desharedElement()e adicioneModifier.animateEnterExit()aos itens.
- Elementos combináveis de imagem compartilhada: