Ir al contenido
charmotion

PhaseAnimator vs KeyframeAnimator en SwiftUI

Por el equipo de Charmotion · Actualizado el

Ilustración de portada de la guía de iOS y Swift: PhaseAnimator vs KeyframeAnimator en SwiftUI, con personajes de demostración de Charmotion

Usa PhaseAnimator cuando una animación pasa por unos pocos estados discretos, como reposo, elevación y aterrizaje, cada uno con su propia curva. Usa KeyframeAnimator cuando la escala, la rotación y el desplazamiento necesitan pistas temporizadas independientes. Ambos llegaron con iOS 17, macOS 14, tvOS 17, watchOS 10 y visionOS 1, y ambos pueden reproducirse en loop o con un trigger.

Puntos clave

  • PhaseAnimator recorre una secuencia de fases; SwiftUI interpola entre ellas con la animación que devuelves para cada fase.
  • KeyframeAnimator reproduce un tipo de valor a lo largo de KeyframeTracks paralelos con duraciones explícitas.
  • Sin trigger, un phase animator se repite indefinidamente; keyframeAnimator(initialValue:repeating:…) se repite de forma predeterminada.
  • Pasa trigger: a cualquiera de los dos para reproducirlo una vez por cada cambio de valor.
  • Para un personaje con muchos estados en los que iteran los diseñadores, una máquina de estados (state machine) de Rive encaja mejor que las pistas programadas a mano.

¿Qué hace PhaseAnimator?

PhaseAnimator (y el modificador .phaseAnimator) recibe una secuencia de fases, renderiza tu contenido para la fase actual y anima hacia la siguiente. La firma de Apple:

func phaseAnimator<Phase>(
    _ phases: some Sequence,
    content: @escaping (PlaceholderContentView<Self>, Phase) -> some View,
    animation: @escaping (Phase) -> Animation? = { _ in .default }
) -> some View

Una insignia que se eleva y aterriza:

enum BadgePhase: CaseIterable {
    case rest, lift, land
}

struct NewBadge: View {
    var body: some View {
        Image(systemName: "star.fill")
            .font(.largeTitle)
            .foregroundStyle(.yellow)
            .phaseAnimator(BadgePhase.allCases) { content, phase in
                content
                    .scaleEffect(phase == .lift ? 1.2 : 1)
                    .offset(y: phase == .lift ? -12 : 0)
                    .rotationEffect(.degrees(phase == .land ? -6 : 0))
            } animation: { phase in
                switch phase {
                case .rest: return .smooth(duration: 0.6)
                case .lift: return .spring(duration: 0.3, bounce: 0.4)
                case .land: return .bouncy
                }
            }
    }
}

El closure de animación recibe la fase hacia la que se anima. Piensa en las fases como poses: describes cada pose y SwiftUI se encarga de los intermedios.

¿Qué hace KeyframeAnimator?

KeyframeAnimator anima un solo valor, normalmente un struct con varias propiedades, a lo largo de pistas que defines con tiempos explícitos. Cada KeyframeTrack apunta a un key path y se ejecuta en paralelo con los demás.

func keyframeAnimator<Value>(
    initialValue: Value,
    repeating: Bool = true,
    content: @escaping (PlaceholderContentView<Self>, Value) -> some View,
    keyframes: @escaping (Value) -> some Keyframes
) -> some View

Un tambaleo sobre una imagen fija de mascota, disparado por un contador:

struct Wobble {
    var scale: CGFloat = 1
    var rotation: Angle = .zero
    var offsetY: CGFloat = 0
}

struct WobblingMascot: View {
    @State private var taps = 0

    var body: some View {
        Image("mascot-still")
            .resizable()
            .scaledToFit()
            .frame(width: 160)
            .keyframeAnimator(initialValue: Wobble(), trigger: taps) { content, value in
                content
                    .scaleEffect(value.scale)
                    .rotationEffect(value.rotation)
                    .offset(y: value.offsetY)
            } keyframes: { _ in
                KeyframeTrack(\.scale) {
                    SpringKeyframe(1.15, duration: 0.2)
                    SpringKeyframe(1.0, duration: 0.3)
                }
                KeyframeTrack(\.rotation) {
                    CubicKeyframe(.degrees(-8), duration: 0.15)
                    CubicKeyframe(.degrees(8), duration: 0.15)
                    CubicKeyframe(.zero, duration: 0.2)
                }
                KeyframeTrack(\.offsetY) {
                    LinearKeyframe(-10, duration: 0.2)
                    SpringKeyframe(0, duration: 0.3)
                }
            }
            .onTapGesture { taps += 1 }
    }
}

Los tipos de keyframe controlan la interpolación: LinearKeyframe, CubicKeyframe, SpringKeyframe y MoveKeyframe (un salto sin interpolación).

¿Cuándo conviene usar fases y cuándo keyframes?

PhaseAnimatorKeyframeAnimatorMáquina de estados (state machine) de Rive
Modelo mentalUna lista de posesPistas con tiempos por propiedadRig creado por un diseñador, con estados y transiciones
Control del tiempoUna curva de animación por faseDuraciones exactas por keyframe y por pistaSe define en el editor
¿Las propiedades se mueven juntas?Sí, todas cambian en cada faseNo, las pistas son independientesPor hueso y por capa
Quién lo editaDesarrolladorDesarrolladorDiseñador, controlado por código
Ideal paraPulsos, toques sutiles para llamar la atención, loops simplesTambaleos, rebotes, reacciones puntuales coreografiadasPersonajes con muchos estados de ánimo y reacciones

Una prueba útil: si puedes describir el movimiento como "ir a A, luego a B y luego volver", usa fases. Si lo describes como "la escala se pasa de largo a los 0.2 s mientras la rotación oscila dos veces", usa keyframes.

Tabla de decisión: APIs integradas para el feedback de la interfaz, efectos de íconos para íconos, una máquina de estados de Rive para personajes y video para clips de marketing
Elige el método de animación según la tarea.

¿Cómo hago que cada animador se repita en loop o se dispare con un trigger?

Ambos modificadores tienen dos modos:

  • Continuo. .phaseAnimator(phases) { … } sin trigger recorre las fases indefinidamente. .keyframeAnimator(initialValue:repeating:content:keyframes:) se repite mientras repeating sea true, su valor predeterminado.
  • Con trigger. .phaseAnimator(phases, trigger: value) { … } recorre las fases una vez cada vez que cambia value y luego se queda en la primera fase. .keyframeAnimator(initialValue:trigger:content:keyframes:) reproduce los keyframes una vez por cada cambio.
// Once per successful save
.phaseAnimator(BadgePhase.allCases, trigger: saveCount) { content, phase in
    content.scaleEffect(phase == .lift ? 1.2 : 1)
}

// Repeating idle breathing, paused under Reduce Motion
.keyframeAnimator(initialValue: Wobble(), repeating: !reduceMotion) { content, value in
    content.scaleEffect(value.scale)
} keyframes: { _ in
    KeyframeTrack(\.scale) {
        CubicKeyframe(1.03, duration: 1.2)
        CubicKeyframe(1.0, duration: 1.2)
    }
}

El valor del trigger debe ser Equatable; un Int que se incrementa es la opción más sencilla. Los loops continuos deben detenerse cuando el usuario activa Reducir movimiento (Reduce Motion); lee @Environment(\.accessibilityReduceMotion).

¿Cómo controlo el tiempo dentro de cada fase o keyframe?

Los dos animadores exponen el tiempo de forma distinta, y eso suele ser el factor decisivo.

PhaseAnimator: la duración de cada fase es la que tarde la Animation que devuelves. Un spring con duration: 0.3 llega a la fase en unos 0.3 s; un .linear(duration: 1) tarda un segundo. Para mantener una pose, agrega un retraso a la animación de la fase siguiente:

} animation: { phase in
    switch phase {
    case .rest: return .easeOut(duration: 0.4).delay(1.5)   // pause before lifting again
    case .lift: return .spring(duration: 0.3, bounce: 0.4)
    case .land: return .bouncy
    }
}

Si devuelves nil para una fase, salta a ella sin animar.

KeyframeAnimator: cada keyframe tiene una duration explícita, y la duración total de una pista es la suma de sus keyframes. Las pistas se ejecutan en paralelo, así que la animación termina cuando termina la pista más larga. Si una pista es más corta, su propiedad simplemente mantiene su último valor hasta que las demás terminan. Para alinear un momento entre pistas (por ejemplo, el punto más bajo de un aplastamiento a los 0.2 s), haz que las duraciones de los keyframes sumen el mismo tiempo en cada pista.

Como los keyframes son datos, también puedes muestrearlos fuera de una vista con KeyframeTimeline, algo útil para pruebas o para controlar valores que no son vistas con la misma curva.

¿Qué versiones del sistema operativo admiten PhaseAnimator y KeyframeAnimator?

La documentación de Apple indica la misma disponibilidad para ambos tipos y sus modificadores:

PlataformaMínimo
iOS / iPadOS17.0
macOS14.0
tvOS17.0
watchOS10.0
visionOS1.0

Si todavía das soporte a iOS 16, envuelve la llamada en if #available(iOS 17, *) y recurre a cadenas de withAnimation o a una vista estática como alternativa. Fíjate en la fila de watchOS: estos animadores son una de las pocas formas de animar un personaje en Apple Watch, donde Rive y Lottie no tienen soporte oficial (consulta animación de personajes en watchOS y visionOS).

¿Cuándo no basta una animación nativa de SwiftUI para un personaje?

Ambos animadores transforman una vista completa: escala, rotación, desplazamiento, opacidad, color. No doblan un brazo, no hacen parpadear un ojo ni sincronizan los labios, porque una imagen plana no tiene partes que mover. Puedes dividir un personaje en imágenes por capas y animar cada una, pero entonces cada estado nuevo implica más código y más tiempos que ajustar a mano.

Pasa a un formato creado por un diseñador cuando:

  • El personaje necesita más de dos o tres estados (reposo (idle), pensando, éxito, error, hablando…).
  • Los estados deben fundirse entre sí a mitad de la animación, sin cortes.
  • Quien debe iterar el movimiento es un diseñador, no un desarrollador.
  • El mismo personaje también debe funcionar en Android o en la web.

Rive cubre esos casos con una máquina de estados (state machine) que tu código controla mediante unos pocos valores (consulta Data Binding de Rive en SwiftUI). Los animadores nativos siguen combinando bien con Rive: un pulso con phaseAnimator en una insignia junto a una mascota de Rive no cuesta nada y no necesita ningún asset.

Preguntas frecuentes

¿PhaseAnimator y KeyframeAnimator funcionan en watchOS?

Sí. Apple indica ambos para watchOS 10 y posteriores, junto con iOS 17, macOS 14, tvOS 17 y visionOS 1. Eso los convierte en una forma práctica de animar un personaje sencillo o una insignia en Apple Watch, donde los paquetes de Rive y Lottie no declaran soporte para watchOS.

¿Cómo evito que un PhaseAnimator se repita en loop sin fin?

Pasa un valor de trigger: .phaseAnimator(phases, trigger: count). Así recorre las fases una vez cada vez que cambia count y se queda en la primera fase. Sin trigger, SwiftUI recorre las fases continuamente mientras la vista esté en pantalla.

¿KeyframeAnimator es lo mismo que los keyframes de CSS?

La idea es parecida, pero la estructura es distinta. Los keyframes de CSS definen todas las propiedades en puntos porcentuales. KeyframeAnimator de SwiftUI da a cada propiedad su propia pista, con duraciones en segundos y una interpolación a elegir, así que la escala y la rotación pueden seguir tiempos distintos dentro de una misma animación. Además, los springs están disponibles como keyframes, algo que CSS no puede expresar directamente.

Respuestas rápidas de esta guía

Fuentes

Guías relacionadas