Animaciones avanzadas en SwiftUI: matchedGeometryEffect, PhaseAnimator y KeyframeAnimator

Las animaciones básicas de SwiftUI (withAnimation, .animation) resuelven el 80% de los casos. Pero hay situaciones donde necesitas controlar con precisión el tiempo, la trayectoria o la sincronización de múltiples propiedades. Para eso, iOS 17 trajo tres herramientas que cubren los casos avanzados: matchedGeometryEffect para transiciones hero, PhaseAnimator para ciclos por fases y KeyframeAnimator para animaciones multi-propiedad con timings independientes.

matchedGeometryEffect: hero animations

Una hero animation conecta visualmente dos vistas en pantallas distintas, animando suavemente la transición entre sus posiciones y tamaños. En SwiftUI se logra con matchedGeometryEffect, que necesita un namespace compartido entre ambas vistas:

struct CatalogoView: View {
    @Namespace private var animacionHero
    @State private var productoSeleccionado: Producto?

    var body: some View {
        ZStack {
            if let producto = productoSeleccionado {
                DetalleView(
                    producto: producto,
                    namespace: animacionHero,
                    alCerrar: { withAnimation(.spring(duration: 0.5)) {
                        productoSeleccionado = nil
                    }}
                )
            } else {
                ScrollView {
                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 160))]) {
                        ForEach(productos) { producto in
                            ProductoCard(producto: producto)
                                .matchedGeometryEffect(id: producto.id, in: animacionHero)
                                .onTapGesture {
                                    withAnimation(.spring(duration: 0.5)) {
                                        productoSeleccionado = producto
                                    }
                                }
                        }
                    }
                }
            }
        }
    }
}

struct DetalleView: View {
    let producto: Producto
    var namespace: Namespace.ID
    let alCerrar: () -> Void

    var body: some View {
        ScrollView {
            VStack(alignment: .leading) {
                AsyncImage(url: producto.imagenURL)
                    .frame(maxWidth: .infinity)
                    .frame(height: 300)
                    .matchedGeometryEffect(id: producto.id, in: namespace)
                // ...resto del detalle
            }
        }
        .overlay(alignment: .topTrailing) {
            Button(action: alCerrar) {
                Image(systemName: "xmark.circle.fill")
                    .font(.title)
            }
            .padding()
        }
    }
}

SwiftUI interpola automáticamente la posición, tamaño y forma de los elementos con el mismo id en el mismo namespace.

Controlar qué propiedades se animan con matchedGeometryEffect

El modificador acepta parámetros para controlar qué propiedades participan en la interpolación:

// Solo anima la posición, no el tamaño
.matchedGeometryEffect(
    id: producto.id,
    in: namespace,
    properties: .position, // .frame incluye posición + tamaño
    anchor: .center,
    isSource: true // false en la vista destino cuando quieres control manual
)

PhaseAnimator: animaciones por fases

PhaseAnimator permite definir una secuencia de fases por las que la vista cicla automáticamente. Cada fase puede tener su propia animación de transición:

enum FasePulso {
    case normal, expandido, contraido
}

struct BotonPulso: View {
    @State private var activo = false

    var body: some View {
        PhaseAnimator(
            [FasePulso.normal, .expandido, .contraido],
            trigger: activo
        ) { fase in
            Circle()
                .fill(.blue)
                .frame(width: tamano(fase), height: tamano(fase))
                .opacity(opacidad(fase))
        } animation: { fase in
            switch fase {
            case .normal: .easeOut(duration: 0.3)
            case .expandido: .spring(duration: 0.4, bounce: 0.3)
            case .contraido: .easeIn(duration: 0.2)
            }
        }
        .onTapGesture { activo.toggle() }
    }

    func tamano(_ fase: FasePulso) -> CGFloat {
        switch fase {
        case .normal: 60
        case .expandido: 90
        case .contraido: 50
        }
    }

    func opacidad(_ fase: FasePulso) -> Double {
        fase == .contraido ? 0.6 : 1.0
    }
}

Cuando no proporcionas un trigger, el ciclo de fases se repite de forma continua y automática, útil para loaders y efectos ambient.

PhaseAnimator continuo: loaders y efectos

struct LoaderPuntos: View {
    var body: some View {
        HStack(spacing: 8) {
            ForEach(0..<3) { idx in
                PhaseAnimator([false, true]) { saltando in
                    Circle()
                        .frame(width: 12, height: 12)
                        .offset(y: saltando ? -10 : 0)
                } animation: { _ in
                    .easeInOut(duration: 0.5)
                        .delay(Double(idx) * 0.15)
                }
            }
        }
    }
}

KeyframeAnimator: control total de múltiples propiedades

KeyframeAnimator es la opción más potente: permite definir keyframes para múltiples propiedades con timings completamente independientes, similar a un editor de animación en código:

struct ValoresAnimacion {
    var escala: Double = 1.0
    var rotacion: Angle = .zero
    var opacidad: Double = 1.0
    var offsetY: Double = 0
}

struct AnimacionCompleja: View {
    @State private var disparar = false

    var body: some View {
        KeyframeAnimator(
            initialValue: ValoresAnimacion(),
            trigger: disparar
        ) { valores in
            Image(systemName: "star.fill")
                .font(.system(size: 50))
                .foregroundStyle(.yellow)
                .scaleEffect(valores.escala)
                .rotationEffect(valores.rotacion)
                .opacity(valores.opacidad)
                .offset(y: valores.offsetY)
        } keyframes: { _ in
            KeyframeTrack(.escala) {
                LinearKeyframe(1.5, duration: 0.2)
                SpringKeyframe(1.0, duration: 0.3, spring: .bouncy)
            }

            KeyframeTrack(.rotacion) {
                LinearKeyframe(.degrees(360), duration: 0.5)
            }

            KeyframeTrack(.opacidad) {
                LinearKeyframe(1.0, duration: 0.3)
                LinearKeyframe(0.0, duration: 0.2)
                LinearKeyframe(1.0, duration: 0.1)
            }

            KeyframeTrack(.offsetY) {
                SpringKeyframe(-30, duration: 0.25, spring: .snappy)
                SpringKeyframe(0, duration: 0.25, spring: .bouncy)
            }
        }
        .onTapGesture { disparar.toggle() }
    }
}

Tipos de keyframe

Hay cuatro tipos de keyframe según la curva de interpolación:

  • LinearKeyframe: velocidad constante entre el valor anterior y el nuevo
  • CubicKeyframe: curva cúbica suave (similar a ease-in-out)
  • SpringKeyframe: simula un muelle con parámetros configurables
  • MoveKeyframe: salta al valor sin animación (cambio instantáneo)
KeyframeTrack(.escala) {
    CubicKeyframe(1.2, duration: 0.15)   // Sube suavemente
    CubicKeyframe(0.9, duration: 0.1)    // Baja un poco (squash)
    SpringKeyframe(1.0, duration: 0.4,   // Rebota a su tamaño normal
                   spring: Spring(mass: 1, stiffness: 200, damping: 15))
}

Ejemplo real: notificación animada

struct NotificacionAnimada: View {
    let mensaje: String
    @State private var visible = false
    @State private var disparar = false

    var body: some View {
        KeyframeAnimator(
            initialValue: ValoresNotificacion(),
            trigger: disparar
        ) { v in
            if visible {
                HStack {
                    Image(systemName: "bell.fill").foregroundStyle(.white)
                    Text(mensaje).foregroundStyle(.white)
                }
                .padding()
                .background(.blue, in: RoundedRectangle(cornerRadius: 12))
                .scaleEffect(v.escala)
                .offset(y: v.offsetY)
                .opacity(v.opacidad)
            }
        } keyframes: { _ in
            KeyframeTrack(.offsetY) {
                LinearKeyframe(-80, duration: 0)
                SpringKeyframe(0, duration: 0.4, spring: .bouncy)
                MoveKeyframe(0)
                LinearKeyframe(0, duration: 2) // Espera 2 segundos
                LinearKeyframe(-80, duration: 0.3)
            }
            KeyframeTrack(.opacidad) {
                LinearKeyframe(1, duration: 0.4)
                LinearKeyframe(1, duration: 2)
                LinearKeyframe(0, duration: 0.3)
            }
            KeyframeTrack(.escala) {
                SpringKeyframe(1.05, duration: 0.2, spring: .snappy)
                SpringKeyframe(1.0, duration: 0.2, spring: .smooth)
            }
        }
        .onAppear { visible = true; disparar.toggle() }
    }
}

struct ValoresNotificacion {
    var offsetY: Double = -80
    var opacidad: Double = 1
    var escala: Double = 1
}

Resumen

Las tres APIs cubren un espectro completo: matchedGeometryEffect para hero animations entre vistas, PhaseAnimator para secuencias de fases con transiciones por etapas y KeyframeAnimator para control preciso de múltiples propiedades con timings independientes. En iOS 17 estas tres herramientas permiten implementar cualquier animación de interfaz compleja con código declarativo, sin necesidad de librerías externas ni bajar a Core Animation.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP