Transiciones de elementos compartidos en Compose

Las transiciones de elementos compartidos son una forma fluida de realizar transiciones 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 un usuario navega entre ellas.

Por ejemplo, en el siguiente video, puedes ver que la imagen y el título del snack se comparten desde la página de la ficha hasta la página de detalles.

Figura 1: Demostración del elemento compartido de Jetsnack.

En Compose, hay algunas APIs de alto nivel que te ayudan a crear elementos compartidos:

  • SharedTransitionLayout: Es el diseño más externo que se requiere para implementar transiciones de elementos compartidos. Proporciona un SharedTransitionScope. Los elementos componibles deben estar en un SharedTransitionScope para usar los modificadores de elementos compartidos.
  • Modifier.sharedElement(): Es el modificador que indica al SharedTransitionScope el elemento componible que se debe hacer coincidir con otro elemento componible.
  • Modifier.sharedBounds(): Es el modificador que indica a SharedTransitionScope que los límites de este elemento componible se deben usar como los límites del contenedor en el que debe tener lugar la transición. A diferencia de sharedElement(), 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 Recorte y superposiciones para obtener más información sobre este tema importante.

Uso básico

En esta sección, se creará la siguiente transición, que pasará del elemento de "lista" más pequeño al elemento detallado más grande:

Figura 2: Ejemplo básico de una transición de elementos compartidos entre dos elementos componibles.

La mejor manera de usar Modifier.sharedElement() es en conjunto con AnimatedContent, AnimatedVisibility o NavHost, ya que esto administra la transición entre los elementos componibles automáticamente por ti.

El punto de partida es un AnimatedContent básico existente que tiene un MainContent y un elemento DetailsContent componible antes de agregar elementos compartidos:

Figura 3: Comienza AnimatedContent sin transiciones de elementos compartidos.

  1. Para que los elementos compartidos se animen entre los dos diseños, rodea el elemento AnimatedContent componible con SharedTransitionLayout. Los alcances de SharedTransitionLayout y AnimatedContent se pasan a MainContent y 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. Agrega Modifier.sharedElement() a la cadena de modificadores componibles en los dos elementos componibles que coincidan. Crea un objeto SharedContentState y recuérdalo con rememberSharedContentState(). El objeto SharedContentState almacena la clave única que determina los elementos que se comparten. Proporciona una clave única para identificar el contenido y usa rememberSharedContentState() para el elemento que se recordará. El AnimatedContentScope 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 elemento compartido, extrae rememberSharedContentState() en una variable y consulta isMatchFound.

Esto genera la siguiente animación automática:

Figura 4: Ejemplo básico de una transición de elementos compartidos entre dos elementos componibles.

Es posible que notes que el color y el tamaño de fondo de todo el contenedor siguen usando la configuración predeterminada de AnimatedContent.

Límites compartidos versus elemento compartido

Modifier.sharedBounds() es similar a Modifier.sharedElement(). Sin embargo, los modificadores son diferentes en los siguientes aspectos:

  • sharedBounds() es para el contenido que es visualmente diferente, pero que debe compartir la misma área entre estados, mientras que sharedElement() 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 con sharedElement() solo se renderiza el contenido de destino en los límites de transformación. Modifier.sharedBounds() tiene parámetros enter y exit para especificar cómo debe realizarse la transición del contenido, de manera similar a como funciona AnimatedContent.
  • El caso de uso más común para sharedBounds() es el patrón de transformación de contenedor, mientras que para sharedElement(), el ejemplo de caso de uso es una transición hero.
  • Cuando se usan elementos Text componibles, se prefiere sharedBounds() para admitir cambios de fuente, como la transición entre cursiva y negrita, o cambios de color.

En el ejemplo anterior, agregar Modifier.sharedBounds() a Row y Column en los dos escenarios 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()
                )
                // ...

        ) {
            // ...
        }
    }
}

Figure 5: Límites compartidos entre dos elementos componibles.

Información sobre los alcances

Para usar Modifier.sharedElement(), el elemento componible debe estar en un SharedTransitionScope. El elemento SharedTransitionLayout componible proporciona el SharedTransitionScope. Asegúrate de colocarlo en el mismo punto de nivel superior de la jerarquía de la IU que contiene los elementos que deseas compartir.

En general, los elementos componibles también deben colocarse dentro de un AnimatedVisibilityScope. Por lo general, esto se proporciona con AnimatedContent para cambiar entre elementos componibles o cuando se usa AnimatedVisibility directamente, o bien con la función de componibilidad NavHost, a menos que administres la visibilidad de forma manual. Para usar varios permisos, guarda los permisos requeridos en un CompositionLocal, usa receptores de contexto en Kotlin o pasa los permisos como parámetros a tus funciones.

Usa CompositionLocals en la situación en la que tienes varios permisos para hacer un seguimiento o una jerarquía profundamente anidada. Un CompositionLocal te permite elegir los permisos exactos que deseas guardar y usar. Por otro lado, cuando usas receptores de contexto, otros diseños en tu jerarquía podrían anular accidentalmente los alcances proporcionados. Por ejemplo, si tienes varios AnimatedContent anidados, es posible que se anulen 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 de forma profunda, 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 con 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(
                            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 compartidos con AnimatedVisibility.

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 colocación incorrecta de 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, habrá 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 se ve un poco desalineada, ya que debe cambiar de tamaño 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 diseña el elemento secundario con las restricciones de destino y, en cambio, usa un factor de escala para realizar la animación en lugar de cambiar el tamaño del diseño.

Claves únicas

Cuando trabajas con elementos compartidos complejos, es una buena práctica 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:

Figura 7: Imagen que muestra Jetsnack con anotaciones para cada parte de la IU.

Podrías 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 diferentes de la pantalla principal, por ejemplo, en las secciones "Popular" y "Recomendado". Puedes crear una clave que tenga snackId, origin ("Popular" o "Recomendado") y 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 manualmente la visibilidad de los elementos compartidos

En los casos en los que no uses AnimatedVisibility o AnimatedContent, puedes administrar la visibilidad del elemento compartido 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, se destacan los siguientes:

  • No se admite la interoperabilidad entre Views y Compose. Esto incluye cualquier elemento componible que encapsule AndroidView, como Dialog o ModalBottomSheet.
  • No hay compatibilidad con la animación automática para lo siguiente:
    • Elementos componibles de Shared Image:
      • ContentScale no se anima de forma predeterminada. Se ajusta al final establecido ContentScale.
    • Recorte de formas: No hay compatibilidad integrada para la animación automática entre formas, por ejemplo, la animación de un cuadrado a un círculo a medida que el elemento realiza la transición.
    • En los casos no admitidos, usa Modifier.sharedBounds() en lugar de sharedElement() y agrega Modifier.animateEnterExit() a los elementos.