wandres.dev
COMPOSE: ANIMACIONES · transiciones y gestos

Animaciones de alto nivel: declarar el destino, no la secuencia

Las cuatro APIs que cubren casi todas las animaciones de una app Compose: la familia animate*AsState para valores continuos, AnimatedVisibility para lo que entra y sale del árbol, AnimatedContent para el contenido que se sustituye y Crossfade como su caso degenerado. Qué le hace cada una a la composición y al layout, cuánto cuesta leer un valor animado en la fase equivocada, y un criterio explícito para no dudar nunca al elegir.

⏱ 16 min

En un sistema imperativo animar es describir un procedimiento: coge esta vista, mueve su opacidad durante trescientos milisegundos y avísame al terminar. En Compose ese procedimiento no existe, porque tampoco existe el objeto que lo recibiría: solo hay una función que describe cómo debe verse la pantalla para un estado dado y un runtime que la vuelve a ejecutar cuando ese estado cambia. Animar deja entonces de ser una orden y pasa a ser una declaración de continuidad: este valor no salta a su destino, viaja hacia él. Quién guarda el valor intermedio, quién pide el siguiente fotograma y quién redirige el viaje cuando el destino cambia a mitad de camino son problemas del framework. Las cuatro APIs de esta lección son cuatro formas distintas de hacer esa misma declaración, y elegir bien entre ellas es casi siempre elegir bien qué es exactamente lo que cambia.

🎯 Al terminar esta lección sabrás
  • Entender por qué animar en Compose es declarar un destino y no ejecutar una secuencia.
  • Usar la familia animate*AsState y medir su coste real en recomposiciones por fotograma.
  • Distinguir AnimatedVisibility de AnimatedContent por lo que cada uno le hace a la composición.
  • Elegir entre AnimatedContent y Crossfade con un criterio explícito y defendible.

Un valor que viaja en vez de saltar

La familia animate*AsState es la puerta de entrada y la que más se usa. Son funciones componibles que reciben un valor objetivo y devuelven un State cuyo contenido se aproxima a ese objetivo fotograma a fotograma. Hay una variante por tipo animable —animateFloatAsState, animateDpAsState, animateColorAsState, animateOffsetAsState— porque interpolar requiere saber convertir el tipo a un vector de números y de vuelta.

@Composable
fun BotonPrincipal(seleccionado: Boolean, onClick: () -> Unit) {
    val color by animateColorAsState(
        targetValue = if (seleccionado) MaterialTheme.colorScheme.primary
                      else MaterialTheme.colorScheme.surfaceVariant,
        label = "colorBoton",
    )
    val elevacion by animateDpAsState(
        targetValue = if (seleccionado) 8.dp else 0.dp,
        label = "elevacionBoton",
    )
    Surface(color = color, shadowElevation = elevacion, onClick = onClick) {
        Text("Continuar", Modifier.padding(16.dp))
    }
}

Por dentro no hay magia: la función recuerda un Animatable y lanza una corrutina en la composición. Cuando el objetivo cambia, no reinicia nada desde cero, sino que redirige la animación en curso hacia el nuevo destino conservando la velocidad instantánea. Esa interrumpibilidad viene de fábrica y es la razón de que un botón pulsado y soltado a toda prisa nunca dé un tirón. El parámetro label, que muchos omiten, es el nombre con el que la animación aparece en el inspector de Android Studio; ponerlo cuesta cinco segundos y ahorra media hora cuando algo se mueve y nadie sabe quién lo mueve.

Hay una consecuencia de rendimiento que conviene interiorizar pronto. El valor devuelto es un State, y leerlo dentro del cuerpo de un componible provoca una recomposición por fotograma de todo el ámbito que lo lee. A sesenta o ciento veinte hercios eso es mucho trabajo si el ámbito es grande. La disciplina consiste en bajar la lectura hasta donde de verdad se necesita, o mejor aún, mover la lectura a una fase posterior usando los modificadores que reciben una lambda: la variante de Modifier.offset con lambda lee en la fase de layout y Modifier.graphicsLayer con lambda lee en la de dibujo, saltándose por completo la recomposición.

💡
Anima en la fase más tardía que puedas

Compose tiene tres fases por fotograma: composición, layout y dibujo. Una animación de opacidad, escala, rotación o traslación no cambia el árbol ni las medidas, así que no tiene por qué tocar las dos primeras. Escribir Modifier.graphicsLayer con una lambda que lee el valor animado, en vez de pasar el valor directamente como parámetro, mueve la lectura a la fase de dibujo y convierte una animación de sesenta recomposiciones por segundo en cero. Es la optimización de animación más rentable de Compose y no cambia una línea de la lógica.

Entrar y salir sin dejar de existir a destiempo

animate*AsState sirve para valores que existen antes y después. No sirve para algo que aparece y desaparece, porque un componible que ya no está en el árbol no puede animar su salida: simplemente no se ejecuta. Ese es el problema exacto que resuelve AnimatedVisibility, y su valor real no está en las transiciones bonitas sino en que retiene a sus hijos en la composición hasta que la animación de salida termina.

AnimatedVisibility(
    visible = hayError,
    enter = fadeIn() + expandVertically(),
    exit = fadeOut() + shrinkVertically(),
) {
    TarjetaDeError(mensaje)
}

Las transiciones se combinan con el operador de suma y se dividen en dos familias que conviene no mezclar por descuido. Las de tipo fadeIn, scaleIn y slideIn afectan solo al dibujo: el elemento ocupa su espacio completo desde el primer fotograma y los vecinos no se enteran. Las de tipo expandVertically y shrinkHorizontally sí afectan a la medida, de modo que el resto de la columna se reacomoda progresivamente. Una tarjeta de error que aparece con un simple fadeIn empujará de golpe todo lo que tenga debajo; con expandVertically el empujón se reparte en el tiempo. Elegir mal aquí es la causa más frecuente de que una animación correcta se sienta brusca.

Cuando hace falta saber en qué punto está la transición —para liberar recursos al terminar la salida, o para animar la primera aparición de la pantalla, que con el parámetro booleano no ocurre— se usa la variante con estado observable:

val estado = remember { MutableTransitionState(initialState = false) }
estado.targetState = mostrarPanel

AnimatedVisibility(visibleState = estado, enter = slideInVertically(), exit = fadeOut()) {
    PanelDetalle()
}

if (estado.isIdle && !estado.currentState) liberarRecursosDelPanel()

Dentro del bloque, además, el receptor es un AnimatedVisibilityScope, que ofrece Modifier.animateEnterExit para que cada hijo entre y salga con su propia especificación y su propio retardo. Ahí es donde se construyen las entradas escalonadas sin escribir un solo temporizador.

Sustituir un contenido por otro

Queda el tercer caso, que no es aparecer ni desaparecer sino reemplazar: el paso dos de un formulario que da paso al tres, un contador que cambia de cifra, una cabecera que muestra otro título. AnimatedContent recibe un estado objetivo y mantiene brevemente en pantalla el contenido viejo y el nuevo a la vez, animando la salida de uno, la entrada del otro y, si se le pide, el cambio de tamaño del contenedor.

AnimatedContent(
    targetState = paso,
    transitionSpec = {
        if (targetState > initialState) {
            slideInHorizontally { ancho -> ancho } togetherWith
                slideOutHorizontally { ancho -> -ancho }
        } else {
            slideInHorizontally { ancho -> -ancho } togetherWith
                slideOutHorizontally { ancho -> ancho }
        }.using(SizeTransform(clip = false))
    },
    contentKey = { it },
    label = "pasoFormulario",
) { pasoActual ->
    ContenidoDelPaso(pasoActual)
}

El bloque transitionSpec recibe como contexto el estado inicial y el objetivo, y por eso puede decidir la dirección del deslizamiento: hacia delante o hacia atrás según el sentido del cambio. El SizeTransform es la pieza que suele faltar cuando el resultado se ve mal; sin él, el contenedor salta al tamaño nuevo mientras los contenidos se deslizan. Y contentKey evita animar cuando el estado cambia en un campo que no altera lo que se ve.

Crossfade es el caso degenerado de todo lo anterior: un AnimatedContent con fundido cruzado y sin transformación de tamaño. Sigue siendo la elección correcta cuando lo que cambia no tiene dirección ni continuidad espacial —el contenido de un panel que alterna entre cargando, vacío y lista— y su brevedad comunica esa ausencia de dirección a quien lee el código.

flowchart TD
A[Que cambia exactamente cuando cambia el estado] --> B[Un valor continuo: color, tamano, posicion]
A --> C[Un elemento aparece o desaparece del arbol]
A --> D[Un contenido se sustituye por otro distinto]
B --> B1[animateDpAsState y familia]
C --> C1[AnimatedVisibility]
D --> D1[El cambio tiene direccion o cambia de tamano]
D1 -->|si| D2[AnimatedContent con SizeTransform]
D1 -->|no| D3[Crossfade]
style B1 fill:#a6e3a1,color:#11111b
style C1 fill:#89b4fa,color:#11111b
style D2 fill:#f9e2af,color:#11111b

El criterio, en cuatro tarjetas

Valor continuo

Si el elemento existe antes y después y solo cambia una propiedad suya, la respuesta es animate*AsState. Es la más barata de escribir y la que más se beneficia de leerse en la fase de dibujo.

🚪

Presencia

Si el elemento está o no está, la respuesta es AnimatedVisibility. Nadie más puede animar una salida, porque nadie más mantiene vivo en la composición a algo que la lógica ya dio por muerto.

🔄

Sustitución

Si un contenido cede el sitio a otro y ese relevo tiene dirección o cambio de tamaño, la respuesta es AnimatedContent con su transitionSpec y su SizeTransform.

🌫️

Relevo neutro

Si el relevo no tiene dirección ni geometría —cargando, vacío, contenido— Crossfade dice justo eso y nada más. Usar la API más simple que expresa la intención también es documentación.

La animación no es adorno: es la prueba de que el estado tiene historia

Conviene entender por qué un framework declarativo necesita APIs de animación tan cuidadosamente separadas, porque la respuesta explica media plataforma. Un sistema declarativo puro describe la pantalla como una función del estado, y esa función es atemporal: dado un estado, hay una imagen y solo una. El problema es que la percepción humana no funciona así. Cuando dos imágenes se suceden sin transición, el sistema visual no interpreta que un objeto se movió, sino que un objeto desapareció y otro apareció; la continuidad de identidad —esto de aquí es lo mismo que estaba allí— la construye el cerebro a partir del movimiento, no de la semejanza. Por eso una interfaz sin animación obliga a reconstruir mentalmente qué cambió en cada salto, y por eso una interfaz bien animada se entiende sin esfuerzo aunque nadie repare conscientemente en las animaciones. Compose resuelve la tensión de una forma elegante: la función sigue siendo atemporal, y el tiempo se introduce como un estado más que el runtime posee y actualiza por ti. animate*AsState no ejecuta una animación, declara que ese valor tiene inercia; AnimatedVisibility no anima una desaparición, declara que la muerte de un subárbol es un proceso y no un instante; AnimatedContent no cruza dos vistas, declara que un relevo de contenido es un evento con dirección. Las tres son la misma idea aplicada a tres capas distintas del modelo: al valor, a la presencia y a la identidad. Quien elige la API por el efecto visual que produce acaba peleando contra el framework en cuanto la interfaz se complica, porque escogió por el síntoma. Quien elige por lo que cambia en el modelo descubre que la animación correcta casi siempre se escribe sola, y que las que cuesta escribir suelen estar señalando que el estado estaba mal modelado.

⚔️ Clasifica antes de animar
  1. Coge una pantalla tuya y haz una lista de todo lo que cambia visualmente. Clasifica cada entrada en valor continuo, presencia o sustitución, sin escribir todavía una línea de código.
  2. Implementa la primera categoría con animate*AsState y comprueba con el inspector de animaciones de Android Studio que aparece con el label que le pusiste.
  3. Toma una animación de traslación u opacidad que hayas escrito leyendo el valor en la composición y reescríbela con Modifier.graphicsLayer recibiendo una lambda. Mide la diferencia de recomposiciones.
  4. Sustituye un Crossfade que tenga cambio de tamaño por un AnimatedContent con SizeTransform y describe qué salto desaparece.
  5. Provoca a propósito el error de animar la aparición inicial de una pantalla con el parámetro booleano de AnimatedVisibility, comprueba que no ocurre nada y arréglalo con MutableTransitionState.