PhaseAnimator vs KeyframeAnimator en SwiftUI
Por el equipo de Charmotion · Actualizado el

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?
| PhaseAnimator | KeyframeAnimator | Máquina de estados (state machine) de Rive | |
|---|---|---|---|
| Modelo mental | Una lista de poses | Pistas con tiempos por propiedad | Rig creado por un diseñador, con estados y transiciones |
| Control del tiempo | Una curva de animación por fase | Duraciones exactas por keyframe y por pista | Se define en el editor |
| ¿Las propiedades se mueven juntas? | Sí, todas cambian en cada fase | No, las pistas son independientes | Por hueso y por capa |
| Quién lo edita | Desarrollador | Desarrollador | Diseñador, controlado por código |
| Ideal para | Pulsos, toques sutiles para llamar la atención, loops simples | Tambaleos, rebotes, reacciones puntuales coreografiadas | Personajes 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.

¿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 mientrasrepeatingseatrue, su valor predeterminado. - Con trigger.
.phaseAnimator(phases, trigger: value) { … }recorre las fases una vez cada vez que cambiavaluey 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:
| Plataforma | Mínimo |
|---|---|
| iOS / iPadOS | 17.0 |
| macOS | 14.0 |
| tvOS | 17.0 |
| watchOS | 10.0 |
| visionOS | 1.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.