wandres.dev
ANIMACIONES A FONDO · transiciones y física

PhaseAnimator y KeyframeAnimator: secuencias complejas

Las dos herramientas para animaciones que no son un único salto de estado: `PhaseAnimator` como máquina de estados que avanza sola y `KeyframeAnimator` como pistas paralelas sobre una línea de tiempo. Cuándo usar cada una y qué cuestan.

⏱ 18 min

Todo lo anterior resuelve el mismo problema: ir de un estado a otro. Pero hay movimientos que no son un salto sino una secuencia: un icono que se encoge, salta, gira en el aire y aterriza deformándose; un campo que se sacude tres veces al fallar la contraseña; un indicador que respira indefinidamente. Reproducir eso encadenando cierres con retardos es un desastre conocido: no se cancela, no se interrumpe, se desincroniza y se convierte en código imposible de leer. SwiftUI ofrece dos primitivas declarativas para el problema, y no compiten entre sí: resuelven cosas distintas.

🎯 Al terminar esta lección sabrás
  • Modelar una secuencia discreta con PhaseAnimator y una curva por tramo.
  • Coreografiar propiedades independientes con pistas de KeyframeAnimator.
  • Elegir entre fases, keyframes y un simple withAnimation.
  • Valorar el coste de evaluación y respetar la reducción de movimiento.

PhaseAnimator: una máquina de estados que avanza sola

phaseAnimator recibe una colección de fases y una vista parametrizada por la fase actual. SwiftUI recorre las fases una a una, animando entre cada par consecutivo y esperando a que termine cada tramo antes de empezar el siguiente. Es, literalmente, una máquina de estados con avance automático.

Las fases solo tienen que ser Equatable; lo idiomático es un enum que además concentre los valores derivados, de modo que la vista quede declarativa y sin condicionales sueltos:

enum Sacudida: CaseIterable {
    case centro, izquierda, derecha, reposo

    var desplazamiento: CGFloat {
        switch self {
        case .centro, .reposo: 0
        case .izquierda: -14
        case .derecha: 14
        }
    }
}

SecureField("Contraseña", text: $clave)
    .phaseAnimator(Sacudida.allCases, trigger: intentosFallidos) { vista, fase in
        vista.offset(x: fase.desplazamiento)
    } animation: { fase in
        .spring(duration: 0.10, bounce: 0.5)
    }

Dos parámetros deciden todo el comportamiento. Sin trigger, la secuencia se repite en bucle indefinidamente: es la forma correcta de hacer que algo respire o pulse. Con trigger, la secuencia se recorre una vez cada vez que ese valor cambia, y ahí está el detalle fino: no importa a qué cambia, solo que cambie, así que un simple contador de fallos sirve de disparador.

El cierre animation es lo que eleva la herramienta por encima de un bucle: recibe la fase de destino y devuelve la curva con la que llegar a ella. Cada tramo puede tener su propio carácter —una salida lineal rápida, un aterrizaje con rebote— sin que el código de la vista se entere.

flowchart LR
A[Fase 1] -->|animacion propia del tramo| B[Fase 2]
B -->|animacion propia del tramo| C[Fase 3]
C -->|si no hay trigger vuelve al inicio| A
C -->|si hay trigger se detiene| D[Reposo hasta el siguiente disparo]

KeyframeAnimator: pistas paralelas sobre una línea de tiempo

PhaseAnimator es secuencial y monolítico: en cada momento hay una sola fase y todo cambia a la vez. Muchas coreografías no funcionan así. Cuando algo salta, la altura y la deformación no comparten ritmo: el estiramiento ocurre en la salida y en el aterrizaje, mientras la altura describe una parábola continua. Forzar eso en fases obliga a inventar estados intermedios artificiales.

keyframeAnimator da la vuelta al modelo. Defines un valor propio con todas las propiedades a animar, y después declaras una pista independiente por propiedad, cada una con sus tiempos. Las pistas corren en paralelo y la animación completa dura lo que la más larga.

struct Salto {
    var altura = 0.0
    var escalaY = 1.0
    var giro = Angle.zero
}

Image(systemName: "figure.jumprope")
    .font(.system(size: 64))
    .keyframeAnimator(initialValue: Salto(), trigger: salta) { vista, v in
        vista
            .scaleEffect(x: 2 - v.escalaY, y: v.escalaY, anchor: .bottom)
            .rotationEffect(v.giro)
            .offset(y: -v.altura)
    } keyframes: { _ in
        KeyframeTrack(\.altura) {
            SpringKeyframe(120, duration: 0.30, spring: .bouncy)
            SpringKeyframe(0,   duration: 0.45, spring: .bouncy)
        }
        KeyframeTrack(\.escalaY) {
            LinearKeyframe(0.75, duration: 0.10)
            CubicKeyframe(1.20,  duration: 0.25)
            CubicKeyframe(1.00,  duration: 0.40)
        }
        KeyframeTrack(\.giro) {
            LinearKeyframe(.zero,          duration: 0.12)
            CubicKeyframe(.degrees(-18),   duration: 0.30)
            CubicKeyframe(.zero,           duration: 0.33)
        }
    }

Cada tipo de keyframe describe cómo se llega a su valor, no cómo se sale de él: LinearKeyframe a ritmo constante, CubicKeyframe con una curva suave que además encadena su tangente con los cúbicos vecinos, SpringKeyframe con un muelle, y MoveKeyframe saltando de golpe sin interpolar. La duración de cada uno es la del tramo que él cierra, así que los tiempos de una pista se acumulan.

🎬

Fases · secuencial

Estados discretos con nombre, recorridos en orden. Ideal para bucles de atención, sacudidas de error y microinteracciones de dos o tres tiempos.

🎛️

Keyframes · paralelo

Propiedades independientes con ritmos distintos sobre una misma línea de tiempo. Ideal para coreografías donde nada empieza ni acaba a la vez.

Cuándo cada herramienta

La regla de decisión es corta y evita el noventa por ciento del uso indebido:

  • Si la interfaz va de un estado a otro y debe poder interrumpirse a mitad, es withAnimation. Ni fases ni keyframes: ambas reproducen una secuencia que, una vez lanzada, va a llegar hasta el final.
  • Si hay estados discretos con nombre y el orden importa, son fases.
  • Si hay propiedades con ritmos independientes sobre una línea de tiempo, son keyframes.
⚠️
Una secuencia lanzada no representa el estado; lo decora

La diferencia crítica frente a withAnimation es que ni las fases ni los keyframes persiguen un destino: reproducen una línea de tiempo cerrada. Si el estado real cambia a mitad, la secuencia no se redirige. Por eso son perfectas para feedback —celebrar, avisar, llamar la atención— y peligrosas para representar datos que el usuario puede cambiar mientras miran. La prueba mental es sencilla: si al terminar la animación la vista debe volver exactamente al mismo sitio de donde partió, la herramienta es correcta.

Coste y accesibilidad

El cierre de contenido de ambas primitivas se evalúa en cada refresco de pantalla, hasta ciento veinte veces por segundo en pantallas ProMotion. Ahí dentro solo debe haber modificadores geométricos baratos: desplazamientos, escalas, rotaciones, opacidad. Formatear texto, filtrar colecciones, construir degradados o tocar cualquier cosa que provoque un nuevo cálculo de layout multiplica ese trabajo por el número de fotogramas y se nota en el consumo antes que en la fluidez.

Y una obligación, no una cortesía: un phaseAnimator sin trigger anima para siempre. Es exactamente el tipo de movimiento perpetuo que provoca malestar a quien tiene sensibilidad vestibular, y el sistema ya expone la preferencia:

@Environment(\.accessibilityReduceMotion) private var menosMovimiento

var body: some View {
    icono
        .phaseAnimator(menosMovimiento ? [1.0] : [1.0, 1.15, 1.0]) { v, escala in
            v.scaleEffect(escala)
        }
}

Con una sola fase la secuencia se queda quieta y el resto del código no cambia. Sustituir el movimiento por una señal estática equivalente —color, peso, un indicador fijo— es la respuesta correcta; eliminarlo sin sustituirlo deja sin información a quien más la necesita.

Fases y keyframes son animación imperativa recuperada como declaración

Es tentador ver estas dos APIs como una capitulación: SwiftUI predicó que solo se anima el estado y aquí acaba ofreciendo, en efecto, un guion con tiempos. La lectura correcta es la inversa, y explica por qué existen. El modelo de estado es completo para todo movimiento que representa algo: si el movimiento significa que un dato cambió, siempre hay dos estados y una interpolación que los une, y la interrumpibilidad es una propiedad deseable. Pero hay una segunda categoría de movimiento que no representa ningún cambio de dato, sino que es el mensaje: la sacudida que dice error, el latido que dice espera, el salto que dice enhorabuena. Ahí el estado inicial y el final son idénticos, la trayectoria es todo el contenido semántico, y un modelo basado en perseguir un destino no tiene nada que ofrecer porque no hay destino nuevo al que ir. PhaseAnimator y KeyframeAnimator no rompen el modelo declarativo: lo extienden al eje del tiempo, permitiendo declarar una trayectoria completa como valor —una colección de fases, un conjunto de pistas— en lugar de ejecutarla como una cadena de efectos secundarios con retardos. Sigues sin escribir cuándo ocurre nada; describes la forma del recorrido y el sistema lo integra. Y de esa distinción sale la regla de diseño definitiva: pregúntate si el movimiento comunica un cambio de estado o un acontecimiento. Lo primero es withAnimation. Lo segundo, y solo lo segundo, es una secuencia.

⚔️ Coreografía sin cadenas de retardos
  1. Implementa la sacudida de contraseña con phaseAnimator y trigger sobre un contador de fallos.
  2. Convierte esa secuencia en un bucle quitando el trigger y observa el efecto sobre la atención.
  3. Da a cada tramo una curva distinta con el cierre animation y describe cómo cambia el carácter del movimiento.
  4. Escribe un salto con keyframeAnimator usando al menos tres pistas de duraciones distintas.
  5. Añade el soporte de accessibilityReduceMotion a ambos ejemplos sustituyendo el movimiento por una señal estática equivalente.