Las transiciones de elementos compartidos son una forma fluida de hacer la transición entre elementos componibles que tienen contenido coherente entre ellos. A menudo, se usan para la navegación, lo que te permite conectar visualmente diferentes pantallas a medida que el usuario navega entre ellas.
Por ejemplo, en el siguiente video, se puede ver que la imagen y el título del bocadillo se comparten desde la página de la ficha hasta la página de detalles.
En Compose, hay algunas APIs de alto nivel que te ayudan a crear elementos compartidos:
SharedTransitionLayout
: Es el diseño más externo necesario para implementar transiciones de elementos compartidos. Proporciona unSharedTransitionScope
. Los elementos componibles deben estar en unSharedTransitionScope
para usar los modificadores de elementos compartidos.Modifier.sharedElement()
: Es el modificador que marca paraSharedTransitionScope
el elemento componible que debe coincidir con otro elemento componible.Modifier.sharedBounds()
: Es el modificador que marca aSharedTransitionScope
que los límites de este elemento componible deben usarse como los límites del contenedor donde debe ocurrir la transición. A diferencia desharedElement()
,sharedBounds()
está diseñado para contenido visualmente diferente.
Un concepto importante cuando se crean elementos compartidos en Compose es cómo funcionan con las superposiciones y el recorte. Consulta la sección de recorte y superposiciones para obtener más información sobre este tema importante.
Uso básico
En esta sección, se compilará la siguiente transición, que pasa del elemento "lista" más pequeño al elemento detallado más grande:
La mejor manera de usar Modifier.sharedElement()
es en conjunto con AnimatedContent
, AnimatedVisibility
o NavHost
, ya que administra la transición entre elementos componibles automáticamente.
El punto de partida es un AnimatedContent
básico existente que tiene un elemento MainContent
y DetailsContent
componible antes de agregar elementos compartidos:
Para animar los elementos compartidos entre los dos diseños, encierra el elemento componible
AnimatedContent
conSharedTransitionLayout
. Los alcances deSharedTransitionLayout
yAnimatedContent
se pasan aMainContent
yDetailsContent
: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 ) } } }
Agrega
Modifier.sharedElement()
a tu cadena de modificadores componibles en los dos elementos componibles que coincidan. Crea un objetoSharedContentState
y recuérdalo conrememberSharedContentState()
. El objetoSharedContentState
almacena la clave única que determina los elementos que se comparten. Proporciona una clave única para identificar el contenido y usarememberSharedContentState()
para que se recuerde el elemento. ElAnimatedContentScope
se pasa al modificador, que se usa para coordinar la animación.@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 obtener información sobre si se produjo una coincidencia de elementos compartidos, extrae rememberSharedContentState()
en una variable y consulta isMatchFound
.
Esto genera la siguiente animación automática:
Notarás que el color y el tamaño de fondo de todo el contenedor aún usan la configuración predeterminada de AnimatedContent
.
Límites compartidos en comparación con elemento compartido
Modifier.sharedBounds()
es similar a Modifier.sharedElement()
.
Sin embargo, los modificadores son diferentes de las siguientes maneras:
sharedBounds()
es para el contenido que es visualmente diferente, pero debe compartir la misma área entre los estados, mientras quesharedElement()
espera que el contenido sea el mismo.- Con
sharedBounds()
, el contenido que entra y sale de la pantalla es visible durante la transición entre los dos estados, mientras que consharedElement()
, solo se renderiza el contenido de destino en los límites de transformación.Modifier.sharedBounds()
tiene parámetrosenter
yexit
para especificar cómo debe migrar el contenido, de manera similar a cómo funcionaAnimatedContent
. - El caso de uso más común de
sharedBounds()
es el patrón de transformación de contenedores, mientras que parasharedElement()
el caso de uso de ejemplo es una transición de héroe. - Cuando se usan elementos componibles
Text
, se prefieresharedBounds()
para admitir cambios de fuente, como la transición entre itálica y negrita, o cambios de color.
A partir del ejemplo anterior, agregar Modifier.sharedBounds()
a Row
y Column
en las dos situaciones diferentes nos permitirá compartir los límites de los dos y realizar la animación de transición, lo que les permitirá crecer entre sí:
@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() ) // ... ) { // ... } } }
Información sobre los alcances
Para usar Modifier.sharedElement()
, el elemento componible debe estar en una SharedTransitionScope
. El elemento componible SharedTransitionLayout
proporciona el SharedTransitionScope
. Asegúrate de colocar el mismo punto de nivel superior en la jerarquía de la IU que contiene los elementos que deseas compartir.
Por lo general, los elementos componibles también deben colocarse dentro de un AnimatedVisibilityScope
. Por lo general, se proporciona con AnimatedContent
para cambiar entre elementos componibles o cuando se usa AnimatedVisibility
directamente, o con la función de componibilidad NavHost
, a menos que administres la visibilidad de forma manual. Para usar varios alcances, guárdalos en un CompositionLocal, usa receptores de contexto en Kotlin o pasa los alcances como parámetros a tus funciones.
Usa CompositionLocals
en el caso de que tengas varios alcances para hacer un seguimiento o una jerarquía anidada en profundidad. Un CompositionLocal
te permite elegir los permisos exactos que deseas guardar y usar. Por otro lado, cuando usas receptores de contexto,
otros diseños de tu jerarquía podrían anular accidentalmente los alcances proporcionados.
Por ejemplo, si tienes varios AnimatedContent
anidados, se podrían
anular los permisos.
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") } // ... } } } }
De manera alternativa, si tu jerarquía no está anidada en profundidad, puedes pasar los alcances como parámetros:
@Composable fun MainContent( animatedVisibilityScope: AnimatedVisibilityScope, sharedTransitionScope: SharedTransitionScope ) { } @Composable fun Details( animatedVisibilityScope: AnimatedVisibilityScope, sharedTransitionScope: SharedTransitionScope ) { }
Elementos compartidos con AnimatedVisibility
En los ejemplos anteriores, se mostró cómo usar elementos compartidos con AnimatedContent
, pero los elementos compartidos también funcionan con AnimatedVisibility
.
Por ejemplo, en este ejemplo de cuadrícula diferida, cada elemento se une en AnimatedVisibility
. Cuando se hace clic en el elemento, el contenido tiene el efecto visual de salir de la IU y convertirse en un componente similar a un 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 } ) }
Orden de los modificadores
Con Modifier.sharedElement()
y Modifier.sharedBounds()
, el orden de la cadena de modificadores es importante, al igual que con el resto de Compose. La ubicación incorrecta de los modificadores que afectan el tamaño puede provocar saltos visuales inesperados durante la coincidencia de elementos compartidos.
Por ejemplo, si colocas un modificador de padding en una posición diferente en dos elementos compartidos, hay una diferencia visual en la animación.
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 ) } } } }
Límites coincidentes |
Límites no coincidentes: Observa cómo la animación del elemento compartido parece un poco descolocada, ya que debe cambiar de tamaño para adaptarse a los límites incorrectos. |
---|---|
Los modificadores que se usan antes de los modificadores de elementos compartidos proporcionan restricciones a los modificadores de elementos compartidos, que luego se usan para derivar los límites iniciales y de destino, y, posteriormente, la animación de límites.
Los modificadores que se usan después de los modificadores de elementos compartidos usan las restricciones anteriores para medir y calcular el tamaño objetivo del elemento secundario. Los modificadores de elementos compartidos crean una serie de restricciones animadas para transformar gradualmente el elemento secundario del tamaño inicial al tamaño de destino.
La excepción a esto es si usas resizeMode = ScaleToBounds()
para la animación o Modifier.skipToLookaheadSize()
en un elemento componible. En este caso, Compose establece el elemento secundario con las restricciones de destino y, en su lugar, usa un factor de escala para realizar la animación en lugar de cambiar el tamaño del diseño.
Claves únicas
Cuando se trabaja con elementos compartidos complejos, es recomendable crear una clave que no sea una cadena, ya que las cadenas pueden ser propensas a errores de coincidencia. Cada clave debe ser única para que se produzcan coincidencias. Por ejemplo, en Jetsnack, tenemos los siguientes elementos compartidos:
Puedes crear una enumeración para representar el tipo de elemento compartido. En este ejemplo, la tarjeta de snack completa también puede aparecer en varios lugares de la pantalla principal, por ejemplo, en una sección "Popular" y una sección "Recomendada". Puedes crear una clave que tenga el snackId
, el origin
("Popular"/"Recommended") y el type
del elemento compartido que se compartirá:
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 ) ) // ... }
Se recomiendan las clases de datos para las claves, ya que implementan hashCode()
y isEquals()
.
Cómo administrar la visibilidad de los elementos compartidos de forma manual
En los casos en que no uses AnimatedVisibility
o AnimatedContent
, puedes administrar la visibilidad de los elementos compartidos por tu cuenta. Usa Modifier.sharedElementWithCallerManagedVisibility()
y proporciona tu propia condición que determine cuándo un elemento debe ser visible o no:
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) } }
Limitaciones actuales
Estas APIs tienen algunas limitaciones. En particular:
- No se admite la interoperabilidad entre Views y Compose. Esto incluye cualquier elemento componible que une
AndroidView
, como unDialog
. - No se admite la animación automática para lo siguiente:
- Componibles de imágenes compartidas:
ContentScale
no está animado de forma predeterminada. Se ajusta al final establecidoContentScale
.
- Recorte de formas: No hay compatibilidad integrada para la animación automática entre formas (por ejemplo, animar de un cuadrado a un círculo mientras pasa el elemento).
- En los casos no admitidos, usa
Modifier.sharedBounds()
en lugar desharedElement()
y agregaModifier.animateEnterExit()
a los elementos.
- Componibles de imágenes compartidas: