Lektion 3.15 · Stufe 3 – Fortgeschritten

SwiftData II: Bearbeiten, Sortieren, Filtern

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du gespeicherte Daten gezielt abfragen: mit #Predicate filtern, mit SortDescriptor nach mehreren Kriterien sortieren, Abfragen abhängig von Nutzereingaben dynamisch aufbauen und Datensätze in einem Formular mit Entwurf, Sichern und Abbrechen bearbeiten.

Das Problem: Nicht immer alles

In der letzten Lektion hat @Query alle Bücher geladen. Echte Apps brauchen aber meist nur einen Teil:

  • nur die ungelesenen Bücher,
  • nur Bücher eines bestimmten Autors,
  • nur Treffer für einen Suchbegriff,
  • sortiert nach Autor, und bei gleichem Autor nach Titel.

Du könntest alles laden und dann mit filter (Lektion 1.11) aussortieren. Bei zehn Büchern ist das in Ordnung, bei zehntausend Ausgaben nicht mehr. Viel effizienter ist es, die Datenbank selbst filtern zu lassen. Dann werden nur die passenden Datensätze überhaupt geladen.

Filtern mit #Predicate

Swift
@Query(filter: #Predicate<Book> { book in
    book.isRead == false
}) private var unreadBooks: [Book]

#Predicate ist ein Makro, das eine Bedingung beschreibt. Es sieht aus wie eine Closure (Lektion 1.11): Sie bekommt ein Buch und liefert true, wenn es dazugehören soll. Das <Book> sagt, für welchen Typ die Bedingung gilt (Lektion 1.18).

Der Clou: Das Makro übersetzt deine Bedingung in eine Datenbankabfrage. Gefiltert wird also nicht in Swift, sondern direkt in der Datenbank. Das ist schnell, auch bei sehr vielen Datensätzen.

Mehr Bedingungen

Innerhalb von #Predicate stehen dir viele bekannte Ausdrücke zur Verfügung:

Swift
// Vergleiche und Verknüpfungen
#Predicate<Book> { $0.pages > 300 && !$0.isRead }

// Text enthält, ohne Rücksicht auf Groß- und Kleinschreibung
#Predicate<Book> { $0.title.localizedStandardContains("harry") }

// Optionale Werte (angenommen, Book hätte ein `var rating: Int?`)
#Predicate<Book> { $0.rating != nil }

// Datumsbereich
let lastWeek = Date.now.addingTimeInterval(-7 * 24 * 60 * 60)
#Predicate<Book> { $0.addedAt > lastWeek }

localizedStandardContains ist die Variante für Datenbanksuchen: Sie ignoriert Groß- und Kleinschreibung und Akzente, „cafe“ findet also auch „Café“.

Achtung

Nicht alles, was in Swift erlaubt ist, funktioniert in #Predicate. Eigene Funktionen, berechnete Eigenschaften oder komplexe Ausdrücke lassen sich oft nicht in eine Datenbankabfrage übersetzen. Xcode meldet das beim Kompilieren oder spätestens beim Ausführen. Bleib bei gespeicherten Eigenschaften, Vergleichen, &&, ||, ! und den Text-Methoden.

Tipp

Werte von außen, etwa lastWeek oben, müssen vor dem #Predicate in einer Konstante stehen. Direkt Date.now.addingTimeInterval(...) im Prädikat aufzurufen, funktioniert oft nicht.

Sortieren nach mehreren Kriterien

Mit einem einzelnen Key Path sortierst du nach einer Eigenschaft (Lektion 3.14). Für mehrere Kriterien gibt es SortDescriptor:

Swift
@Query(sort: [
    SortDescriptor(\Book.author),
    SortDescriptor(\Book.title)
]) private var books: [Book]

Erst nach Autor, bei gleichem Autor nach Titel. Jeder Descriptor kann eine eigene Richtung haben: SortDescriptor(\Book.addedAt, order: .reverse).

Filtern und Sortieren zusammen

Swift
@Query(
    filter: #Predicate<Book> { !$0.isRead },
    sort: \Book.addedAt,
    order: .reverse
) private var readingList: [Book]

„Alle ungelesenen Bücher, die neuesten zuerst.“

Dynamische Abfragen

Bisher steht die Abfrage fest im Code. Was, wenn der Nutzer selbst bestimmt, wonach gesucht oder sortiert wird? Etwa mit einem Suchfeld (Lektion 3.12)?

@Query wird beim Erzeugen der View festgelegt und kann sich danach nicht selbst ändern. Der Trick: Lager die Liste in eine eigene View aus, die den Suchbegriff als Parameter bekommt und ihren @Query im Initializer aufbaut:

Swift
struct BookList: View {
    @Query private var books: [Book]

    init(searchText: String, showOnlyUnread: Bool) {
        let predicate = #Predicate<Book> { book in
            (searchText.isEmpty || book.title.localizedStandardContains(searchText))
            && (!showOnlyUnread || !book.isRead)
        }
        _books = Query(filter: predicate, sort: \Book.title)
    }

    var body: some View {
        List(books) { book in
            Text(book.title)
        }
    }
}
  • _books = Query(...) setzt den Property Wrapper selbst, wie _draft = State(...) in Lektion 3.2.
  • searchText und showOnlyUnread sind hier Parameter, also feste Werte, die im Prädikat verwendet werden dürfen.

Die Eltern-View hält den Suchbegriff und gibt ihn weiter:

Swift
struct LibraryView: View {
    @State private var searchText = ""
    @State private var showOnlyUnread = false

    var body: some View {
        NavigationStack {
            BookList(searchText: searchText, showOnlyUnread: showOnlyUnread)
                .navigationTitle("Bibliothek")
                .searchable(text: $searchText)
                .toolbar {
                    Toggle("Nur ungelesene", systemImage: "book", isOn: $showOnlyUnread)
                }
        }
    }
}

Ändert sich searchText, erzeugt SwiftUI BookList mit dem neuen Wert neu, und der Initializer baut eine neue Abfrage. Die Datenbank liefert sofort die passenden Treffer.

Erwartetes Ergebnis

Tippst du „mo“ ins Suchfeld, bleibt nur „Momo“. Schaltest du „Nur ungelesene“ ein, verschwinden alle gelesenen Bücher, auch in Kombination mit der Suche.

Ein Muster zum Merken

Diese Aufteilung, Eltern-View hält die Eingaben, Kind-View baut daraus die Abfrage, ist der Standardweg für dynamische Abfragen in SwiftData. Du wirst sie in Projekt E wiedersehen.

Dynamische Sortierung

Genauso funktioniert eine wählbare Sortierung:

Swift
enum BookSort: String, CaseIterable, Identifiable {
    case title = "Titel"
    case author = "Autor"
    case newest = "Neueste"

    var id: Self { self }

    var descriptors: [SortDescriptor<Book>] {
        switch self {
        case .title: [SortDescriptor(\.title)]
        case .author: [SortDescriptor(\.author), SortDescriptor(\.title)]
        case .newest: [SortDescriptor(\.addedAt, order: .reverse)]
        }
    }
}

// In BookList:
init(searchText: String, sort: BookSort) {
    _books = Query(
        filter: #Predicate<Book> { searchText.isEmpty || $0.title.localizedStandardContains(searchText) },
        sort: sort.descriptors
    )
}

Das Enum kennt zu jeder Option die passenden Descriptors. Die Eltern-View zeigt einen Picker (Lektion 2.13), und BookList bekommt die Auswahl.

Bearbeiten mit Entwurf

In Lektion 3.14 hat ein Editor mit @Bindable direkt in die Datenbank geschrieben. Für Einstellungen ist das gut. Für ein Formular mit „Abbrechen“ brauchst du aber einen Entwurf (Lektion 3.2).

Bei @Model-Klassen kannst du das Objekt nicht einfach kopieren. Stattdessen hältst du die Werte im Formular in eigenem @State und schreibst sie erst beim Sichern zurück:

Swift
struct BookForm: View {
    @Environment(\.modelContext) private var context
    @Environment(\.dismiss) private var dismiss

    let book: Book?

    @State private var title = ""
    @State private var author = ""
    @State private var pages = 100

    private var isNew: Bool { book == nil }

    var body: some View {
        NavigationStack {
            Form {
                TextField("Titel", text: $title)
                TextField("Autor", text: $author)
                Stepper("Seiten: \(pages)", value: $pages, in: 1...5000, step: 10)
            }
            .navigationTitle(isNew ? "Neues Buch" : "Bearbeiten")
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Abbrechen") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Sichern") {
                        save()
                        dismiss()
                    }
                    .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty)
                }
            }
            .onAppear {
                if let book {
                    title = book.title
                    author = book.author
                    pages = book.pages
                }
            }
        }
    }

    private func save() {
        if let book {
            book.title = title
            book.author = author
            book.pages = pages
        } else {
            context.insert(Book(title: title, author: author, pages: pages))
        }
    }
}

Ein Formular für beides:

  • book == nil: neues Buch. Beim Sichern wird eingefügt.
  • book != nil: vorhandenes Buch. Die Werte werden in .onAppear übernommen und beim Sichern zurückgeschrieben.
  • Abbrechen schließt einfach. Die Datenbank wurde nie verändert.

Aufgerufen wird es als Sheet:

Swift
.sheet(isPresented: $showNewBook) {
    BookForm(book: nil)
}
.sheet(item: $bookToEdit) { book in
    BookForm(book: book)
}

sheet(item:) funktioniert, weil @Model-Objekte automatisch Identifiable sind (Lektion 2.19).

Typische Fehler

Prädikat lässt sich nicht übersetzen

Fehlermeldung

Je nach Fall eine Meldung wie Unsupported Predicate oder ein Fehler im Makro.

Im Prädikat steht etwas, das die Datenbank nicht versteht, etwa eine berechnete Eigenschaft oder ein Methodenaufruf. Verwende gespeicherte Eigenschaften und einfache Ausdrücke.

Werte aus der View im Prädikat

Swift
@Query(filter: #Predicate<Book> { $0.title.contains(searchText) })
private var books: [Book]

Das funktioniert nicht, weil @Query erzeugt wird, bevor searchText existiert. Bau die Abfrage im Initializer einer Kind-View.

Formular ändert sofort

Das Formular verwendet @Bindable auf das Modell. „Abbrechen“ kann die Änderungen dann nicht mehr verhindern. Nutze lokale @State-Werte als Entwurf.

onAppear überschreibt Eingaben

Wird das Sheet neu aufgebaut, kann .onAppear erneut laufen und Eingaben zurücksetzen. Für robuste Formulare kannst du die Werte stattdessen im Initializer mit _title = State(initialValue: book?.title ?? "") vorbelegen (Lektion 3.2).

FAQ

Anfänger

Was bedeutet das überhaupt?

Wann filtere ich mit #Predicate, wann mit filter? Mit #Predicate, wenn die Bedingung auf gespeicherten Eigenschaften beruht. Das ist schneller und lädt weniger. Mit filter in Swift, wenn die Bedingung etwas braucht, das die Datenbank nicht kennt, etwa eine berechnete Eigenschaft wie die Streak aus Projekt C.

Wie zeige ich die Anzahl der Treffer? Einfach books.count in der View mit dem @Query.

Fortgeschritten

Warum verhält es sich so?

Wie begrenze ich die Anzahl der Ergebnisse? Mit einem FetchDescriptor: var descriptor = FetchDescriptor<Book>(sortBy: [SortDescriptor(\.addedAt, order: .reverse)]), dann descriptor.fetchLimit = 5 und _books = Query(descriptor). Das liefert etwa die fünf neuesten Bücher.

Kann ich außerhalb einer View abfragen, etwa in einem ViewModel? Ja, mit try context.fetch(FetchDescriptor<Book>(predicate: ..., sortBy: ...)). Das Ergebnis aktualisiert sich dann aber nicht automatisch wie bei @Query. Deshalb ist @Query in Views meist der einfachere Weg.

Profi

Was passiert unter der Haube?

Wie funktioniert #Predicate technisch? Das Makro wandelt deine Closure beim Kompilieren in einen Ausdrucksbaum um: eine Datenstruktur, die die Bedingung beschreibt, statt sie auszuführen. SwiftData übersetzt diesen Baum in eine Abfrage der darunterliegenden Datenbank. Deshalb sind nur Ausdrücke erlaubt, die sich so übersetzen lassen, und deshalb meldet das Makro manche Fehler schon beim Kompilieren.

Wird die Kind-View bei jeder Eingabe komplett neu abgefragt? Ja, jede Änderung des Suchbegriffs erzeugt eine neue Abfrage. Datenbanken sind darin sehr schnell, und SwiftData lädt Objekte erst, wenn sie gebraucht werden. Bei sehr großen Datenmengen kannst du die Eingabe trotzdem entprellen (Lektion 3.12) und einen @Attribute(.spotlight)- oder Index-Hinweis für häufig gefilterte Eigenschaften setzen.

Übungen

Anfänger

Übung 1: Lange Bücher

Zeig in einer Liste nur Bücher mit mehr als 300 Seiten, sortiert nach Seitenzahl absteigend.

Lösung Übung 1Erst selbst probieren!
Swift
@Query(
    filter: #Predicate<Book> { $0.pages > 300 },
    sort: \Book.pages,
    order: .reverse
) private var longBooks: [Book]
Fortgeschritten

Übung 2: Suche und Sortierung

Baue die Bibliothek mit einem Suchfeld und einem Sortier-Menü in der Toolbar (Titel, Autor, Neueste). Verwende das Muster „Eltern-View hält die Eingaben, Kind-View baut die Abfrage“ und das BookSort-Enum aus dieser Lektion.

Profi

Übung 3: Ein Formular für alles

Baue BookForm aus dieser Lektion nach und ergänze eine Bewertung von 1 bis 5 Sternen, die als optionaler Int (rating: Int?) im Modell gespeichert wird. Verwende dafür die StarRating-View aus Lektion 2.10, angepasst an ein optionales Rating. Das Formular soll für neue und vorhandene Bücher funktionieren, mit Vorbelegung im Initializer statt in .onAppear.

Zusammenfassung

  • #Predicate filtert direkt in der Datenbank. Erlaubt sind gespeicherte Eigenschaften und einfache Ausdrücke.
  • localizedStandardContains für Textsuche ohne Rücksicht auf Groß- und Kleinschreibung und Akzente.
  • SortDescriptor sortiert nach mehreren Kriterien.
  • Dynamische Abfragen: Eltern-View hält die Eingaben, Kind-View baut im Initializer mit _items = Query(...) die Abfrage.
  • Bearbeiten mit Entwurf: Werte in lokalem @State, beim Sichern zurückschreiben oder einfügen.
  • Ein Formular für Neu und Bearbeiten, gesteuert über ein optionales Modell.

Spickzettel

Swift
@Query(filter: #Predicate<Book> { !$0.isRead },
       sort: [SortDescriptor(\Book.author), SortDescriptor(\Book.title)])
private var books: [Book]

struct BookList: View {
    @Query private var books: [Book]
    init(search: String) {
        _books = Query(
            filter: #Predicate<Book> { search.isEmpty || $0.title.localizedStandardContains(search) },
            sort: \Book.title)
    }
    var body: some View { List(books) { Text($0.title) } }
}

// Formular
let book: Book?
@State private var title = ""
func save() { if let book { book.title = title } else { context.insert(Book(title: title)) } }

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.