wandres.dev
ANIMACIONES A FONDO · transiciones y física

matchedGeometryEffect: el efecto héroe entre dos vistas

Cómo funciona de verdad `matchedGeometryEffect`: un identificador, un namespace y una única fuente de geometría. Los dos montajes válidos, los requisitos que casi nadie cumple, los errores típicos y el zoom de navegación de iOS 18.

⏱ 18 min

El efecto héroe —una miniatura que crece hasta convertirse en la pantalla de detalle— es la animación que más convence en una demo y la que más frustra al implementarla. La razón es que matchedGeometryEffect no hace lo que su nombre sugiere: no transforma una vista en otra, ni las funde, ni conoce su contenido. Hace algo mucho más humilde y más útil, y todos los fallos clásicos vienen de pedirle lo que no promete. Una vez sabes exactamente qué interpola, el efecto deja de ser magia frágil y pasa a ser una herramienta predecible.

🎯 Al terminar esta lección sabrás
  • Describir qué interpola exactamente matchedGeometryEffect y qué no toca.
  • Distinguir el montaje por ramas del montaje con fuente explícita.
  • Diagnosticar los fallos habituales: doble fuente, namespace perdido y recorte.
  • Elegir entre el efecto y la transición de zoom de navegación.

Un identificador, un namespace y un donante

El modificador declara: esta vista pertenece al grupo geométrico id dentro del espacio de nombres ns. En cada instante, como mucho una vista del grupo es la fuente; las demás son receptoras. Lo único que ocurre es que las receptoras adoptan el rectángulo —posición, tamaño o ambos— de la fuente, y cuando la fuente cambia, ese rectángulo se interpola como cualquier otro atributo animable.

Tres consecuencias que conviene grabar antes de escribir una línea:

  • No hay morphing. El contenido de cada vista sigue siendo el suyo. La ilusión de que una se convierte en otra la produce la coincidencia visual entre ambas más el fundido cruzado de la inserción y la eliminación.
  • Se anima el marco, no la tipografía. Un Text que va de un cuerpo pequeño a un titular no interpola el tamaño de letra: cambia de golpe mientras su rectángulo viaja.
  • Es geometría, no jerarquía. Las dos vistas pueden vivir en ramas completamente distintas del árbol, siempre que compartan el Namespace y estén ambas montadas en el mismo fotograma.
struct Galeria: View {
    @Namespace private var ns
    @State private var expandida = false

    var body: some View {
        ZStack {
            if expandida {
                Detalle()
                    .matchedGeometryEffect(id: "tarjeta", in: ns)
            } else {
                Miniatura()
                    .matchedGeometryEffect(id: "tarjeta", in: ns)
            }
        }
        .onTapGesture { withAnimation(.snappy) { expandida.toggle() } }
    }
}
flowchart TB
A[Dos vistas comparten id y namespace] --> B[SwiftUI elige la fuente activa]
B --> C[Mide el rectangulo de la fuente]
C --> D[Las receptoras adoptan ese rectangulo]
D --> E{Cambia cual es la fuente}
E -->|Si dentro de una transaccion animada| F[Se interpola el marco entre ambos]
E -->|Sin animacion| G[Salto instantaneo]
F --> H[Ilusion de una vista que viaja]
style H fill:#a6e3a1,color:#11111b

Los dos montajes válidos

Montaje por ramas. Es el del ejemplo anterior: un if/else dentro de un contenedor estable donde una vista se inserta y la otra se elimina en la misma transacción. Cada rama declara el efecto y SwiftUI empareja la que sale con la que entra. Simple y suficiente para pantallas de dos estados.

Montaje con fuente explícita. Cuando ambas vistas deben coexistir —una rejilla que sigue visible detrás de un detalle superpuesto— se usa isSource para designar quién manda:

LazyVGrid(columns: columnas) {
    ForEach(fotos) { foto in
        Miniatura(foto: foto)
            .matchedGeometryEffect(id: foto.id, in: ns,
                                   isSource: abierta == nil)
            .onTapGesture { withAnimation(.snappy) { abierta = foto } }
    }
}

if let foto = abierta {
    Detalle(foto: foto)
        .matchedGeometryEffect(id: foto.id, in: ns, isSource: false)
        .onTapGesture { withAnimation(.snappy) { abierta = nil } }
}

Aquí el detalle nunca es fuente: siempre toma el marco de la miniatura y, al abrirse, la miniatura deja de serlo, con lo que el detalle recupera su propio tamaño natural y el marco se interpola hasta él. El parámetro properties afina qué se copia —frame por defecto, o solo position o size— y anchor decide desde qué punto se alinean cuando las proporciones no coinciden.

⚠️
Exactamente una fuente, ni cero ni dos

Si en un mismo instante dos vistas del grupo tienen isSource: true, el resultado es indefinido y verás parpadeos o saltos; la consola lo avisa con un mensaje sobre múltiples vistas insertadas en el mismo grupo. Si no hay ninguna, las receptoras se quedan sin rectángulo de referencia y colapsan a tamaño cero. Escribe la condición de isSource como una expresión que sea demostrablemente exclusiva, no como dos condiciones independientes que deberían excluirse.

Los fallos que se repiten

🧭

Namespace recreado

El @Namespace debe declararse en una vista que sobreviva a toda la animación. Si vive en un componente que se destruye y se recrea, o si cada rama declara el suyo, el emparejamiento no existe y no hay error visible: simplemente no pasa nada.

🧅

Orden de modificadores

El efecto mide el marco de la vista tal y como está en ese punto de la cadena. Aplicarlo antes o después de un padding, un frame o un background empareja rectángulos distintos. Colócalo donde de verdad está la geometría que quieres casar.

😴

Fuente en un contenedor perezoso

Si la miniatura vive en una rejilla o pila perezosa y está fuera de pantalla, puede no estar montada y su geometría no existe. El detalle sale entonces de un rectángulo absurdo o de la nada.

✂️

Recorte del ancestro

Un clipped, un frame fijo o un fondo redondeado en un ancestro común amputan el viaje: la vista se anima correctamente por una zona que nadie ve.

Hay un quinto caso, y es de otra naturaleza: el efecto no cruza fronteras de presentación. Un sheet, un fullScreenCover o un empujón en NavigationStack montan su contenido en otro contexto, y el emparejamiento geométrico no lo atraviesa. Puedes gastar horas depurando un montaje impecable que nunca podría haber funcionado.

Cuando el héroe cruza una navegación

Para ese caso concreto existe desde iOS 18 una API propia, que además hereda la interactividad del gesto de retroceso del sistema:

NavigationLink {
    Detalle(foto: foto)
        .navigationTransition(.zoom(sourceID: foto.id, in: ns))
} label: {
    Miniatura(foto: foto)
        .matchedTransitionSource(id: foto.id, in: ns)
}

La diferencia práctica no es solo que funcione a través de la pila: la transición de zoom es interrumpible y reversible con el gesto, mientras que un matchedGeometryEffect reproduce el viaje de un estado al otro y punto. Regla de decisión: dentro de una misma pantalla, el efecto; cruzando una presentación, la transición de zoom.

Queda un ajuste que resuelve la mayoría de los emparejamientos feos. Cuando las proporciones de origen y destino no coinciden —una miniatura cuadrada y un detalle apaisado—, copiar el marco entero deforma el contenido durante el vuelo. Copiar solo la posición y dejar que cada vista conserve su tamaño natural produce a menudo un movimiento más limpio:

Detalle(foto: foto)
    .matchedGeometryEffect(id: foto.id, in: ns,
                           properties: .position,   // .frame, .position o .size
                           anchor: .topLeading,     // punto por el que se alinean
                           isSource: false)
📝
Lo esencial del efecto héroe

Un grupo geométrico se identifica por el par formado por el id y el Namespace. En cada instante debe haber exactamente una fuente; las demás vistas del grupo adoptan su rectángulo, y ese rectángulo se interpola cuando la fuente cambia dentro de una transacción animada. No hay morphing de contenido ni interpolación tipográfica. Ambas vistas deben estar montadas a la vez, en el mismo contexto de presentación y sin recortes de por medio. Para cruzar una navegación, matchedTransitionSource con navigationTransition(.zoom).

Nunca hay una vista que viaja

La ilusión que compras al usar este modificador es la de un objeto que se desplaza y crece desde la rejilla hasta la pantalla completa, y esa ilusión es exactamente eso: nada viaja. Hay dos vistas independientes, con contenidos, ciclos de vida y árboles distintos, y un único acuerdo entre ellas —comparten un rectángulo—. Lo que se anima no es la vista sino el marco, que es un CGRect y por tanto un vector perfectamente interpolable; el contenido se limita a redibujarse dentro del rectángulo que le toque en cada fotograma. Toda la fenomenología del efecto se deduce de ahí sin necesidad de recordar reglas sueltas: no interpola tipografía porque el tipo de letra no está en el rectángulo; exige una única fuente porque un valor no puede tener dos orígenes simultáneos; no cruza una presentación modal porque el rectángulo se mide en un espacio de coordenadas que la otra no comparte; y falla en silencio con un namespace equivocado porque sin identidad común no hay acuerdo que romper, solo dos vistas que nunca se hablaron. La lección general excede al modificador: en SwiftUI la continuidad visual casi nunca se consigue moviendo objetos, sino haciendo que dos declaraciones distintas coincidan en un valor animable.

⚔️ Construye el héroe y rómpelo a propósito
  1. Monta el efecto con if/else en un ZStack y comprueba que sin withAnimation no hay viaje.
  2. Pásalo al montaje con isSource sobre una rejilla y provoca a propósito dos fuentes simultáneas: observa el parpadeo y lee la consola.
  3. Mueve el modificador antes y después de un padding y describe cómo cambia el rectángulo emparejado.
  4. Mete la miniatura en una rejilla perezosa, desplázala fuera de pantalla y abre el detalle desde ahí.
  5. Reimplementa el mismo efecto entre dos pantallas con matchedTransitionSource y navigationTransition(.zoom) y compara la interrupción con el gesto de retroceso.