Lektion 2.19 · Stufe 2 – SwiftUI-Einstieg

Sheets

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du Bildschirme als Sheet von unten einblenden, sie mit @Environment(\.dismiss) wieder schließen, Sheets für bestimmte Elemente mit sheet(item:) öffnen, ihre Höhe mit Detents steuern und Daten aus einem Sheet an die aufrufende View zurückgeben.

Das Problem: Nicht alles ist Navigation

Mit NavigationStack gehst du tiefer in die App: von der Liste zu den Details. Manche Bildschirme sind aber keine Unterseiten, sondern eine kurze Unterbrechung:

  • Eine neue Notiz anlegen und dann zurück zur Liste.
  • Einen Filter einstellen.
  • Die Einstellungen öffnen.

Diese Bildschirme gleiten in iOS von unten herein und legen sich wie eine Karte über den aktuellen Inhalt. Man nennt sie Sheets. Der Nutzer erledigt etwas und kehrt zurück, mit „Abbrechen“, „Fertig“ oder einem Wisch nach unten.

Navigation oder Sheet?

  • Navigation: Der Nutzer erkundet Inhalte und geht tiefer. Er geht mit „Zurück“ zurück.
  • Sheet: Der Nutzer erledigt eine Aufgabe und schließt sie ab. Er schließt mit „Fertig“ oder „Abbrechen“.

Das erste Sheet

Swift
struct ContentView: View {
    @State private var showSettings = false

    var body: some View {
        Button("Einstellungen öffnen") {
            showSettings = true
        }
        .sheet(isPresented: $showSettings) {
            Text("Hier sind die Einstellungen")
        }
    }
}
  • @State private var showSettings merkt sich, ob das Sheet gerade sichtbar ist.
  • Der Button setzt den Wert auf true.
  • .sheet(isPresented: $showSettings) { ... } zeigt das Sheet, solange der Wert true ist. Der Inhalt steht in der Closure.

Wischt der Nutzer das Sheet nach unten weg, setzt SwiftUI showSettings automatisch wieder auf false. Deshalb wird ein Binding übergeben (Lektion 2.10).

Erwartetes Ergebnis

Ein Tipp auf den Button lässt eine Karte von unten hereingleiten. Dahinter ist die ursprüngliche Ansicht leicht verkleinert zu sehen. Ein Wisch nach unten schließt das Sheet.

Screenshot folgt: Geöffnetes Sheet über der Startansicht

Fällt dir das Muster auf? Du sagst nie „zeig das Sheet“. Du änderst einen Zustand, und das Sheet folgt ihm. Genau wie überall sonst in SwiftUI.

Sheets selbst schließen

Ein Wisch nach unten reicht nicht immer. Ein Sheet braucht meist einen Knopf zum Schließen. Dafür gibt es @Environment(\.dismiss):

Swift
struct SettingsSheet: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            Form {
                Text("Einstellungen")
            }
            .navigationTitle("Einstellungen")
            .toolbar {
                ToolbarItem(placement: .confirmationAction) {
                    Button("Fertig") {
                        dismiss()
                    }
                }
            }
        }
    }
}
  • @Environment(\.dismiss) holt eine Aktion aus der Umgebung (Lektion 2.2), die die aktuelle Präsentation schließt.
  • dismiss() ruft sie auf, und das Sheet gleitet nach unten weg.

Das Sheet bekommt hier einen eigenen NavigationStack, damit es Titel und Toolbar haben kann. Das ist korrekt: Ein Sheet ist ein eigenständiger Bereich, nicht Teil des Navigationsstapels der aufrufenden View.

Tipp

dismiss ist praktisch, weil das Sheet nicht wissen muss, wie es geöffnet wurde. Es braucht kein Binding von außen. Dieselbe View funktioniert als Sheet und als Navigationsziel: Dort führt dismiss() zurück zur vorherigen Seite.

Ein Formular im Sheet

Das typische Muster zum Anlegen eines neuen Eintrags:

Swift
struct Note: Identifiable {
    let id = UUID()
    var title: String
}

struct NotesView: View {
    @State private var notes: [Note] = []
    @State private var showNewNote = false

    var body: some View {
        NavigationStack {
            List(notes) { note in
                Text(note.title)
            }
            .navigationTitle("Notizen")
            .toolbar {
                Button("Neu", systemImage: "plus") {
                    showNewNote = true
                }
            }
            .sheet(isPresented: $showNewNote) {
                NewNoteSheet(notes: $notes)
            }
        }
    }
}

struct NewNoteSheet: View {
    @Binding var notes: [Note]
    @Environment(\.dismiss) private var dismiss
    @State private var title = ""

    var body: some View {
        NavigationStack {
            Form {
                TextField("Titel", text: $title)
            }
            .navigationTitle("Neue Notiz")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Abbrechen") {
                        dismiss()
                    }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Sichern") {
                        notes.append(Note(title: title))
                        dismiss()
                    }
                    .disabled(title.isEmpty)
                }
            }
        }
    }
}

So spielen die Teile zusammen:

  1. Das Sheet bekommt ein Binding auf die Notizen.
  2. Der neue Titel lebt in einem eigenen @State im Sheet. Er gehört nur dem Sheet, solange es offen ist.
  3. Bei „Sichern“ wird die Notiz angehängt und das Sheet geschlossen.
  4. Bei „Abbrechen“ wird nur geschlossen, und der Entwurf verfällt.

Erwartetes Ergebnis

Ein Tipp auf „+“ öffnet ein Sheet „Neue Notiz“ mit einem Textfeld, links „Abbrechen“, rechts „Sichern“ (ausgegraut, bis ein Titel eingegeben ist). Nach „Sichern“ steht die Notiz in der Liste.

Alternative: Eine Closure statt eines Bindings

Das Sheet muss nicht die ganze Liste kennen. Oft ist es eleganter, ihm nur zu sagen, was beim Speichern passieren soll:

Swift
struct NewNoteSheet: View {
    let onSave: (Note) -> Void
    @Environment(\.dismiss) private var dismiss
    @State private var title = ""

    var body: some View {
        // … wie oben, aber beim Sichern:
        Button("Sichern") {
            onSave(Note(title: title))
            dismiss()
        }
    }
}

// Aufruf:
.sheet(isPresented: $showNewNote) {
    NewNoteSheet { note in
        notes.append(note)
    }
}

onSave ist eine Closure vom Typ (Note) -> Void (Lektion 1.11). Das Sheet liefert die neue Notiz ab, und die Liste entscheidet, was damit passiert. So ist das Sheet unabhängig davon, wo die Notizen gespeichert sind. Diese Variante nutzen wir in Projekt C.

Ein Sheet pro Element: sheet(item:)

Manchmal soll ein Sheet ein bestimmtes Element zeigen, etwa die Details der angetippten Notiz. Mit einem Bool müsstest du dir zusätzlich merken, welche Notiz gemeint ist. Einfacher geht es mit einem Optional (Lektion 1.8):

Swift
@State private var selectedNote: Note?

List(notes) { note in
    Button(note.title) {
        selectedNote = note
    }
}
.sheet(item: $selectedNote) { note in
    Text(note.title)
        .font(.largeTitle)
}
  • Ist selectedNote nil, ist kein Sheet offen.
  • Wird es auf eine Notiz gesetzt, öffnet sich das Sheet und bekommt diese Notiz in die Closure.
  • Beim Schließen setzt SwiftUI selectedNote wieder auf nil.

Der Typ muss dafür Identifiable sein (Lektion 2.16).

Die Höhe steuern: Detents

Nicht jedes Sheet braucht den ganzen Bildschirm. Mit Detents (Rastpunkten) bestimmst du, wie hoch es ist:

Swift
.sheet(isPresented: $showFilter) {
    FilterView()
        .presentationDetents([.medium, .large])
        .presentationDragIndicator(.visible)
}
  • .presentationDetents([.medium, .large]): Das Sheet öffnet halbhoch und lässt sich auf volle Höhe ziehen.
  • .presentationDragIndicator(.visible) zeigt den kleinen Griff oben an.

Es gibt auch feste Höhen: .height(200) oder einen Anteil: .fraction(0.3).

Achtung

Die Modifier für Detents gehören an den Inhalt des Sheets, also in die Closure, nicht an .sheet selbst.

Versehentliches Schließen verhindern

Hat der Nutzer schon viel eingetippt, wäre es ärgerlich, wenn ein versehentlicher Wisch alles verwirft:

Swift
Form { ... }
    .interactiveDismissDisabled(!title.isEmpty)

Solange etwas eingegeben ist, lässt sich das Sheet nicht wegwischen, nur über „Abbrechen“ oder „Sichern“ schließen.

Vollbild: fullScreenCover

Für Inhalte, die den ganzen Bildschirm brauchen, etwa eine Kamera, einen Videoplayer oder ein Onboarding, gibt es eine Variante ohne sichtbaren Hintergrund:

Swift
.fullScreenCover(isPresented: $showOnboarding) {
    OnboardingView()
}

Sie funktioniert wie .sheet, lässt sich aber nicht wegwischen. Du brauchst also unbedingt einen eigenen Knopf mit dismiss().

Typische Fehler

Sheet öffnet sich nicht

Prüf, ob das Binding wirklich auf true gesetzt wird und ob der .sheet-Modifier an einer View hängt, die gerade sichtbar ist. Hängt er an einer View in einem if, das gerade false ist, gibt es auch kein Sheet.

Mehrere Sheets an derselben View

Swift
Button("A") { showA = true }
    .sheet(isPresented: $showA) { A() }
    .sheet(isPresented: $showB) { B() }

Zwei Sheets direkt an derselben View führen dazu, dass oft nur eins funktioniert. Häng sie an verschiedene Views oder nimm sheet(item:) mit einem Enum, das beschreibt, welches Sheet offen ist.

dismiss schließt nichts

@Environment(\.dismiss) muss im Sheet stehen, nicht in der View, die es öffnet. In der aufrufenden View würde dismiss() versuchen, diese View zu schließen.

Daten aus dem Sheet kommen nicht an

Das Sheet hat ein eigenes @State statt eines Bindings oder einer Closure. Änderungen bleiben dann im Sheet und verschwinden mit ihm.

FAQ

Anfänger

Was bedeutet das überhaupt?

Warum schrumpft der Bildschirm hinter dem Sheet? Das ist Apples Design: Die Ansicht dahinter tritt optisch zurück, damit klar ist, dass das Sheet eine Ebene darüber liegt und man dorthin zurückkehrt.

Kann ein Sheet ein weiteres Sheet öffnen? Ja, ein Sheet kann wieder ein Sheet zeigen. Übertreib es aber nicht. Mehr als zwei Ebenen werden für Nutzer schnell verwirrend.

Wie heißen die Knöpfe richtig, „Speichern“ oder „Sichern“? Apple verwendet im Deutschen für „Save“ meist „Sichern“ und für „Done“ „Fertig“. Wichtiger als das Wort ist Einheitlichkeit: Nimm in der ganzen App dieselben Begriffe.

Fortgeschritten

Warum verhält es sich so?

Wie reagiere ich darauf, dass ein Sheet geschlossen wurde? Mit dem Parameter onDismiss: .sheet(isPresented: $show, onDismiss: { reload() }) { ... }. Die Closure läuft, sobald das Sheet ganz verschwunden ist, egal ob per Knopf oder Wischgeste.

Wie verwalte ich viele verschiedene Sheets an einer Stelle? Mit einem Enum, das Identifiable erfüllt, und sheet(item:):

Swift
enum ActiveSheet: Identifiable {
    case settings, newNote
    var id: Self { self }
}

@State private var activeSheet: ActiveSheet?

.sheet(item: $activeSheet) { sheet in
    switch sheet {
    case .settings: SettingsSheet()
    case .newNote: NewNoteSheet { notes.append($0) }
    }
}

Kann der Hintergrund hinter einem halbhohen Sheet bedienbar bleiben? Ja, mit .presentationBackgroundInteraction(.enabled(upThrough: .medium)). So funktionieren etwa Karten-Apps, in denen man die Karte bewegt, während unten eine Liste angezeigt wird.

Profi

Was passiert unter der Haube?

Wann wird der Inhalt eines Sheets erzeugt? Erst wenn das Sheet erscheint. Die Closure von .sheet wird ausgewertet, sobald isPresented true wird. Das ist ein Grund, warum @State im Sheet bei jedem Öffnen frisch beginnt: Das Sheet bekommt jedes Mal eine neue Identität.

Was steckt hinter dismiss? DismissAction ist eine Struct mit einer callAsFunction-Methode. Deshalb kannst du sie wie eine Funktion aufrufen: dismiss(). SwiftUI setzt in die Umgebung jeder Präsentation die passende Aktion, die genau diese Präsentation beendet.

Übungen

Anfänger

Übung 1: Info-Sheet

Baue eine View mit einem Info-Button (info.circle) in der Toolbar. Er öffnet ein halbhohes Sheet mit einem Text über die App und einem „Fertig“-Button.

Lösung Übung 1Erst selbst probieren!
Swift
struct MainView: View {
    @State private var showInfo = false

    var body: some View {
        NavigationStack {
            Text("Hauptinhalt")
                .navigationTitle("Meine App")
                .toolbar {
                    Button("Info", systemImage: "info.circle") {
                        showInfo = true
                    }
                }
                .sheet(isPresented: $showInfo) {
                    InfoSheet()
                        .presentationDetents([.medium])
                }
        }
    }
}

struct InfoSheet: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            Text("Diese App wurde im SwiftUI-Kurs gebaut.")
                .padding()
                .navigationTitle("Über")
                .toolbar {
                    ToolbarItem(placement: .confirmationAction) {
                        Button("Fertig") { dismiss() }
                    }
                }
        }
    }
}
Fortgeschritten

Übung 2: Kontakt anlegen

Baue eine Kontaktliste mit einem Plus-Button, der ein Sheet mit einem Formular öffnet: Name und Telefonnummer. „Sichern“ ist nur aktiv, wenn ein Name eingegeben ist, und gibt den neuen Kontakt über eine onSave-Closure zurück. Solange etwas eingegeben ist, lässt sich das Sheet nicht wegwischen.

Profi

Übung 3: Bearbeiten im Sheet

Erweitere Übung 2: Ein Tipp auf einen Kontakt öffnet mit sheet(item:) dasselbe Formular, aber mit den vorhandenen Daten vorausgefüllt und dem Titel „Bearbeiten“. Beim Sichern wird der bestehende Kontakt ersetzt, nicht ein neuer angelegt.

Tipp: Gib dem Formular einen optionalen Kontakt mit. Im init kannst du @State-Werte mit _name = State(initialValue: contact?.name ?? "") vorbelegen.

Zusammenfassung

  • Sheets sind für abgeschlossene Aufgaben, Navigation für das Erkunden.
  • .sheet(isPresented: $bool) { ... } zeigt ein Sheet, solange der Wert true ist.
  • @Environment(\.dismiss) im Sheet schließt es mit dismiss().
  • Daten kommen über ein Binding oder eine onSave-Closure zurück.
  • .sheet(item: $optional) öffnet ein Sheet für ein bestimmtes Identifiable-Element.
  • .presentationDetents steuert die Höhe, .interactiveDismissDisabled verhindert versehentliches Schließen.
  • .fullScreenCover für Vollbild-Inhalte.

Spickzettel

Swift
@State private var showSheet = false
@State private var selected: Item?

.sheet(isPresented: $showSheet) {
    NewItemSheet { item in items.append(item) }
        .presentationDetents([.medium, .large])
}
.sheet(item: $selected) { item in DetailSheet(item: item) }
.fullScreenCover(isPresented: $showIntro) { IntroView() }

// im Sheet
@Environment(\.dismiss) private var dismiss
ToolbarItem(placement: .cancellationAction) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .confirmationAction) { Button("Sichern") { save(); dismiss() } }
.interactiveDismissDisabled(hasChanges)

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.