Especificaciones: tween, spring y keyframes
La especificación es la física de la animación: qué curva sigue el valor entre el origen y el destino. Estudia tween con su duración y su easing, spring como sistema masa-resorte-amortiguador con sus dos parámetros reales, keyframes para las coreografías con puntos intermedios, y la razón profunda por la que un resorte se siente natural mientras una curva de duración fija se rompe en cuanto alguien interrumpe el movimiento.
Todas las APIs de animación de Compose comparten un mismo parámetro que casi nadie mira las primeras semanas y que decide por completo cómo se siente la app: animationSpec. Declarar que un valor viaja hacia su destino solo fija los extremos del viaje; la especificación fija la trayectoria en el tiempo, es decir, la velocidad y la aceleración en cada instante intermedio. Y ahí se juega algo más serio que el gusto estético, porque el sistema visual humano es un detector de aceleraciones excelente y un cronómetro pésimo: nadie nota que una transición dura doscientos o trescientos milisegundos, pero todo el mundo nota que un objeto arranca a velocidad máxima o se detiene en seco. Elegir la especificación es elegir qué modelo del mundo físico está imitando tu interfaz.
- Configurar
tweencon duración, retardo y curva de easing, y saber qué comunica cada curva. - Entender
springcomo sistema masa-resorte-amortiguador y manejar sus dos parámetros reales. - Explicar por qué un resorte sobrevive a la interrupción y una duración fija no.
- Construir coreografías con
keyframesy saber cuándorepeatableosnapson la respuesta correcta.
Duración fija: tween y el arte del easing
tween es la especificación clásica, la que existe en todos los sistemas de animación desde hace décadas: un valor recorre el trayecto en un tiempo prefijado siguiendo una curva. Sus tres parámetros son la duración en milisegundos, un retardo opcional y el easing.
val ancho by animateDpAsState(
targetValue = if (expandido) 320.dp else 160.dp,
animationSpec = tween(
durationMillis = 300,
delayMillis = 0,
easing = FastOutSlowInEasing,
),
label = "anchoPanel",
)
El easing es la función que reparte el progreso: recibe una fracción de tiempo entre cero y uno y devuelve una fracción de recorrido. LinearEasing devuelve la identidad y produce ese movimiento mecánico que solo es correcto para giros continuos e indicadores de carga. FastOutSlowInEasing arranca rápido y frena al final, y es la curva de lo que entra en pantalla. LinearOutSlowInEasing conviene a lo que aparece; FastOutLinearInEasing, a lo que se va y ya no importa. Cuando ninguna encaja, CubicBezierEasing acepta los dos puntos de control de una curva de Bézier y permite copiar exactamente la curva que el equipo de diseño trae de su herramienta.
La regla implícita de todo esto es la asimetría: entrar y salir no deben usar la misma curva ni la misma duración, porque no tienen la misma importancia. Lo que llega merece que el ojo lo siga hasta posarse; lo que se va debe desaparecer sin robar atención. Un solo tween compartido para las dos direcciones es la firma de una animación que nadie revisó.
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(250, easing = LinearOutSlowInEasing)) +
expandVertically(tween(250, easing = FastOutSlowInEasing)),
exit = fadeOut(tween(150, easing = FastOutLinearInEasing)) +
shrinkVertically(tween(150)),
) {
PanelDeAvisos()
}
Sobre las duraciones concretas hay más consenso del que parece. Por debajo de unos cien milisegundos el movimiento se percibe como un salto y no aporta continuidad; por encima de unos cuatrocientos empieza a leerse como espera, y el usuario que repite la acción cien veces al día lo sufre. La franja útil para transiciones de interfaz va de ciento cincuenta a trescientos milisegundos, con salidas más cortas que entradas y con duraciones algo mayores cuando el elemento recorre más distancia. Y hay un factor que a menudo se olvida: el sistema permite al usuario escalar o desactivar las animaciones desde las opciones de accesibilidad, así que una app seria consulta esa preferencia y acorta o suprime en consecuencia, en vez de imponer su coreografía a quien pidió no verla.
El resorte: una especificación sin duración
spring no toma una duración porque no la tiene. Modela un sistema físico —una masa unida por un resorte a la posición objetivo, con un amortiguador que disipa energía— y simula su evolución. El tiempo que tarda no se declara: emerge de la distancia que falta, de la rigidez del resorte y de la velocidad que el valor traía.
val desplazamiento by animateDpAsState(
targetValue = if (abierto) 0.dp else (-280).dp,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow,
),
label = "cajon",
)
Los dos parámetros tienen un significado físico preciso. El dampingRatio es el cociente de amortiguamiento: por debajo de uno el sistema es subamortiguado y rebota, cruzando el destino y volviendo; exactamente en uno está críticamente amortiguado y llega en el menor tiempo posible sin sobrepasarlo; por encima de uno es sobreamortiguado y se arrastra con desgana. Las constantes de Compose cubren el rango útil, de DampingRatioHighBouncy a DampingRatioNoBouncy, que vale uno. La stiffness es la constante del resorte y controla cuánta fuerza tira hacia el destino: más rigidez significa más rápido y más nervioso, y sus constantes van de StiffnessVeryLow a StiffnessHigh.
Hay un tercer parámetro casi invisible, visibilityThreshold, y merece una frase. Un resorte matemático nunca llega del todo a su destino, solo se acerca asintóticamente; el umbral define a qué distancia se considera terminado. Las variantes tipadas ya traen umbrales sensatos —un píxel para posiciones, algo minúsculo para colores—, pero al animar un Float que representa una magnitud enorme conviene fijarlo, o la animación seguirá viva y consumiendo fotogramas mucho después de que sea indistinguible del final.
Desde la variante expresiva de Material 3, el tema incluye un MotionScheme con conjuntos de especificaciones ya afinadas y divididas por dos ejes: espacial frente a efectos, y rápido, por defecto o lento. Las animaciones que alteran geometría usan resortes espaciales, con algo de rebote; las que solo afectan a color u opacidad usan resortes sin rebote, porque un color que sobrepasa su destino y vuelve se ve como un parpadeo raro. Tomar la especificación del tema en vez de escribir constantes sueltas hace que toda la app comparta una misma personalidad de movimiento y que un cambio de criterio sea un cambio en un sitio.
Por qué el resorte se siente natural
La respuesta habitual es que imita el mundo físico, y es cierta pero superficial. La razón técnica es la continuidad de la velocidad bajo interrupción, y se ve mejor con un ejemplo. Una tarjeta se desliza hacia la derecha con un tween de trescientos milisegundos. A los cien milisegundos, el usuario cambia de idea y la tarjeta debe volver. Con duración fija, la nueva animación empieza donde esté el valor pero con velocidad inicial cero: el objeto que iba lanzado hacia la derecha se detiene de golpe y arranca hacia la izquierda. Ese frenazo instantáneo es una aceleración infinita, algo que no ocurre en la naturaleza, y el ojo lo detecta aunque no sepa nombrarlo.
El resorte no tiene ese problema porque su estado incluye la velocidad. Al cambiar el destino, la simulación continúa desde la posición y la velocidad actuales: el objeto lanzado hacia la derecha decelera, cambia de sentido y vuelve, describiendo una trayectoria que el ojo lee como un único movimiento con una intención que cambió. Nada se reinicia porque nada había terminado.
Conviene además desconfiar de una intuición muy extendida: la de que un resorte es siempre más lento. Un spring con rigidez alta y sin rebote llega antes que un tween de trescientos milisegundos y se siente más ágil, porque la mayor parte del recorrido la hace en el primer tercio del tiempo. Lo que un resorte no puede darte es la garantía de terminar en un instante concreto, y esa es la única razón legítima para preferir una duración fija.
De ahí sale una heurística sólida: usa spring para todo lo que un usuario pueda interrumpir —arrastres, gestos, paneles, selección, cualquier cosa ligada al dedo— y reserva tween para lo que tiene una duración por naturaleza y nadie va a interrumpir, como el fundido de una imagen al cargarse o una coreografía de entrada. Muchos equipos invierten esta regla por costumbre heredada de otras plataformas y luego pasan meses puliendo transiciones que se sienten pegajosas sin entender por qué.
flowchart TD A[Elijo la especificacion] --> B[El movimiento puede interrumpirse a mitad] B -->|si| S[spring: la velocidad se conserva] B -->|no| C[Necesito puntos intermedios exactos] C -->|si| K[keyframes o keyframesWithSplines] C -->|no| T[tween con easing asimetrico] S --> S1[Geometria: con algo de rebote] S --> S2[Color y opacidad: sin rebote] T --> T1[Entrada lenta al final] T --> T2[Salida rapida y discreta] style S fill:#a6e3a1,color:#11111b style T fill:#89b4fa,color:#11111b style K fill:#f9e2af,color:#11111b
Coreografías: keyframes, repeatable y snap
Cuando el movimiento necesita pasar por valores concretos en instantes concretos, ni el resorte ni la curva bastan. keyframes describe el trayecto por tramos: se fija la duración total y luego se ancla el valor en los milisegundos que interesen, cada tramo con su propio easing.
val escala by animateFloatAsState(
targetValue = if (pulsado) 1f else 1f,
animationSpec = keyframes {
durationMillis = 400
0.9f at 100 using LinearOutSlowInEasing
1.08f at 250
1f at 400
},
label = "rebotePulsacion",
)
La variante keyframesWithSplines interpola con splines monótonas en vez de por tramos independientes, lo que evita los codos de velocidad en cada punto de anclaje; es la elección correcta para trayectorias en dos dimensiones, donde los codos se ven como esquinas en el recorrido.
tween
Duración y curva fijas. Correcto cuando el movimiento tiene un tiempo por naturaleza y nadie lo va a interrumpir: fundidos, entradas coreografiadas, indicadores con ritmo propio.
spring
Sin duración: la trayectoria emerge de la distancia, la rigidez y la velocidad heredada. Obligatorio en todo lo que un dedo pueda tocar o un estado pueda cambiar a mitad.
keyframes
Puntos intermedios exactos con su propio easing por tramo. Para rebotes, sacudidas de error y coreografías donde el camino importa tanto como el destino.
repeatable y snap
Los extremos del catálogo: repetir hasta que alguien pare, o no animar en absoluto. Declarar el salto con snap es más legible que fabricar un caso especial en la lógica.
Alrededor de estas tres especificaciones hay un puñado de envoltorios que resuelven el resto de casos. repeatable repite un número fijo de veces y infiniteRepeatable no para nunca, ambos con un repeatMode que decide si la repetición reinicia o va y vuelve; el segundo es la base de los indicadores de carga y de los brillos de esqueleto, y siempre se combina con rememberInfiniteTransition. snap no anima en absoluto, y aunque suene inútil es la manera declarativa de decir que en esta rama concreta el valor debe saltar, con un retardo opcional. Y delay como parámetro, presente en todas ellas, es lo que convierte varias animaciones simultáneas en una entrada escalonada sin escribir un solo temporizador.
Merece la pena mirar la diferencia entre tween y spring como lo que realmente es: dos ontologías distintas de qué es una animación. tween la concibe como una función del tiempo transcurrido, una tabla que va del instante al valor, y por tanto necesita conocer de antemano dos cosas: cuándo empieza y cuánto dura. Es un modelo cerrado, completamente determinado antes del primer fotograma, y su virtud es exactamente esa previsibilidad: si el diseño exige que el logotipo tarde cuatrocientos milisegundos, tween lo garantiza. spring la concibe como un sistema dinámico, un estado que evoluciona según una ecuación diferencial a partir de las condiciones actuales, y por eso no puede prometer duraciones: solo promete continuidad. La consecuencia práctica es asimétrica y decide qué usar. Un modelo cerrado se rompe cuando el mundo cambia a mitad de ejecución, porque su parámetro fundamental —el tiempo total— dejó de tener sentido en cuanto el destino se movió; lo único que puede hacer es tirar la animación y empezar otra, y ese reinicio es visible como un frenazo. Un sistema dinámico no se rompe nunca, porque no había ningún plan que romper: cambiar el destino solo cambia la fuerza que actúa sobre una masa que ya venía moviéndose. Por eso las interfaces táctiles modernas convergieron en resortes, y no por moda. Un dedo sobre una pantalla es la fuente de interrupción por excelencia: puede soltar, volver a coger, cambiar de dirección y detenerse en cualquier instante, y ninguna de esas cosas está prevista en una línea de tiempo. En cuanto una animación queda acoplada a un gesto, la pregunta deja de ser cuánto debe durar y pasa a ser cómo debe reaccionar, y esa es literalmente la pregunta que un resorte responde y una curva de duración fija ni siquiera se plantea.
- Coge una animación tuya escrita con
tweeny duplícala en pantalla conspring. Interrumpe las dos a mitad de camino cambiando el destino y describe con precisión qué diferencia ves. - Recorre el rango de
dampingRatiodesdeHighBouncyhastaNoBouncymanteniendo la rigidez fija, y anota a partir de qué punto el rebote deja de leerse como energía y empieza a leerse como error. - Anima un color con un resorte con rebote y explica por qué el resultado se ve mal. Relaciónalo con la separación entre especificaciones espaciales y de efectos.
- Construye con
keyframesun rebote de pulsación de cuatrocientos milisegundos que baje, sobrepase y vuelva, y luego reescríbelo conkeyframesWithSplines. - Monta un brillo de carga con
rememberInfiniteTransitioneinfiniteRepeatable, y comparaRepeatMode.RestartconRepeatMode.Reverseen el mismo elemento.