Curvas y springs: por qué el muelle es el estándar de iOS
La diferencia real entre una curva de tiempo y un sistema físico: parámetros de duración y rebote, los presets modernos, `interpolatingSpring` para gestos, y los casos donde una curva clásica sigue siendo la respuesta correcta.
Durante años animar significó elegir una duración y una curva de aceleración. Hoy, en el ecosistema Apple, la respuesta por defecto es otra: un muelle. No es una moda estética ni una preferencia de diseñador; es una consecuencia técnica de cómo se comporta una interfaz táctil, donde el dedo puede interrumpir cualquier movimiento en cualquier instante y donde una discontinuidad de velocidad se percibe como un error del sistema. Entender la física que hay detrás cambia la forma en que eliges parámetros y, sobre todo, en que dejas de elegirlos a ojo.
- Distinguir una curva de tiempo de un sistema masa-muelle-amortiguador.
- Parametrizar un muelle con duración perceptual y rebote.
- Elegir entre
spring,interpolatingSpringy los presets del sistema. - Reconocer los casos donde una curva clásica es la opción correcta.
Dos familias: guion fijo frente a sistema dinámico
Una curva de tiempo es una función que mapea el progreso normalizado a la fracción de trayecto recorrida. easeInOut es una bézier cúbica: dado un instante, devuelve una posición. No tiene memoria ni estado. Si a mitad de camino cambias el destino, la única salida es empezar otra curva desde la posición actual con velocidad cero, y ese cero es exactamente el tirón que el ojo detecta.
Un muelle es otra cosa: un sistema dinámico con estado. Su valor en cada instante es la solución de una ecuación diferencial de segundo orden con tres constantes —masa, rigidez y amortiguamiento— y dos condiciones iniciales: posición y velocidad. Que la velocidad forme parte del estado es la propiedad que lo cambia todo. Si el destino se mueve a mitad de vuelo, el sistema no reinicia: sigue integrando desde donde estaba, con la inercia que llevaba. La trayectoria resultante es continua en posición y en velocidad.
Esa continuidad es la razón técnica de que iOS sea un sistema de muelles. Cada arrastre que sueltas, cada hoja que empujas y devuelves, cada pulsación repetida sobre el mismo botón es una redirección a mitad de camino. Una curva de tiempo produce ahí un artefacto visible; un muelle, no.
flowchart TB
A[El destino cambia a mitad de animacion] --> B{Que tipo de animacion es}
B -->|Curva de tiempo| C[Se descarta la curva anterior]
C --> D[Nueva curva desde velocidad cero]
D --> E[Discontinuidad visible: tiron]
B -->|Muelle| F[Se conserva la velocidad actual]
F --> G[El integrador sigue hacia el nuevo destino]
G --> H[Trayectoria continua sin costuras]
style H fill:#a6e3a1,color:#11111b
style E fill:#eba0ac,color:#11111bParametrizar por percepción, no por física
La API antigua pedía masa, rigidez y amortiguamiento, y era casi imposible de usar: los parámetros interactúan entre sí de forma no lineal, así que subir la rigidez para acelerar el movimiento cambiaba también cuánto sobreoscilaba. SwiftUI moderno expone en su lugar dos ejes ortogonales, elegidos para que uno no contamine al otro.
// duration: cuánto tarda en asentarse, en la percepción del usuario.
// bounce: cuánto se pasa de largo antes de estabilizarse. Rango útil de -1 a 1.
.spring(duration: 0.4, bounce: 0.0) // amortiguamiento crítico: llega y para
.spring(duration: 0.4, bounce: 0.3) // subamortiguado: rebota
.spring(duration: 0.4, bounce: -0.2) // sobreamortiguado: se arrastra al final
duration no es el tiempo hasta el reposo matemático —un muelle solo se detiene en el infinito— sino la duración perceptual del movimiento; el motor la traduce internamente a la constante de tiempo del sistema. bounce en cero produce amortiguamiento crítico, el punto exacto en que el sistema llega lo más rápido posible sin pasarse. Positivo, sobreoscila. Negativo, se vuelve pastoso.
La correspondencia con la formulación clásica es directa y conviene tenerla presente si vienes de ella: bounce es uno menos la fracción de amortiguamiento. Rebote cero equivale a fracción uno, rebote 0.3 a fracción 0.7, y un rebote negativo a una fracción mayor que uno, es decir, al régimen sobreamortiguado.
// Estas dos declaraciones describen el mismo sistema
.spring(duration: 0.5, bounce: 0.3)
.spring(response: 0.5, dampingFraction: 0.7)
Sobre esos dos ejes están construidos los presets, y por eso son un buen vocabulario de partida:
smooth · rebote cero
Sin sobreoscilación. Para cambios de layout, cambios de estado sobrios y todo lo que deba sentirse inevitable más que juguetón.
snappy · rebote leve
Un toque de vida sin llamar la atención. El compromiso por defecto para la mayoría de interacciones táctiles directas.
bouncy · rebote marcado
Sobreoscilación evidente. Para elementos con carácter, confirmaciones y microinteracciones que celebran algo.
Los tres aceptan duration: y extraBounce: para afinarlos sin abandonar el vocabulario. Y hay un detalle histórico que conviene saber: desde iOS 17 la propia Animation.default dejó de ser una curva y pasó a ser un muelle. Si arrastras código antiguo y notas que “se mueve distinto”, esa suele ser la causa.
spring frente a interpolatingSpring
Existen dos implementaciones y no son intercambiables. spring es un muelle basado en duración: se le da un tiempo objetivo y siempre termina de forma predecible; es la que quieres el noventa por ciento de las veces.
interpolatingSpring es un muelle aditivo por velocidad. Cuando se le aplica un cambio nuevo mientras está en marcha, en lugar de reemplazar el movimiento anterior lo suma. Eso lo hace ideal para animaciones encadenadas rápidamente y, sobre todo, para continuar el movimiento de un gesto con la velocidad exacta con la que el dedo lo soltó:
.gesture(
DragGesture()
.onChanged { g in desplazamiento = g.translation.height }
.onEnded { g in
let v = g.predictedEndTranslation.height - g.translation.height
withAnimation(.interpolatingSpring(stiffness: 240, damping: 26,
initialVelocity: v / 100)) {
desplazamiento = abierto ? 0 : -320
}
}
)
Sin esa initialVelocity, el panel arrancaría parado justo en el instante en que el dedo iba a toda velocidad, y el usuario percibiría un frenazo entre su gesto y la respuesta del sistema.
Sea cual sea la que elijas, toda Animation admite además un puñado de transformaciones que devuelven otra animación y se encadenan. No cambian la curva, cambian su relación con el tiempo:
.smooth.delay(0.08) // arranca más tarde
.snappy.speed(1.4) // la misma curva, comprimida
.easeInOut(duration: 0.6)
.repeatCount(3, autoreverses: true) // tres idas y vueltas
.linear(duration: 2).repeatForever(autoreverses: false)
delay escalonado sobre los elementos de una colección es la forma barata de conseguir una entrada en cascada sin recurrir a keyframes; speed es preferible a recalcular duraciones cuando quieres una variante más rápida de una animación ya afinada, porque conserva la forma exacta del movimiento.
El error más común al pasar a muelles es seguir pensando en fotogramas y teclear duraciones arbitrarias. Empieza siempre por un preset. Si algo no encaja, cambia un solo eje: si se siente lento, baja duration; si se siente muerto o excesivo, ajusta el rebote. Cambiar los dos a la vez te deja sin capacidad de atribuir la mejora, que es exactamente el problema que la API ortogonal vino a resolver.
Cuándo una curva clásica sigue mandando
Los muelles no son universales. Hay una familia de casos donde el sistema físico es la herramienta equivocada, y todos comparten un rasgo: el movimiento no representa un objeto que se desplaza.
- Movimiento continuo y uniforme. Un indicador que gira indefinidamente, una marquesina, una barra indeterminada. Aquí
lineares lo correcto: cualquier aceleración se leería como un fallo de rendimiento. - Progreso ligado a una magnitud real. Una barra que refleja bytes descargados o segundos reproducidos no debe sobreoscilar jamás: el rebote comunicaría una información falsa sobre el dato.
- Sincronización con una línea de tiempo externa. Si la animación debe cuadrar con un audio, un vídeo o una secuencia coreografiada, necesitas una duración exacta y determinista, no una duración perceptual.
- Fundidos puros de opacidad. Sin geometría que se mueva no hay inercia que simular;
easeInOutcorto oeaseOutes más limpio y más barato. - Control total de la forma.
timingCurveacepta los cuatro puntos de control de una bézier cúbica y sirve para reproducir con exactitud una curva definida por diseño o heredada de otra plataforma.
.linear(duration: 1).repeatForever(autoreverses: false) // giro continuo
.easeOut(duration: 0.2) // fundido de salida
.timingCurve(0.2, 0, 0, 1, duration: 0.35) // bézier a medida
Fíjate en el patrón que une a los cinco casos: en ninguno el usuario puede interrumpir el movimiento a mitad. Cuando nadie va a cambiar el destino, la única ventaja estructural del muelle —conservar la velocidad ante una redirección— deja de tener aplicación, y lo que queda es una animación con final asintótico donde probablemente querías un final exacto.
Una curva de tiempo es una función del reloj; un muelle es un sistema con estado que incluye la velocidad. Parametriza los muelles con duration y bounce, ejes ortogonales pensados para que puedas afinar uno sin estropear el otro, y arranca siempre desde smooth, snappy o bouncy. Usa interpolatingSpring cuando haya que continuar la velocidad de un gesto. Reserva las curvas clásicas para lo que no se puede interrumpir: progreso real, movimiento uniforme, sincronía con una línea de tiempo externa y fundidos puros.
Es tentador justificar los muelles diciendo que “imitan el mundo real”, y esa explicación es a la vez bonita y falsa. Ninguna pantalla contiene masa, y ningún usuario espera que una hoja modal obedezca a Newton. Lo que un muelle aporta no es realismo: es una garantía matemática de continuidad de primer orden bajo entradas impredecibles. Una interfaz táctil es un sistema al que se le cambia el objetivo en instantes arbitrarios, y el ojo humano está extraordinariamente afinado para detectar discontinuidades de velocidad: un salto de posición se perdona, un salto de velocidad se lee como rotura. Una curva de tiempo es una función del tiempo absoluto y por tanto no tiene forma de recordar con qué velocidad venía; un muelle lleva la velocidad en su estado, así que ante cualquier redirección produce, por construcción, una trayectoria de clase C1. Ahí está el argumento entero: iOS eligió muelles porque es el sistema más simple que hace imposible el tirón. Corolario práctico y útil: cuando el movimiento no puede ser interrumpido —una barra de progreso, un giro perpetuo, una coreografía cronometrada— desaparece la razón de ser del muelle, y la curva clásica no solo es aceptable, es superior.
- Anima el mismo desplazamiento con
smooth,snappyybouncyy pulsa repetidamente a mitad de camino: describe qué cambia. - Barre
bounceentre -0.5 y 0.7 con la mismadurationy localiza el punto donde el movimiento empieza a parecer un error. - Sustituye un
springporeaseInOuten un botón que se pulsa rápido y documenta el artefacto que aparece. - Continúa un
DragGestureconinterpolatingSpringusandopredictedEndTranslationy compáralo con arrancar de velocidad cero. - Encuentra en una app tuya una animación que deba ser
lineary argumenta por escrito por qué el muelle sería incorrecto ahí.