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:
- Suchen: oben ein Suchfeld, ein paar Buchstaben eintippen, und die Liste zeigt nur noch passende Einträge.
- 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
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.
So funktioniert es:
.searchable(text: $searchText)fügt das Suchfeld ein und schreibt jede Eingabe über das Binding insearchText(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. localizedCaseInsensitiveContainsignoriert 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:
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:
.onChange(of: searchText) {
Task { await viewModel.search(searchText) }
}Das hat zwei Probleme:
- Zu viele Anfragen: Tippt der Nutzer „Lasagne“, entstehen sieben Anfragen, eine pro Buchstabe. Das belastet Server und Datenvolumen.
- 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:
.task(id: searchText) {
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
await viewModel.search(searchText)
}So funktioniert es:
- Der Nutzer tippt einen Buchstaben,
searchTextändert sich, die Aufgabe startet und wartet 400 Millisekunden. - Tippt er in dieser Zeit weiter, ändert sich
searchTexterneut. Die alte Aufgabe wird abgebrochen, bevor sie überhaupt etwas geladen hat, und eine neue startet. - 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:
@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:
.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.
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
.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
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“):
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:
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
Ü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.
Ü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?
Ü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 einenNavigationStack.- 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..searchSuggestionsund.searchScopesfür Vorschläge und Bereiche..refreshable { await … }für Ziehen zum Aktualisieren. Unbedingt mitawaitwarten.
Spickzettel
@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) }
}