Lektion 3.25 · Stufe 3 – Fortgeschritten

matchedGeometryEffect

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du Elemente fließend von einer Stelle an eine andere bewegen: mit @Namespace und matchedGeometryEffect für animierte Auswahl-Markierungen und aufklappende Karten, und mit der Zoom-Transition für Navigation und Sheets. Du weißt, worauf es bei IDs und Quellen ankommt.

Das Problem: Zwei Views, die eigentlich eine sind

Stell dir eine Leiste mit drei Kategorien vor: „Alle“, „Obst“, „Gemüse“. Unter der gewählten Kategorie ist ein Strich. Tippst du auf eine andere Kategorie, soll der Strich hinübergleiten.

Mit dem, was du bisher kennst, sieht der Code etwa so aus:

Swift
ForEach(categories, id: \.self) { category in
    VStack {
        Text(category)
        if category == selected {
            Capsule().frame(height: 3)
        }
    }
}

Mit withAnimation verschwindet der Strich unter der alten Kategorie (ausblenden) und erscheint unter der neuen (einblenden). Für SwiftUI sind das zwei verschiedene Views an zwei Stellen im Code. Wie soll es wissen, dass sie für dich „derselbe“ Strich sind?

Genau das sagst du SwiftUI mit matchedGeometryEffect: „Diese beiden Views sind eigentlich eine. Wenn die eine verschwindet und die andere erscheint, bewege sie von hier nach dort.“

Der Namespace

Zuerst brauchst du einen Namensraum, in dem die zusammengehörigen Views ihre gemeinsame ID haben:

Swift
@Namespace private var namespace

@Namespace ist ein Property Wrapper wie @State. Er erzeugt eine eindeutige Kennung, die über die ganze Lebensdauer der View gleich bleibt. Du kannst dir ihn als „Raum“ vorstellen, in dem IDs gelten. Zwei Views mit derselben ID im selben Raum gehören zusammen.

Der wandernde Strich

Swift
struct CategoryBar: View {
    let categories = ["Alle", "Obst", "Gemüse", "Brot"]
    @State private var selected = "Alle"
    @Namespace private var namespace

    var body: some View {
        HStack(spacing: 20) {
            ForEach(categories, id: \.self) { category in
                VStack(spacing: 6) {
                    Text(category)
                        .fontWeight(category == selected ? .bold : .regular)
                        .foregroundStyle(category == selected ? Color.primary : Color.secondary)

                    if category == selected {
                        Capsule()
                            .fill(.orange)
                            .frame(height: 3)
                            .matchedGeometryEffect(id: "underline", in: namespace)
                    } else {
                        Capsule()
                            .fill(.clear)
                            .frame(height: 3)
                    }
                }
                .onTapGesture {
                    withAnimation(.snappy) {
                        selected = category
                    }
                }
            }
        }
        .padding()
    }
}

Erwartetes Ergebnis

Unter „Alle“ ist ein oranger Strich. Tippst du auf „Gemüse“, gleitet der Strich hinüber und passt dabei seine Breite an das Wort an.

Das Entscheidende ist .matchedGeometryEffect(id: "underline", in: namespace):

  • id: die gemeinsame Kennung. Alle Views mit dieser ID im selben Namespace gelten als dieselbe.
  • in: der Namespace.

Wird der Strich unter „Alle“ entfernt und unter „Gemüse“ eingefügt, sieht SwiftUI: Gleiche ID, gleicher Raum. Statt aus- und einzublenden, animiert es Position und Größe von der alten zur neuen Stelle.

Die durchsichtige Capsule im else-Zweig hält den Platz frei, damit die Texte nicht springen, wenn der Strich wechselt.

Ein Bild dafür

Stell dir einen Zauberer vor, der eine Münze in der linken Hand verschwinden und in der rechten wieder auftauchen lässt. Für das Publikum ist es dieselbe Münze. matchedGeometryEffect ist der Zaubertrick: SwiftUI lässt eine View verschwinden und eine andere erscheinen, aber du siehst eine einzige, die sich bewegt.

Die Variante mit Hintergrund

Dasselbe Muster ergibt eine Auswahl, bei der eine farbige „Pille“ hinter das gewählte Element gleitet, wie bei einem segmentierten Picker:

Swift
Text(category)
    .padding(.horizontal, 14)
    .padding(.vertical, 8)
    .background {
        if category == selected {
            Capsule()
                .fill(.orange.opacity(0.2))
                .matchedGeometryEffect(id: "pill", in: namespace)
        }
    }

Aufklappende Karten

Der zweite klassische Einsatz: Eine kleine Karte wächst zu einer großen Ansicht, und ihre Teile gleiten dabei an ihre neuen Positionen. So wie im App Store, wenn du auf eine Geschichte tippst.

Swift
struct HeroCard: View {
    @State private var isExpanded = false
    @Namespace private var namespace

    var body: some View {
        ZStack {
            if isExpanded {
                VStack(alignment: .leading, spacing: 16) {
                    RoundedRectangle(cornerRadius: 0)
                        .fill(.orange.gradient)
                        .matchedGeometryEffect(id: "image", in: namespace)
                        .frame(height: 300)

                    Text("Pfannkuchen")
                        .font(.largeTitle.bold())
                        .matchedGeometryEffect(id: "title", in: namespace)
                        .padding(.horizontal)

                    Text("Fluffig, schnell gemacht und mit allem kombinierbar, was die Küche hergibt.")
                        .padding(.horizontal)

                    Spacer()
                }
                .ignoresSafeArea(edges: .top)
                .onTapGesture {
                    withAnimation(.spring(duration: 0.5)) { isExpanded = false }
                }
            } else {
                VStack(alignment: .leading) {
                    RoundedRectangle(cornerRadius: 16)
                        .fill(.orange.gradient)
                        .matchedGeometryEffect(id: "image", in: namespace)
                        .frame(height: 140)

                    Text("Pfannkuchen")
                        .font(.headline)
                        .matchedGeometryEffect(id: "title", in: namespace)
                }
                .frame(width: 200)
                .onTapGesture {
                    withAnimation(.spring(duration: 0.5)) { isExpanded = true }
                }
            }
        }
    }
}

Bild und Titel haben jeweils ihre eigene ID. Beim Wechsel wachsen sie gleichzeitig an ihre neue Position und Größe, während der neue Beschreibungstext einfach einblendet.

Reihenfolge der Modifier

.matchedGeometryEffect sollte vor .frame stehen. Der Effekt übernimmt Position und Größe von der anderen View, und .frame darf das nicht vorher festlegen. Steht .frame davor, bekommst du oft seltsame Sprünge.

isSource: Wer gibt die Form vor?

Normalerweise ist zu jeder Zeit genau eine View mit einer ID sichtbar. Es gibt aber Fälle, in denen beide gleichzeitig da sind, etwa ein Platzhalter und ein darüberliegendes Element. Dann musst du sagen, welche die Quelle ist:

Swift
Color.clear
    .frame(width: 60, height: 60)
    .matchedGeometryEffect(id: "box", in: namespace, isSource: true)

Circle()
    .fill(.blue)
    .matchedGeometryEffect(id: "box", in: namespace, isSource: false)

Der Kreis übernimmt Position und Größe des durchsichtigen Platzhalters. Verschiebst du den Platzhalter, folgt ihm der Kreis, mit Animation. So kannst du eine View an eine andere „anheften“.

Tipp

Die Regel: Von allen gleichzeitig sichtbaren Views mit derselben ID darf höchstens eine isSource: true haben. Das ist der Standardwert. Sind zwei Quellen gleichzeitig sichtbar, warnt SwiftUI in der Konsole: „Multiple inserted views in matched geometry group … have isSource: true“.

Zoom-Transition bei Navigation und Sheets

Für einen sehr häufigen Fall gibt es eine fertige Lösung: Ein Tipp auf ein Element in einer Liste oder einem Raster öffnet die Detailseite, und die Detailseite wächst aus dem Element heraus. Wie in der Fotos-App.

Swift
struct PhotoGrid: View {
    let colors: [Color] = [.red, .orange, .green, .blue, .purple, .pink]
    @Namespace private var namespace

    var body: some View {
        NavigationStack {
            ScrollView {
                LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
                    ForEach(colors, id: \.self) { color in
                        NavigationLink {
                            color
                                .ignoresSafeArea()
                                .navigationTransition(.zoom(sourceID: color, in: namespace))
                        } label: {
                            color
                                .aspectRatio(1, contentMode: .fit)
                                .clipShape(.rect(cornerRadius: 12))
                                .matchedTransitionSource(id: color, in: namespace)
                        }
                    }
                }
                .padding()
            }
            .navigationTitle("Farben")
        }
    }
}
  • .matchedTransitionSource(id:in:) markiert das Element, aus dem gezoomt wird.
  • .navigationTransition(.zoom(sourceID:in:)) an der Zielseite sagt: „Wachs aus der Quelle mit dieser ID heraus.“

Erwartetes Ergebnis

Ein Tipp auf eine Kachel lässt sie zur ganzen Seite aufzoomen. Zurück schrumpft die Seite wieder in die Kachel. Der Nutzer kann die Detailseite sogar mit einer Wischgeste nach unten „zurückschrumpfen“.

Dasselbe funktioniert mit Sheets und fullScreenCover: .navigationTransition(.zoom(...)) an den Inhalt des Sheets, .matchedTransitionSource an den Button, der es öffnet.

Was nehme ich wann?

  • Navigation oder Sheet soll aus einem Element herauswachsen → Zoom-Transition. Einfach, robust, mit Systemgesten.
  • Innerhalb einer Seite soll ein Element an eine andere Stelle wandern, etwa ein Auswahlstrich oder eine Karte, die sich aufklappt → matchedGeometryEffect.

Typische Fehler

Element blendet über statt zu wandern

  • Die Änderung ist nicht animiert. withAnimation fehlt.
  • Die IDs unterscheiden sich, etwa durch einen Tippfehler.
  • Die Views verwenden verschiedene Namespaces, weil jede Kind-View ihren eigenen @Namespace erzeugt. Erzeuge den Namespace in der Eltern-View und gib ihn als Namespace.ID weiter:
Swift
struct Child: View {
    let namespace: Namespace.ID
    ...
}

Seltsame Sprünge

.frame steht vor .matchedGeometryEffect, oder Modifier wie .padding liegen auf einer Seite innerhalb, auf der anderen außerhalb des Effekts. Halte die Reihenfolge auf beiden Seiten gleich.

Warnung über mehrere Quellen

Beide Views sind gleichzeitig sichtbar und beide sind Quelle. Sorg dafür, dass immer nur eine eingefügt ist, oder setz bei einer isSource: false.

Text verzerrt sich beim Wachsen

matchedGeometryEffect interpoliert den Rahmen, nicht die Schriftgröße. Wechselt der Text zwischen .headline und .largeTitle, kann er während der Animation kurz abgeschnitten oder verzerrt wirken. Oft hilft .fixedSize() am Text oder eine gemeinsame Schriftgröße mit .scaleEffect.

FAQ

Anfänger

Was bedeutet das überhaupt?

Muss die ID ein String sein? Nein, jeder Hashable-Wert geht: Strings, Zahlen, UUIDs, die ID eines Elements. In Listen nimmst du meist die id des Elements, etwa .matchedGeometryEffect(id: item.id, in: namespace).

Kann ich mehrere Effekte im selben Namespace haben? Ja, solange die IDs verschieden sind. Bild und Titel einer Karte teilen sich einen Namespace, haben aber eigene IDs.

Fortgeschritten

Warum verhält es sich so?

Was genau wird übernommen? Standardmäßig Position und Größe (properties: .frame). Mit properties: .position oder .size kannst du einschränken. Farbe, Eckenradius und Inhalt werden nicht „gematcht“, sondern ganz normal animiert oder übergeblendet.

Funktioniert matchedGeometryEffect zwischen verschiedenen Bildschirmen? Nicht zuverlässig über NavigationStack oder Sheets hinweg, weil das System dort eigene Übergänge steuert. Dafür gibt es die Zoom-Transition.

Profi

Was passiert unter der Haube?

Wie funktioniert matchedGeometryEffect intern? SwiftUI merkt sich pro ID und Namespace den Rahmen der Quell-View. Wird eine andere View mit derselben ID eingefügt, startet ihre Transition mit dem gemerkten Rahmen und animiert zum eigenen. Gleichzeitig animiert die verschwindende View zum neuen Rahmen. Beide werden dabei übergeblendet. Deshalb sieht es aus wie eine einzige View.

Warum ist Namespace.ID ein eigener Typ? Damit IDs nicht versehentlich zwischen unabhängigen Komponenten kollidieren. Zwei Komponenten können beide die ID "title" verwenden, ohne sich zu stören, solange jede ihren eigenen Namespace hat.

Übungen

Anfänger

Übung 1: Eigener Segment-Picker

Baue mit dem „Pillen“-Muster einen Auswahl-Schalter für „Tag“, „Woche“, „Monat“, bei dem ein farbiger Hintergrund hinter die gewählte Option gleitet.

Lösung Übung 1Erst selbst probieren!
Swift
struct PeriodPicker: View {
    let options = ["Tag", "Woche", "Monat"]
    @State private var selected = "Woche"
    @Namespace private var namespace

    var body: some View {
        HStack(spacing: 4) {
            ForEach(options, id: \.self) { option in
                Text(option)
                    .font(.subheadline.weight(.semibold))
                    .foregroundStyle(option == selected ? Color.white : Color.primary)
                    .padding(.vertical, 8)
                    .frame(maxWidth: .infinity)
                    .background {
                        if option == selected {
                            Capsule()
                                .fill(.blue)
                                .matchedGeometryEffect(id: "pill", in: namespace)
                        }
                    }
                    .contentShape(.rect)
                    .onTapGesture {
                        withAnimation(.snappy) { selected = option }
                    }
            }
        }
        .padding(4)
        .background(.gray.opacity(0.15), in: .capsule)
        .padding()
    }
}

.contentShape(.rect) macht die ganze Fläche antippbar, nicht nur den Text.

Fortgeschritten

Übung 2: Karte aufklappen

Erweitere das Karten-Beispiel: Zeig eine Liste von drei Rezept-Karten. Ein Tipp auf eine Karte lässt diese Karte bildschirmfüllend aufklappen. Tipp: Merk dir die ID der aufgeklappten Karte in einem Optional und verwende die Rezept-ID in der matchedGeometryEffect-ID, etwa "image-\(recipe.id)".

Profi

Übung 3: Zoom in Projekt D

Füge der Rezept-App aus Projekt D die Zoom-Transition hinzu: Ein Tipp auf ein Rezept in der Liste lässt die Detailseite aus dem Vorschaubild herauswachsen. Achte darauf, dass matchedTransitionSource am Bild der Zeile hängt und die ID die Rezept-ID ist.

Zusammenfassung

  • @Namespace erzeugt einen Raum, in dem IDs zusammengehörige Views verbinden.
  • .matchedGeometryEffect(id:in:) lässt eine verschwindende und eine erscheinende View wie eine wandern.
  • Typische Einsätze: wandernde Markierungen und aufklappende Karten.
  • Effekt vor .frame setzen, die Änderung mit withAnimation animieren.
  • isSource bestimmt, wer bei gleichzeitig sichtbaren Views die Form vorgibt.
  • Für Navigation und Sheets: .matchedTransitionSource plus .navigationTransition(.zoom(sourceID:in:)).

Spickzettel

Swift
@Namespace private var namespace

if isSelected {
    Capsule()
        .matchedGeometryEffect(id: "underline", in: namespace)
        .frame(height: 3)
}
withAnimation(.snappy) { selected = item }

// Namespace weitergeben
struct Child: View { let namespace: Namespace.ID }

// Zoom
NavigationLink {
    Detail().navigationTransition(.zoom(sourceID: item.id, in: namespace))
} label: {
    Thumbnail().matchedTransitionSource(id: item.id, in: namespace)
}

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.