Lektion 3.12 · Stufe 3 – Fortgeschritten

Suchen und Aktualisieren: searchable, refreshable

ca. 15 Minuten Lesezeit

Lernziel

Nach dieser Lektion kannst du mit .searchable ein Suchfeld in die Navigationsleiste setzen, Suchergebnisse lokal filtern oder online laden, Eingaben mit .task(id:) entprellen, Suchvorschläge und Filter anbieten und mit .refreshable „Ziehen zum Aktualisieren“ einbauen.

Das Problem: Suchen und Aktualisieren sind überall

Zwei Funktionen erwarten Nutzer in fast jeder App mit Listen:

  1. Suchen: oben ein Suchfeld, ein paar Buchstaben eintippen, und die Liste zeigt nur noch passende Einträge.
  2. Aktualisieren: die Liste nach unten ziehen, ein Ladekreis erscheint, und neue Daten werden geladen.

Beides selbst zu bauen, wäre viel Arbeit: Suchfeld positionieren, Abbrechen-Knopf, Tastatur, Animation beim Ziehen. In SwiftUI ist es jeweils ein einziger Modifier.

.searchable: Ein Suchfeld in einer Zeile

Swift
struct FruitList: View {
    let fruits = ["Apfel", "Banane", "Birne", "Kirsche", "Mango", "Orange", "Pfirsich"]
    @State private var searchText = ""

    var filteredFruits: [String] {
        if searchText.isEmpty {
            fruits
        } else {
            fruits.filter { $0.localizedCaseInsensitiveContains(searchText) }
        }
    }

    var body: some View {
        NavigationStack {
            List(filteredFruits, id: \.self) { fruit in
                Text(fruit)
            }
            .navigationTitle("Obst")
            .searchable(text: $searchText, prompt: "Obst suchen")
        }
    }
}

Erwartetes Ergebnis

Unter dem Titel „Obst“ erscheint ein Suchfeld mit dem grauen Hinweis „Obst suchen“. Tippst du „b“, bleiben nur Banane, Birne und Kirsche übrig. Mit „Abbrechen“ ist alles wieder da.

Screenshot folgt: Obstliste mit Suchfeld und gefilterten Ergebnissen

So funktioniert es:

  • .searchable(text: $searchText) fügt das Suchfeld ein und schreibt jede Eingabe über das Binding in searchText (Lektion 2.10).
  • prompt: ist der graue Platzhalter.
  • Die gefilterte Liste ist eine berechnete Eigenschaft (Lektion 1.12). Ändert sich searchText, wird sie neu berechnet, und die Liste aktualisiert sich.
  • localizedCaseInsensitiveContains ignoriert Groß- und Kleinschreibung (Lektion 3.5).

Das Suchfeld braucht einen NavigationStack, damit es in der Navigationsleiste erscheinen kann. Wie bei Titel und Toolbar steht der Modifier an der View im Stack (Lektion 2.17).

Tipp

Hier wird if als Ausdruck verwendet (Lektion 1.4): Jeder Zweig liefert direkt ein Array, ohne return.

Leere Suchergebnisse

Findet die Suche nichts, sollte das deutlich werden (Lektion 3.11). Dafür gibt es eine fertige Variante:

Swift
List(filteredFruits, id: \.self) { fruit in
    Text(fruit)
}
.overlay {
    if filteredFruits.isEmpty {
        ContentUnavailableView.search(text: searchText)
    }
}

ContentUnavailableView.search(text:) zeigt eine Lupe und „Keine Ergebnisse für ‚xyz‘“, im Stil der Systemapps und automatisch übersetzt.

Online suchen

Lokal filtern ist einfach, weil alle Daten schon da sind. Bei einer Online-API wie in Lektion 3.10 musst du für jede Suche eine Anfrage stellen. Naheliegend wäre:

Swift
.onChange(of: searchText) {
    Task { await viewModel.search(searchText) }
}

Das hat zwei Probleme:

  1. Zu viele Anfragen: Tippt der Nutzer „Lasagne“, entstehen sieben Anfragen, eine pro Buchstabe. Das belastet Server und Datenvolumen.
  2. Falsche Reihenfolge: Kommt die Antwort für „Las“ nach der für „Lasagne“ an, zeigt die Liste plötzlich die falschen Ergebnisse.

Die Lösung kennst du schon zur Hälfte: .task(id:) (Lektion 3.7). Es bricht die alte Aufgabe ab, sobald sich der Suchbegriff ändert. Dazu kommt eine kurze Pause:

Swift
.task(id: searchText) {
    try? await Task.sleep(for: .milliseconds(400))
    guard !Task.isCancelled else { return }
    await viewModel.search(searchText)
}

So funktioniert es:

  1. Der Nutzer tippt einen Buchstaben, searchText ändert sich, die Aufgabe startet und wartet 400 Millisekunden.
  2. Tippt er in dieser Zeit weiter, ändert sich searchText erneut. Die alte Aufgabe wird abgebrochen, bevor sie überhaupt etwas geladen hat, und eine neue startet.
  3. Erst wenn der Nutzer kurz innehält, ist die Wartezeit vorbei, und es wird einmal gesucht.

Dieses Muster heißt Debouncing (Entprellen), und mit .task(id:) sind es nur drei Zeilen. Task.isCancelled prüft, ob die Aufgabe inzwischen abgebrochen wurde. Task.sleep würde dann zwar einen Fehler werfen, aber das try? verschluckt ihn, deshalb prüfen wir zusätzlich.

Tipp

400 Millisekunden sind ein guter Startwert. Kürzer fühlt sich reaktionsschneller an, erzeugt aber mehr Anfragen. Länger spart Anfragen, wirkt aber träge.

Ein ViewModel dazu, mit dem Zustand aus Lektion 3.11:

Swift
@MainActor
@Observable
class MealSearchViewModel {
    var state: LoadState<[Meal]> = .idle
    private let api = MealAPI()

    func search(_ query: String) async {
        let trimmed = query.trimmingCharacters(in: .whitespaces)
        guard !trimmed.isEmpty else {
            state = .idle
            return
        }

        state = .loading
        do {
            state = .loaded(try await api.search(trimmed))
        } catch is CancellationError {
            // abgebrochen, weil eine neuere Suche läuft – nichts tun
        } catch let error as URLError where error.code == .cancelled {
            // dasselbe, gemeldet von URLSession
        } catch {
            state = .failed("Die Suche ist fehlgeschlagen.")
        }
    }
}

catch is CancellationError fängt gezielt den Fehler ab, der bei abgebrochenen Aufgaben entsteht. Bricht eine laufende Netzwerkanfrage ab, meldet URLSession das stattdessen als URLError mit dem Code .cancelled, deshalb der zweite catch. Ein Abbruch ist kein echter Fehler. Der Nutzer hat nur weitergetippt.

Vorschläge und Filter

Suchvorschläge

Mit .searchSuggestions bietest du unter dem Suchfeld Vorschläge an:

Swift
.searchable(text: $searchText)
.searchSuggestions {
    ForEach(["Pasta", "Curry", "Kuchen", "Suppe"], id: \.self) { suggestion in
        Text(suggestion)
            .searchCompletion(suggestion)
    }
}

.searchCompletion(...) sorgt dafür, dass ein Tipp auf den Vorschlag den Text ins Suchfeld übernimmt. Die Vorschläge erscheinen, solange das Suchfeld aktiv ist.

Suchbereiche (Scopes)

Willst du die Suche auf eine Kategorie einschränken, gibt es Scopes: eine segmentierte Auswahl direkt unter dem Suchfeld.

Swift
enum SearchScope: String, CaseIterable {
    case all = "Alle"
    case vegetarian = "Vegetarisch"
    case dessert = "Desserts"
}

@State private var scope = SearchScope.all

.searchable(text: $searchText)
.searchScopes($scope) {
    ForEach(SearchScope.allCases, id: \.self) { scope in
        Text(scope.rawValue).tag(scope)
    }
}

Die Auswahl landet in scope, und du kannst sie in deinem Filter verwenden.

Wo das Suchfeld erscheint

Swift
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))

Standardmäßig versteckt sich das Suchfeld beim Scrollen. Mit .always bleibt es immer sichtbar. In neueren iOS-Versionen erscheint es je nach Situation auch unten am Bildschirmrand, gut erreichbar für den Daumen. Das entscheidet das System passend zum aktuellen Design.

.refreshable: Ziehen zum Aktualisieren

Swift
List(meals) { meal in
    Text(meal.name)
}
.refreshable {
    await viewModel.reload()
}

Das ist alles. Zieht der Nutzer die Liste nach unten, erscheint ein Ladekreis, und SwiftUI ruft die Closure auf. Der Kreis bleibt so lange sichtbar, bis die asynchrone Closure fertig ist. Deshalb muss darin mit await gewartet werden.

Achtung

Startest du in .refreshable nur einen Task { … }, ohne zu warten, verschwindet der Ladekreis sofort wieder, obwohl noch geladen wird. Ruf die asynchrone Methode direkt mit await auf.

Beim Aktualisieren sollte die Liste nicht durch einen großen Ladekreis ersetzt werden. Sie ist ja schon da (Lektion 3.11, „Daten behalten“):

Swift
func reload() async {
    do {
        state = .loaded(try await api.search(lastQuery))
    } catch {
        // Liste bleibt sichtbar; optional Hinweis anzeigen
    }
}

Hier wird state gar nicht erst auf .loading gesetzt. Der Ladekreis von .refreshable reicht als Rückmeldung.

.refreshable funktioniert mit List und ScrollView.

Alles zusammen

Ein vollständiger Suchbildschirm für Rezepte:

Swift
struct MealSearchView: View {
    @State private var viewModel = MealSearchViewModel()
    @State private var searchText = ""

    var body: some View {
        NavigationStack {
            content
                .navigationTitle("Rezepte")
                .searchable(text: $searchText, prompt: "Rezept suchen")
                .task(id: searchText) {
                    try? await Task.sleep(for: .milliseconds(400))
                    guard !Task.isCancelled else { return }
                    await viewModel.search(searchText)
                }
        }
    }

    @ViewBuilder
    private var content: some View {
        switch viewModel.state {
        case .idle:
            ContentUnavailableView(
                "Rezepte suchen",
                systemImage: "magnifyingglass",
                description: Text("Gib oben einen Suchbegriff ein, z. B. „Pasta“.")
            )
        case .loading:
            ProgressView()
        case .failed(let message):
            ContentUnavailableView("Fehler", systemImage: "wifi.exclamationmark", description: Text(message))
        case .loaded(let meals) where meals.isEmpty:
            ContentUnavailableView.search(text: searchText)
        case .loaded(let meals):
            List(meals) { meal in
                Text(meal.name)
            }
            .refreshable {
                await viewModel.search(searchText)
            }
        }
    }
}

Hier hat jeder Zustand eine eigene, verständliche Ansicht, auch .idle: Bevor jemand sucht, erklärt die App, was zu tun ist.

Typische Fehler

Kein Suchfeld zu sehen

Die View steckt nicht in einem NavigationStack, oder .searchable hängt am Stack statt an der View darin.

Eine Anfrage pro Buchstabe

.onChange mit Task statt .task(id:) und Pause. Verwende das Debouncing-Muster.

Ladekreis beim Aktualisieren verschwindet sofort

In .refreshable wird nicht gewartet. Ruf die Methode mit await auf, ohne Task.

Filter ignoriert Umlaute oder Großschreibung

contains unterscheidet Groß- und Kleinschreibung. Nimm localizedCaseInsensitiveContains.

FAQ

Anfänger

Was bedeutet das überhaupt?

Kann ich .searchable auch ohne Liste verwenden? Ja. Das Suchfeld funktioniert mit jeder View im NavigationStack, etwa einem Raster (Lektion 3.17). Was du mit dem Suchtext machst, bestimmst du selbst.

Woher weiß ich, ob das Suchfeld gerade aktiv ist? Mit @Environment(\.isSearching) in einer View innerhalb des durchsuchbaren Bereichs. So kannst du etwa während der Suche andere Inhalte ausblenden.

Fortgeschritten

Warum verhält es sich so?

Kann ich das Suchfeld per Code öffnen? Ja, mit .searchable(text: $searchText, isPresented: $isSearchPresented). Setzt du isSearchPresented = true, wird das Suchfeld aktiv und die Tastatur erscheint. Praktisch für eine Schnelltaste oder einen Suchen-Tab.

Wie suche ich in mehreren Feldern gleichzeitig? Kombiniere die Bedingungen im Filter: $0.name.localizedCaseInsensitiveContains(text) || $0.category?.localizedCaseInsensitiveContains(text) == true. Bei SwiftData verwendest du dafür ein #Predicate (Lektion 3.15).

Profi

Was passiert unter der Haube?

Warum funktioniert Debouncing mit .task(id:) zuverlässig? Weil SwiftUI die alte Aufgabe abbricht, bevor die neue startet. Task.sleep reagiert auf den Abbruch sofort. Die alte Aufgabe erreicht ihren Suchaufruf also nie. Und selbst wenn eine Anfrage schon läuft, bricht URLSession sie beim Abbruch ab, sodass keine veraltete Antwort mehr ankommt.

Wie verhält sich .refreshable intern? .refreshable legt eine RefreshAction in die Umgebung. List und ScrollView lesen sie aus und rufen sie beim Ziehen auf. Eigene Views können über @Environment(\.refresh) ebenfalls darauf zugreifen und etwa einen eigenen Aktualisieren-Button anbieten.

Übungen

Anfänger

Übung 1: Kontakte durchsuchen

Baue eine Kontaktliste mit zehn Namen und einem Suchfeld, das nach Vor- oder Nachnamen filtert. Bei leerem Ergebnis erscheint ContentUnavailableView.search.

Fortgeschritten

Übung 2: Debouncing sichtbar machen

Baue die Online-Rezeptsuche aus dieser Lektion nach und füg in search(_:) ein print("Suche nach: \(query)") ein. Tippe schnell einen langen Begriff und beobachte die Konsole. Wie viele Anfragen entstehen? Und wie viele, wenn du die Pause entfernst?

Profi

Übung 3: Scopes für die API

TheMealDB liefert mit filter.php?c=Dessert alle Rezepte einer Kategorie. Kombiniere Suchtext und Scope: Ist ein Scope außer „Alle“ gewählt, filtere die Suchergebnisse zusätzlich nach meal.category. Lade die Ergebnisse neu, wenn sich Text oder Scope ändert. Tipp: .task(id:) braucht einen Wert, der beides enthält. Eine kleine Hashable-Struct mit Text und Scope eignet sich dafür.

Zusammenfassung

  • .searchable(text: $text) setzt ein Suchfeld in die Navigationsleiste. Es braucht einen NavigationStack.
  • Lokal filtern mit einer berechneten Eigenschaft und localizedCaseInsensitiveContains.
  • Online suchen mit .task(id: searchText) und einer kurzen Pause: Debouncing.
  • ContentUnavailableView.search(text:) für leere Ergebnisse.
  • .searchSuggestions und .searchScopes für Vorschläge und Bereiche.
  • .refreshable { await … } für Ziehen zum Aktualisieren. Unbedingt mit await warten.

Spickzettel

Swift
@State private var searchText = ""

List(filtered) { ... }
    .searchable(text: $searchText, prompt: "Suchen")
    .overlay {
        if filtered.isEmpty { ContentUnavailableView.search(text: searchText) }
    }
    .task(id: searchText) {
        try? await Task.sleep(for: .milliseconds(400))
        guard !Task.isCancelled else { return }
        await viewModel.search(searchText)
    }
    .refreshable {
        await viewModel.reload()
    }

var filtered: [Item] {
    searchText.isEmpty ? items : items.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.