Actualizado: 14 de septiembre de 2026
PhaseAnimator y KeyframeAnimator son las dos APIs declarativas que SwiftUI ofrece en iOS 26 para animar cambios complejos sin escribir un timer manual: PhaseAnimator recorre una lista finita de fases con una animación por transición, mientras que KeyframeAnimator interpola trayectorias continuas con curvas independientes por cada propiedad. En esta guía te muestro cuándo elegir cada una, cómo respetar reduceMotion desde el primer render, y qué patrones uso en producción para que VoiceOver y los usuarios sensibles al movimiento no queden fuera de la fiesta.
¿Qué son PhaseAnimator y KeyframeAnimator?
PhaseAnimator es un contenedor que recibe una colección de fases (normalmente un enum conforme a CaseIterable) y produce un valor observable que puedes usar para calcular scaleEffect, opacity, offset o cualquier modificador. En iOS 26 la firma común es PhaseAnimator(_:trigger:content:animation:), y cada transición entre fases usa la animación que devuelvas en el closure animation. Es la herramienta correcta cuando el diseño se piensa como una máquina de estados: la vista está en reposo, salta a entusiasmo, cae en calma, y así sucesivamente.
KeyframeAnimator, en cambio, describe una trayectoria. Le entregas un valor inicial (un struct con las propiedades que quieras animar) y varias pistas (KeyframeTrack) con keyframes tipados. Cada pista se interpola de forma independiente, con su propia curva, lo que permite que la escala use un rebote elástico mientras el color hace un cross-fade lineal. Es el sustituto natural del viejo Animation.timingCurve encadenado con DispatchQueue.main.asyncAfter, un patrón que todavía veo en revisiones de código y que casi siempre rompe la accesibilidad.
Ambas APIs se introdujeron en iOS 17, pero en iOS 26 son la base recomendada para acompañar los nuevos materiales Liquid Glass y las transiciones de zoom entre vistas. Apple documenta la superficie completa en las páginas oficiales de PhaseAnimator y KeyframeAnimator, con notas específicas sobre efectos secundarios de trigger.
Comparativa: PhaseAnimator vs. KeyframeAnimator
Honestamente, la confusión más frecuente que recibo en revisiones de código es tratar KeyframeAnimator como si fuera PhaseAnimator con más pasos. No lo es. La diferencia clave está en quién decide cuándo suceden las cosas. En PhaseAnimator tú declaras discreta la fase actual y SwiftUI te devuelve el valor. En KeyframeAnimator declaras el tiempo total y cada pista te dice qué valor toma en cada momento. Esta tabla resume las decisiones que suelo tomar en un proyecto real, especialmente cuando el equipo de diseño entrega curvas con cubic-bezier precisas.
| Característica | PhaseAnimator | KeyframeAnimator |
|---|---|---|
| Modelo mental | Máquina de estados discreta | Trayectoria continua en el tiempo |
| Disponibilidad | iOS 17+, iPadOS 17+, macOS 14+, iOS 26 con nuevas curvas | iOS 17+, iPadOS 17+, macOS 14+, iOS 26 con MoveKeyframe mejorado |
| Curva por propiedad | No — una Animation por transición | Sí — un KeyframeTrack por propiedad |
| Control del reloj | SwiftUI decide con trigger o bucle interno | Duración explícita en segundos por pista |
| Caso ideal | Botón que reacciona, corazón que late, banner de error | Onboarding cinemático, transición entre estados largos |
| Costo de CPU en ProMotion | Bajo si el bucle está limitado | Medio; interpola cada frame a 120 Hz |
| Accesibilidad | Requiere reduceMotion manual | Requiere reduceMotion manual y fallback a fase final |
En resumen: si puedes dibujar la animación como una serie de cuadros nombrados, es PhaseAnimator. Si necesitas que dos propiedades se muevan con curvas distintas al mismo tiempo, es KeyframeAnimator. Y si el diseño empieza por “quiero que dure exactamente 1,8 segundos”, casi siempre es KeyframeAnimator.
PhaseAnimator paso a paso con código
Vamos con un caso real: un botón de “me gusta” que crece, rebota y vuelve a su tamaño con un halo detrás. Lo modelaré como una máquina de tres fases (rest, pop, settle) que se dispara cuando likes cambia. Fíjate en el animation por fase: es donde la curva vive, no en un withAnimation disperso por el árbol.
import SwiftUI
enum LikePhase: CaseIterable {
case rest, pop, settle
}
struct LikeButton: View {
@Binding var likes: Int
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
Button {
likes += 1
} label: {
Image(systemName: "heart.fill")
.font(.system(size: 32, weight: .bold))
.foregroundStyle(.pink)
.phaseAnimator(LikePhase.allCases, trigger: likes) { view, phase in
view
.scaleEffect(scale(for: phase))
.rotationEffect(.degrees(rotation(for: phase)))
} animation: { phase in
switch phase {
case .rest: .snappy(duration: 0.15)
case .pop: .bouncy(duration: 0.35, extraBounce: 0.2)
case .settle: .smooth(duration: 0.25)
}
}
}
.accessibilityLabel("Me gusta")
.accessibilityValue("\(likes) me gusta")
.sensoryFeedback(.success, trigger: likes)
.animation(reduceMotion ? .none : .default, value: likes)
}
private func scale(for phase: LikePhase) -> CGFloat {
guard !reduceMotion else { return 1 }
switch phase {
case .rest: return 1
case .pop: return 1.35
case .settle: return 1
}
}
private func rotation(for phase: LikePhase) -> Double {
guard !reduceMotion else { return 0 }
return phase == .pop ? -8 : 0
}
}
Un par de detalles que suelo pedir en revisión: trigger recibe cualquier valor Equatable, así que puedes disparar la animación con un UUID generado por el view model si el conteo no cambia (útil para reintentos). Y por favor, no muevas el accessibilityLabel dentro del closure de phaseAnimator; VoiceOver leerá “me gusta” una sola vez cuando el elemento reciba foco, y ese contenido debe ser estable. Si te interesa cómo esto se combina con navegación programática, cubro los detalles en mi guía de NavigationStack en SwiftUI.
KeyframeAnimator: control fino de varias propiedades
Ahora sube la apuesta el diseñador y pide un onboarding en el que el icono flota (y), rota suavemente y cambia de escala con curvas distintas. Con PhaseAnimator tendrías que fingirlo. KeyframeAnimator lo resuelve declarando un struct con las propiedades y una pista por cada una:
import SwiftUI
struct FloatingBadge: View {
struct AnimState {
var yOffset: CGFloat = 0
var scale: CGFloat = 1
var rotation: Angle = .zero
}
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var trigger = 0
var body: some View {
Image(systemName: "sparkles")
.font(.system(size: 60, weight: .semibold))
.foregroundStyle(.tint)
.keyframeAnimator(
initialValue: AnimState(),
trigger: trigger
) { view, state in
view
.offset(y: state.yOffset)
.scaleEffect(state.scale)
.rotationEffect(state.rotation)
} keyframes: { _ in
KeyframeTrack(\.yOffset) {
SpringKeyframe(-24, duration: 0.9, spring: .bouncy)
SpringKeyframe(0, duration: 0.7, spring: .smooth)
}
KeyframeTrack(\.scale) {
CubicKeyframe(1.15, duration: 0.4)
CubicKeyframe(1.00, duration: 1.2)
}
KeyframeTrack(\.rotation) {
LinearKeyframe(.degrees(-6), duration: 0.5)
LinearKeyframe(.degrees( 6), duration: 0.5)
LinearKeyframe(.degrees( 0), duration: 0.6)
}
}
.onAppear {
guard !reduceMotion else { return }
trigger += 1
}
}
}
Lo poderoso está en la línea 25 y siguientes: el offset vertical usa un resorte, la escala una curva cúbica, y la rotación una interpolación lineal en tres segmentos. Cada pista tiene su propia duración; SwiftUI las combina y detiene la animación cuando la pista más larga termina. Si necesitas repetir en bucle, envuelve la vista en un TimelineView(.animation) y actualiza trigger por cada tick, pero verifica primero que el elemento sea visible.
Cuando el diseñador pide una curva con un cero exacto en el medio (una parada) usa MoveKeyframe: salta a un valor sin interpolar. Es lo que Apple recomienda para efectos tipo glitch o parpadeos de estado en la sección de Motion de las Human Interface Guidelines.
¿Cómo respetar Reduce Motion en SwiftUI iOS 26?
SwiftUI no desactiva animaciones automáticamente cuando el usuario activa Reducir movimiento en Ajustes; te da la señal y espera que hagas lo correcto. La señal vive en @Environment(\.accessibilityReduceMotion), y hay tres patrones sanos para consumirla.
1. Cortocircuito con animación .none
El más simple: envuelve las mutaciones de estado en withAnimation pero devuelve nil o .none si reduceMotion está activo. Funciona bien para transiciones simples.
func toggleDetails() {
let animation: Animation? = reduceMotion ? nil : .snappy
withAnimation(animation) {
isExpanded.toggle()
}
}
2. Selección de curva alternativa
Cuando la animación es parte de la comunicación (indica “esto se cargó”), no la elimines: hazla más corta y sin rebote. Cambia una bouncy por una easeInOut(duration: 0.15). El usuario sigue recibiendo el feedback, sin el vaivén que puede provocar mareo.
3. Estados finales sin trayectoria
En KeyframeAnimator, cuando reduceMotion está activo, no dispares el trigger. La vista se dibujará en su estado final directamente. Si necesitas el resultado visual (una escala mayor, por ejemplo), aplícalo con un modificador estático:
.scaleEffect(reduceMotion ? 1.0 : 1.15)
Un patrón que aprendí a la mala: si el diseño depende de que un elemento entre por la derecha, sustituye la entrada por un cross-fade corto. Nunca la elimines completamente sin razonarlo, o los usuarios de VoiceOver perderán la referencia espacial. Apple detalla este balance en la documentación oficial de accesibilidad.
Transiciones personalizadas y matchedGeometryEffect
Las animaciones no viven solas: casi siempre acompañan a una transición entre estados. En iOS 26 hay tres herramientas que uso a diario y que se combinan bien con PhaseAnimator y KeyframeAnimator.
matchedGeometryEffect sigue siendo la reina para animar “el mismo elemento” entre dos jerarquías (miniatura → detalle). Emparejas identificadores en un Namespace y SwiftUI interpola frame, tamaño y posición. Combínalo con un KeyframeAnimator aplicado al fondo si quieres que el Liquid Glass tenga su propia curva de expansión.
NavigationTransition agrega en iOS 26 la variante .zoom(sourceID:in:), que hace una transición de zoom continuo entre la miniatura y la vista de destino sin que tengas que gestionar el namespace manualmente. Es la manera oficial de replicar la transición estilo App Store en tus propias listas.
AsymmetricTransition te permite declarar una entrada distinta de la salida. Yo la uso para menús contextuales: entra con .push(from: .bottom) y sale con .opacity.combined(with: .scale(scale: 0.9)). Recuerda registrarlas dentro de un .transition() aplicado al hijo que aparece y desaparece, no al contenedor.
ZStack {
if isDetail, let item = selectedItem {
DetailView(item: item)
.matchedGeometryEffect(id: item.id, in: namespace)
.transition(.asymmetric(
insertion: .push(from: .bottom),
removal: .opacity
))
}
}
.animation(reduceMotion ? .none : .snappy(duration: 0.35), value: isDetail)
El truco final: siempre pon el .animation(_:value:) lo más cerca posible del cambio de estado. Ponerlo al nivel de NavigationStack o del @main hace que toda la jerarquía anime, y es la causa más frecuente de esos parpadeos de Text que nadie sabe explicar.
Rendimiento y buenas prácticas en ProMotion
En mi último proyecto pillé este problema en una demo interna: los iPhone 13 Pro y superiores animan a 120 Hz cuando la vista está activa. Una animación de 3 segundos a esa frecuencia son 360 evaluaciones del closure de content. Si dentro haces un UIImage(named:), un cálculo de hash o un NumberFormatter, el hilo principal se satura y verás hitches reportados por MetricKit. Estas reglas me han evitado incidentes de rendimiento en producción:
- Precalcula valores antes de la animación. Guarda colores, escalas y offsets como propiedades del
struct, no los recalcules por frame. - Encierra el trabajo pesado en
Task.detachedy publica el resultado con@Observable. Si te interesa el patrón, escribí una guía dedicada sobre la migración a @Observable en SwiftUI iOS 26. - Detén el bucle con
onDisappear. UnPhaseAnimatorcontriggerautomático que sigue vivo tras salir de la pantalla drena batería sin que lo notes. - Prefiere
drawingGroup()con moderación. Renderizar en Metal ayuda con muchas capas apiladas pero rompe algunas transiciones y descarga textura por cada frame; mídelo con Instruments. - Usa
.animation(_:body:)del modificador de vista en vez de envolver todo conwithAnimationcuando la fuente del cambio es un binding externo. Ganas granularidad y reduces evaluaciones.
Un consejo específico para iOS 26: el sistema aplica un “presupuesto” de trabajo para las vistas con material Liquid Glass. Si superpones dos KeyframeAnimator encima de una capa de vidrio, el compositor puede caer a 60 Hz. Prueba antes de asumir; el simulador miente sobre esto.
Errores comunes al animar en SwiftUI iOS 26
Cierro con la lista negra que arrastro de proyecto en proyecto. Si tu animación no se comporta como esperas, casi siempre es una de estas.
Animar propiedades no Animatable
Un String o un Bool no son interpolables. Si necesitas animar la posición de un texto conforme cambia su contenido, envuelve el texto en un Modifier que exponga animatableData, o cambia el trigger al valor derivado (el ancho del texto, por ejemplo). Con formas custom, implementa Animatable devolviendo la propiedad como AnimatablePair.
Olvidar el id al cambiar el contenido
Cuando SwiftUI ve la misma vista tras un cambio, reutiliza la instancia y no anima onAppear. Añade .id(estado) para forzar un remount cuando el diseño lo requiera. Es la forma más honesta de resetear una animación de entrada.
Combinar withAnimation con @Observable sin pensarlo
El nuevo modelo de observación evalúa dependencias por lectura, así que si tu vista lee model.count dentro de un closure asíncrono, la animación puede engancharse a un cambio que no esperabas. Extrae los valores en variables locales antes de withAnimation.
Usar Timer o DispatchQueue.main.asyncAfter
Si estás llegando a este patrón para encadenar animaciones, es una señal de que necesitas KeyframeAnimator. La ventaja no es solo la elegancia: los timers no pausan cuando la app pasa a segundo plano y consumen ciclos innecesarios.
Preguntas frecuentes
¿Cuál es la diferencia entre PhaseAnimator y KeyframeAnimator en SwiftUI?
PhaseAnimator recorre una lista discreta de fases con una animación por transición; KeyframeAnimator interpola trayectorias continuas con una curva independiente por propiedad. Elige PhaseAnimator para reacciones y KeyframeAnimator para animaciones cinemáticas con duración fija.
¿Cómo detengo una animación en SwiftUI cuando el usuario activa Reducir movimiento?
Lee @Environment(\.accessibilityReduceMotion) y devuelve .none como animación o no dispares el trigger. Si el resultado visual final importa (una escala mayor, un color), aplícalo con un modificador estático para que el usuario reciba la información sin trayectoria.
¿Puedo usar PhaseAnimator y KeyframeAnimator en la misma vista?
Sí. Es habitual usar PhaseAnimator para el estado de un botón y KeyframeAnimator para la animación de entrada del contenedor. SwiftUI las compone sin problemas, siempre que apliques cada una al modificador correcto y evites conflictos con withAnimation global.
¿Qué versión mínima de iOS necesito para PhaseAnimator?
PhaseAnimator y KeyframeAnimator requieren iOS 17, iPadOS 17, macOS 14, watchOS 10 o tvOS 17 como mínimo. En iOS 26 conviven con nuevas transiciones (zoom, morphMatch) y con el sistema Liquid Glass, que aporta materiales dinámicos.
¿Cómo respeta VoiceOver mis animaciones en SwiftUI?
VoiceOver anuncia cambios de valor mediante accessibilityValue y no espera a que termine la animación. Mantén los textos accesibles estáticos y usa accessibilityLabel descriptivo. Si la animación transmite información esencial, añade una descripción alternativa con accessibilityAction.




