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.
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.
- Entender por qué animar en Compose es declarar un destino y no ejecutar una secuencia.
- Usar la familia
animate*AsStatey medir su coste real en recomposiciones por fotograma. - Distinguir
AnimatedVisibilitydeAnimatedContentpor lo que cada uno le hace a la composición. - Elegir entre
AnimatedContentyCrossfadecon 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.
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.
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.
- 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.
- Implementa la primera categoría con
animate*AsStatey comprueba con el inspector de animaciones de Android Studio que aparece con ellabelque le pusiste. - Toma una animación de traslación u opacidad que hayas escrito leyendo el valor en la composición y reescríbela con
Modifier.graphicsLayerrecibiendo una lambda. Mide la diferencia de recomposiciones. - Sustituye un
Crossfadeque tenga cambio de tamaño por unAnimatedContentconSizeTransformy describe qué salto desaparece. - 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 conMutableTransitionState.