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 nuevoCubicKeyframe: curva cúbica suave (similar a ease-in-out)SpringKeyframe: simula un muelle con parámetros configurablesMoveKeyframe: 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.
