wandres.dev
COMPOSE: ANIMACIONES · transiciones y gestos

Transiciones compartidas: el efecto héroe entre pantallas

SharedTransitionLayout y el elemento que sobrevive al cambio de pantalla. Por qué el efecto héroe es difícil en un sistema declarativo, qué hacen exactamente sharedElement y sharedBounds, cómo se conecta con AnimatedContent y con la navegación, y qué ajustes finos separan una transición creíble de un elemento que parpadea, se recorta o llega tarde.

⏱ 17 min

Hay una transición que todo el mundo reconoce aunque no sepa nombrarla: tocas una miniatura en una lista y esa misma miniatura crece, se desplaza y se convierte en la cabecera de la pantalla de detalle. El efecto héroe funciona tan bien porque no informa de nada, sino que responde a una pregunta que el ojo se hace sin permiso: dónde ha ido lo que estaba mirando. Mantener la identidad visual a través del cambio convierte dos pantallas en dos vistas del mismo objeto. Y es, técnicamente, de lo más difícil que puede pedirse a un sistema declarativo, porque exige interpolar entre dos composables que ni se conocen, viven en subárboles distintos y, en el momento en que la animación debe empezar, uno de ellos ni siquiera ha sido medido todavía.

🎯 Al terminar esta lección sabrás
  • Entender por qué un elemento compartido necesita conocer el futuro del layout y quién se lo da.
  • Montar un SharedTransitionLayout con AnimatedContent y claves estables.
  • Distinguir sharedElement de sharedBounds y elegir según el contenido interpole o no.
  • Diagnosticar los fallos clásicos: recortes, orden de pintado, claves duplicadas y textos que saltan.

El problema: interpolar entre dos desconocidos

En un sistema de vistas imperativo el truco tradicional consistía en medir el origen, medir el destino, crear una vista falsa por encima de todo, moverla y descartarla. Ese guion sigue siendo, en esencia, lo que ocurre: durante la transición, el elemento se pinta en una capa superpuesta que dibuja por encima de las dos pantallas mientras sus límites viajan de un rectángulo al otro.

Lo que cambia es de dónde salen esos rectángulos. En Compose los da el sistema de layout con anticipación —lookahead—, una pasada de medida adicional que calcula dónde acabará cada elemento antes de que se dibuje, de modo que la animación puede interpolar entre el sitio actual y el sitio final sin que nadie tenga que medir a mano ni congelar capturas de pantalla. SharedTransitionLayout no es más que el ámbito que activa esa maquinaria y ofrece la capa superpuesta compartida.

Hacen falta tres piezas y ninguna es opcional. Primero, un SharedTransitionLayout que envuelva a las dos pantallas y aporte el SharedTransitionScope. Segundo, algo que gestione el relevo entre pantallas y aporte un AnimatedVisibilityScope: normalmente un AnimatedContent, un AnimatedVisibility o el destino de un grafo de navegación. Y tercero, una clave que identifique al mismo elemento en los dos lados.

SharedTransitionLayout {
    AnimatedContent(targetState = seleccionado, label = "listaDetalle") { id ->
        if (id == null) {
            ListaDeGatos(
                onAbrir = { seleccionado = it },
                modificadorDeFoto = { gatoId ->
                    Modifier.sharedElement(
                        sharedContentState = rememberSharedContentState(key = "foto-$gatoId"),
                        animatedVisibilityScope = this@AnimatedContent,
                    )
                },
            )
        } else {
            DetalleDelGato(
                id = id,
                modificadorDeFoto = Modifier.sharedElement(
                    sharedContentState = rememberSharedContentState(key = "foto-$id"),
                    animatedVisibilityScope = this@AnimatedContent,
                ),
            )
        }
    }
}

La clave es lo único que empareja los dos lados, y de ahí salen dos reglas duras. Debe ser estable entre composiciones, así que se construye a partir del identificador del dato y nunca de la posición en la lista. Y debe ser única dentro del ámbito en cada instante: dos elementos visibles con la misma clave dejan a la capa superpuesta sin saber cuál es cuál, y el resultado es un parpadeo o un elemento que desaparece. Muchos equipos usan un tipo de datos propio como clave en vez de una cadena, precisamente para que el compilador impida colisiones entre familias distintas de elementos compartidos.

sharedElement frente a sharedBounds

Las dos APIs mueven límites de un rectángulo a otro, pero parten de supuestos distintos sobre el contenido y confundirlas produce artefactos difíciles de diagnosticar.

Modifier.sharedElement asume que el contenido de los dos lados es el mismo: la misma foto, el mismo icono. Durante la transición se dibuja un único contenido, escalado desde el rectángulo de origen hasta el de destino. Es lo correcto para imágenes y para cualquier cosa cuyo aspecto no cambie salvo en tamaño.

Modifier.sharedBounds asume lo contrario: los dos lados tienen contenidos distintos que comparten un espacio. Lo que se interpola es el contenedor, mientras los contenidos entran y salen con sus propias transiciones, indicadas en los parámetros enter y exit. Es la elección para una tarjeta que se convierte en pantalla completa, para una fila que se despliega en un panel, y en general para cualquier caso en el que el interior del elemento cambie.

Modifier.sharedBounds(
    sharedContentState = rememberSharedContentState(key = TarjetaClave(id)),
    animatedVisibilityScope = this@AnimatedContent,
    enter = fadeIn(),
    exit = fadeOut(),
    resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds(),
    boundsTransform = { inicial, objetivo ->
        spring(dampingRatio = 0.9f, stiffness = Spring.StiffnessMediumLow)
    },
)

El resizeMode decide cómo se adapta el contenido mientras el contenedor cambia de tamaño. ScaleToBounds escala gráficamente, lo que es barato y suave pero deforma el texto si las proporciones cambian mucho. RemeasureToBounds vuelve a medir el contenido en cada fotograma, lo que respeta la tipografía y los saltos de línea a costa de trabajo por fotograma. La regla práctica es escalar cuando el contenido es una imagen o el cambio de proporción es pequeño, y remedir cuando hay texto que de otro modo se vería estirado.

El boundsTransform es el parámetro que más rendimiento estético da por línea escrita. Recibe los rectángulos inicial y final y devuelve la especificación del viaje, así que ahí se decide si el elemento llega con un resorte suave, si tarda más que el resto de la transición, o incluso si describe un arco combinando un keyframes sobre la trayectoria en vez de una recta. El movimiento en arco es lo que separa una transición correcta de una que parece cara.

💡
La transición ocurre en una capa superpuesta, y eso tiene consecuencias

Mientras dura el viaje, el elemento se saca del orden normal de pintado y se dibuja sobre todo lo demás. Por eso, si un contenedor con clip debía recortarlo, deja de hacerlo, y se ve la foto entera saliéndose de su tarjeta. Los parámetros clipInOverlayDuringTransition y renderInOverlayDuringTransition existen justo para eso, y zIndexInOverlay decide qué elemento compartido va delante cuando hay varios viajando a la vez. Cuando algo se ve por encima de lo que no debería, el culpable es casi siempre esta capa.

Integrarlo con la navegación

En una app real las dos pantallas no son dos ramas de un AnimatedContent, sino dos destinos del grafo de navegación. La estructura no cambia: el SharedTransitionLayout envuelve al contenedor de navegación, y cada destino recibe su propio ámbito de visibilidad animada, que la librería expone como receptor del bloque de cada destino.

SharedTransitionLayout {
    NavHost(navController, startDestination = Ruta.Lista) {
        composable<Ruta.Lista> {
            PantallaLista(
                ambitoCompartido = this@SharedTransitionLayout,
                ambitoVisibilidad = this,
                onAbrir = { navController.navigate(Ruta.Detalle(it)) },
            )
        }
        composable<Ruta.Detalle> { entrada ->
            PantallaDetalle(
                ambitoCompartido = this@SharedTransitionLayout,
                ambitoVisibilidad = this,
                id = entrada.toRoute<Ruta.Detalle>().id,
            )
        }
    }
}

Pasar los dos ámbitos como parámetros explícitos es verboso pero honesto: deja escrito que la pantalla participa en una transición compartida y evita el acoplamiento invisible de guardarlos en un valor local de composición. Sea cual sea la vía, el requisito de fondo es el mismo y conviene tenerlo presente al diseñar la arquitectura de pantallas: los dos extremos del elemento compartido tienen que estar bajo el mismo SharedTransitionLayout. Si el detalle se abre en otra actividad, o en un grafo hermano que no comparte ese ámbito, no hay transición compartida posible y hay que rediseñar o renunciar.

flowchart LR
A[Pantalla lista mide su miniatura] --> B[Pasada de lookahead calcula el destino]
C[Pantalla detalle mide su cabecera] --> B
B --> D[Capa superpuesta dibuja un solo elemento]
D --> E[boundsTransform interpola los limites]
E --> F[Al terminar el elemento vuelve a su pantalla]
style B fill:#f9e2af,color:#11111b
style D fill:#a6e3a1,color:#11111b

Los fallos que verás y por qué ocurren

🔑

Claves inestables o duplicadas

Usar el índice de la lista como clave rompe la correspondencia en cuanto la lista se reordena. Dos elementos visibles con la misma clave dejan la transición sin destino y producen parpadeos.

✂️

Recortes perdidos

Un elemento que se salía de su tarjeta redondeada durante el viaje no está mal animado: está dibujándose en la capa superpuesta, fuera del clip de su padre. Se corrige con los parámetros de recorte en superposición.

🔤

Texto que salta

Un título que cambia de tamaño entre pantallas se estira feo con ScaleToBounds. Remedir a los límites respeta la tipografía, y skipToLookaheadSize permite fijar de entrada el tamaño final.

⏱️

Duraciones desacopladas

Si el elemento compartido y la transición de pantalla usan especificaciones distintas, uno llega antes que el otro y la ilusión se rompe. Compartir el resorte entre ambos suele arreglarlo de golpe.

El elemento compartido es la prueba de que la identidad no vive en el árbol

Detrás de esta API hay una idea que cambia la forma de pensar una interfaz declarativa. Un árbol de composición es una descripción de lo que hay ahora, y su noción de identidad es puramente estructural: dos composables son el mismo si ocupan la misma posición en el árbol entre dos recomposiciones. Esa definición basta para el noventa por ciento del trabajo y es la que permite al runtime reutilizar nodos y saltarse subárboles. Pero es una definición demasiado pobre para el usuario, que no ve árboles sino objetos: la foto del gato que estaba en la lista y la que ahora ocupa la cabecera son el mismo gato, aunque estructuralmente no compartan absolutamente nada. Las transiciones compartidas existen para introducir en el sistema esa segunda noción de identidad, la semántica, y hacerlo tiene un coste conceptual concreto: hay que nombrar. La clave que escribes no es un detalle de implementación, es la afirmación de que dos nodos de árboles distintos son manifestaciones del mismo objeto del dominio, y por eso las claves inestables producen fallos que se parecen tanto a los de una lista sin claves estables: en ambos casos el sistema perdió el rastro de qué es qué. La segunda mitad de la idea es igual de interesante y es la que hizo falta inventar en Compose: para animar entre dos estados del layout hay que conocer el segundo antes de dibujarlo, y un sistema que mide una sola vez por fotograma no puede saberlo. De ahí la pasada de anticipación, que le permite al framework preguntarse dónde estaría esto si el cambio ya hubiera ocurrido y usar la respuesta para interpolar. Esa capacidad de mirar un fotograma hacia el futuro es lo que convierte al layout en algo animable, y no solo a las propiedades de dibujo; el efecto héroe es su demostración más vistosa, pero la misma maquinaria sostiene la reordenación de elementos en una lista o el reacomodo de un contenedor adaptativo. Entender que existe explica por qué estas animaciones son posibles aquí y eran un truco de capas superpuestas en todo lo anterior.

⚔️ Construye un héroe honesto
  1. Monta una lista y un detalle dentro de un SharedTransitionLayout con AnimatedContent, y comparte la imagen con sharedElement y una clave derivada del identificador del dato.
  2. Cambia la clave para que use el índice de la lista, reordena los datos y observa el fallo. Explica con precisión qué perdió el sistema.
  3. Sustituye la imagen compartida por una tarjeta entera con sharedBounds y contenidos distintos a cada lado. Ajusta enter y exit hasta que el interior no parpadee.
  4. Comprueba el efecto del resizeMode con un título largo: compara escalar a los límites con remedir, y decide cuál usarías en producción y por qué.
  5. Sustituye el boundsTransform por defecto por un resorte propio y luego por un keyframes que describa un arco, y compara cuál lee mejor el ojo.