Lernziel
In diesem Projektteil machst du aus der Rezept-App eine runde Anwendung: mit einer Kategorieauswahl aus der API, einer Online-Suche mit Debouncing, „Ziehen zum Aktualisieren“, einem Zufallsrezept, Favoriten in einem geteilten Store über die Umgebung und präzisen Fehlermeldungen.
Ausgangslage
Nach Teil 2 hat die App eine Liste von Desserts und eine schöne Detailansicht. Jetzt kommen die Funktionen, die Nutzer erwarten:
- Kategorien wechseln, statt nur Desserts zu sehen,
- Suchen über alle Rezepte,
- Aktualisieren per Ziehen,
- ein Zufallsrezept für Unentschlossene,
- Favoriten merken,
- verständliche Fehler.
Schritt 1: Kategorien laden
Aufgabe
- Leg ein Modell
Category(Decodable,Identifiable,Hashable) mitid,nameundthumbnailan, mitCodingKeysfüridCategory,strCategoryundstrCategoryThumb, sowie eine AntwortCategoryResponsemitcategories: [Category]. - Ergänze
MealAPIumfunc categories() async throws -> [Category].
LösungErst selbst probieren!
struct CategoryResponse: Decodable {
let categories: [Category]
}
struct Category: Decodable, Identifiable, Hashable {
let id: String
let name: String
let thumbnail: String?
enum CodingKeys: String, CodingKey {
case id = "idCategory"
case name = "strCategory"
case thumbnail = "strCategoryThumb"
}
}In MealAPI:
func categories() async throws -> [Category] {
let response: CategoryResponse = try await fetch("categories.php")
return response.categories
}Hier ist categories nicht optional: Die API liefert immer eine Liste, und ohne Kategorien wäre ohnehin etwas grundsätzlich falsch.
Schritt 2: Kategorie wählen
Aufgabe
Erweitere MealListViewModel:
categories: [Category](Startwert leer),func loadCategories() async, die Fehler stillschweigend ignoriert: Ohne Kategorien bleibt einfach die aktuelle Auswahl,func select(_ category: String) async, diecategorysetzt und neu lädt.
Zeig in der Toolbar von MealListView ein Menu mit einem Picker über alle Kategorien (Lektion 2.18). Wird eine andere gewählt, lädt die Liste neu.
LösungErst selbst probieren!
Im ViewModel:
var categories: [Category] = []
func loadCategories() async {
guard categories.isEmpty else { return }
categories = (try? await api.categories()) ?? []
}In der View am content:
.toolbar {
Menu("Kategorie", systemImage: "line.3.horizontal.decrease.circle") {
Picker("Kategorie", selection: $viewModel.category) {
ForEach(viewModel.categories) { category in
Text(category.name).tag(category.name)
}
}
}
}
.task {
await viewModel.loadCategories()
}
.task(id: viewModel.category) {
await viewModel.load()
}Statt einer eigenen select-Methode reicht hier .task(id: viewModel.category) (Lektion 3.7): Ändert der Picker die Kategorie über das Binding, wird automatisch neu geladen, und ein noch laufender Ladevorgang abgebrochen. Ersetze damit auch das bisherige .task, das nur bei .idle geladen hat.
Weil MealListView das ViewModel mit @State besitzt, liefert $viewModel.category direkt ein Binding (Lektion 3.2).
Schritt 3: Suche
Jetzt eine Suche über alle Rezepte, unabhängig von der Kategorie. Läuft eine Suche, zeigt die Liste die Suchergebnisse. Ist das Suchfeld leer, wieder die Kategorie.
Aufgabe
- Ergänze
MealAPIumsearch(_ query: String) async throws -> [Meal]mitsearch.php. - Ergänze das ViewModel um
searchTextund eine Methoderefresh() async, die je nachsearchTextsucht oder die Kategorie lädt. - Setze in der View
.searchableund ersetze.task(id: viewModel.category)durch ein.task(id:), das auf Kategorie und Suchtext reagiert, mit 400 ms Pause (Lektion 3.12). - Behandle Abbrüche, damit beim schnellen Tippen keine Fehler aufblitzen.
Tipp: Für .task(id:) brauchst du einen einzigen Equatable-Wert. Eine kleine Struct mit beiden Werten eignet sich.
LösungErst selbst probieren!
In MealAPI:
func search(_ query: String) async throws -> [Meal] {
let response: MealResponse = try await fetch("search.php", query: ["s": query])
return response.meals ?? []
}Im ViewModel:
var searchText = ""
struct Query: Equatable {
let category: String
let search: String
}
var query: Query {
Query(category: category, search: searchText.trimmingCharacters(in: .whitespaces))
}
var title: String {
query.search.isEmpty ? category : "Suche"
}
func refresh() async {
do {
let meals = if query.search.isEmpty {
try await api.meals(in: category)
} else {
try await api.search(query.search)
}
state = .loaded(meals)
} catch is CancellationError {
return
} catch let error as URLError where error.code == .cancelled {
return
} catch {
print(error)
state = .failed(message(for: error))
}
}
func load() async {
state = .loading
await refresh()
}Die Methode message(for:) folgt in Schritt 6. Bis dahin kannst du einen festen Text verwenden.
In der View:
content
.navigationTitle(viewModel.title)
.searchable(text: $viewModel.searchText, prompt: "Alle Rezepte durchsuchen")
.task(id: viewModel.query) {
if !viewModel.query.search.isEmpty {
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
}
await viewModel.load()
}Die Pause gibt es nur beim Suchen. Beim Wechsel der Kategorie soll sofort geladen werden.
Und für leere Suchergebnisse im content:
case .loaded(let meals) where meals.isEmpty:
if viewModel.query.search.isEmpty {
ContentUnavailableView("Keine Rezepte", systemImage: "fork.knife")
} else {
ContentUnavailableView.search(text: viewModel.query.search)
}Schritt 4: Aktualisieren
Aufgabe
Ergänze an der Liste .refreshable, das viewModel.refresh() aufruft. Weil refresh() den Zustand nicht auf .loading setzt, bleibt die Liste beim Ziehen sichtbar.
LösungErst selbst probieren!
case .loaded(let meals):
List(meals) { meal in
NavigationLink(value: meal) {
MealRow(meal: meal)
}
}
.listStyle(.plain)
.refreshable {
await viewModel.refresh()
}Genau dafür haben wir in Schritt 3 load() und refresh() getrennt: load() zeigt den großen Ladekreis, refresh() aktualisiert still im Hintergrund (Lektion 3.11).
Schritt 5: Favoriten in der Umgebung
Favoriten sollen in der Liste und in der Detailansicht sichtbar und änderbar sein. Das ist ein klassischer Fall für einen geteilten Store in der Umgebung (Lektionen 3.1 und 3.3).
Aufgabe
- Leg eine
@Observable-KlasseFavoritesStorean, die die IDs der Favoriten in einemSet<String>speichert, mitcontains(_ meal:)undtoggle(_ meal:). Die Klasse brauchtidals Parameter, damit sie mitMealundMealDetailfunktioniert. Mach dahercontains(id:)undtoggle(id:). - Erzeuge sie in der App-Struct und leg sie mit
.environment(...)in die Umgebung. - Zeig in
MealRowein kleines gefülltes Herz, wenn das Rezept ein Favorit ist. - Ergänze in der Toolbar der Detailansicht einen Herz-Button, der den Favoriten umschaltet.
LösungErst selbst probieren!
import Observation
@Observable
class FavoritesStore {
private(set) var ids: Set<String> = []
func contains(id: String) -> Bool {
ids.contains(id)
}
func toggle(id: String) {
if ids.contains(id) {
ids.remove(id)
} else {
ids.insert(id)
}
}
}In RezepteApp.swift:
@main
struct RezepteApp: App {
@State private var favorites = FavoritesStore()
var body: some Scene {
WindowGroup {
MealListView()
.environment(favorites)
}
}
}In MealRow:
@Environment(FavoritesStore.self) private var favorites
// im HStack, nach dem Namen:
Spacer()
if favorites.contains(id: meal.id) {
Image(systemName: "heart.fill")
.foregroundStyle(.pink)
}In MealDetailView am content:
@Environment(FavoritesStore.self) private var favorites
.toolbar {
let isFavorite = favorites.contains(id: meal.id)
Button(isFavorite ? "Favorit entfernen" : "Als Favorit merken",
systemImage: isFavorite ? "heart.fill" : "heart") {
favorites.toggle(id: meal.id)
}
.tint(.pink)
.sensoryFeedback(.success, trigger: isFavorite)
}Denk an die Previews: Jede View, die den Store aus der Umgebung liest, braucht dort .environment(FavoritesStore()), sonst stürzt die Preview ab (Lektion 3.3).
Achtung
Die Favoriten sind nach einem Neustart der App wieder weg, weil sie nur im Speicher liegen. In der nächsten Lektion lernst du @AppStorage. Damit kannst du sie mit wenigen Zeilen dauerhaft speichern. Eine Bonus-Aufgabe dort greift genau das auf.
Schritt 6: Präzise Fehlermeldungen
Aufgabe
Ergänze das ViewModel um eine private Methode message(for error: Error) -> String, die unterscheidet:
URLError.notConnectedToInternet: Keine InternetverbindungURLError.timedOut: Server antwortet nichtAPIError.badStatus: Problem beim ServerDecodingError: Daten konnten nicht gelesen werden- alles andere: allgemeine Meldung
LösungErst selbst probieren!
private func message(for error: Error) -> String {
switch error {
case let urlError as URLError where urlError.code == .notConnectedToInternet:
"Keine Internetverbindung. Prüfe WLAN oder mobile Daten."
case let urlError as URLError where urlError.code == .timedOut:
"Der Server antwortet gerade nicht. Versuch es gleich noch einmal."
case APIError.badStatus:
"Beim Rezept-Server ist ein Problem aufgetreten. Versuch es später erneut."
case is DecodingError:
"Die Rezepte konnten nicht gelesen werden."
default:
"Die Rezepte konnten nicht geladen werden."
}
}Ein switch über einen Fehler mit Mustern wie as URLError where ..., APIError.badStatus und is DecodingError vereint alles, was du über Enums, Typumwandlung und Bedingungen gelernt hast. Weil jeder Fall nur einen Wert liefert, wird der switch als Ausdruck verwendet (Lektion 1.4).
Schritt 7: Zufallsrezept
Aufgabe
Ergänze MealAPI um random() async throws -> Meal? (random.php). Füg in der Toolbar von MealListView einen Würfel-Button (dice) hinzu. Ein Tipp lädt ein zufälliges Rezept und öffnet sofort seine Detailansicht.
Tipp: Wertbasierte Navigation per Code geht mit einem NavigationStack(path:) und einem Array von Meals. Hängst du ein Meal an das Array an, öffnet sich die Detailansicht. Ausführlich lernst du das in Lektion 3.20.
LösungErst selbst probieren!
In der View:
@State private var path: [Meal] = []
var body: some View {
NavigationStack(path: $path) {
content
// … wie bisher …
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Zufallsrezept", systemImage: "dice") {
Task {
if let meal = try? await MealAPI().random() {
path.append(meal)
}
}
}
}
}
}
}random.php liefert zwar die vollständigen Details, aber unser Meal liest daraus einfach nur id, name und thumbnail. Die Detailansicht lädt den Rest wie gewohnt nach.
Die fertige App
Erwartetes Ergebnis
Die App startet mit Desserts. Über das Filter-Symbol wechselst du zu Hähnchen, Pasta oder Vegetarisch. Oben suchst du über alle Rezepte, die Ergebnisse erscheinen kurz nach dem Tippen. Ziehen aktualisiert. Der Würfel öffnet ein Überraschungsrezept. Favoriten haben ein Herz. Und ohne Internet erklärt die App freundlich, was los ist.
Was du in diesem Projekt angewendet hast
| Baustein | Wo im Projekt | Lektion |
|---|---|---|
@Observable, Umgebung | FavoritesStore | 3.1, 3.3 |
@Bindable-Bindings auf ViewModel | Kategorie, Suchtext | 3.2 |
| ViewModel nach Bedarf | Liste mit, Details ohne | 3.5 |
async/await, .task, .task(id:) | Laden, Neuladen, Debouncing | 3.6, 3.7 |
Abbruch und @MainActor | Suche, ViewModel | 3.7, 3.8 |
Codable, eigener Decoder | Modelle, Zutaten | 3.9 |
URLSession, generische API | MealAPI | 3.10 |
LoadState, Fehlermeldungen | Liste, Details | 3.11 |
searchable, refreshable | Suche, Aktualisieren | 3.12 |
Geschafft
Herzlichen Glückwunsch! Deine App spricht mit dem Internet, und zwar so, wie es eine professionelle App tun sollte: mit Ladezuständen, Fehlerbehandlung, Abbruch veralteter Anfragen und einer sauberen Trennung von Netzwerk, Logik und Oberfläche. Diese Grundlage brauchst du in fast jeder echten App.
Bonus-Aufgaben
Bonus 1: Favoriten-Tab
Zeig unter der Kategorieliste eine Section „Favoriten“ mit allen gemerkten Rezepten. Weil der Store nur IDs kennt, musst du die Details dafür nachladen. Ein Weg: im FavoritesStore zusätzlich die Namen speichern.
Bonus 2: Kategorie-Raster
Ersetze das Kategorie-Menü durch einen eigenen Startbildschirm mit allen Kategorien als Kacheln mit Bild. Ein Tipp öffnet die Rezeptliste der Kategorie. Grids lernst du in Lektion 3.17. Wenn du vorgreifen willst: LazyVGrid.
Zusammenfassung
.task(id:)mit einem Wert aus Kategorie und Suchtext lädt neu, sobald sich eines davon ändert, und entprellt die Suche.load()zeigt den großen Ladezustand,refresh()aktualisiert still, ideal für.refreshable.- Ein geteilter Store in der Umgebung macht Favoriten in Liste und Details verfügbar.
- Ein
switchüber Fehler übersetzt technische Fehler in verständliche Meldungen. NavigationStack(path:)ermöglicht Navigation per Code, etwa zum Zufallsrezept.