Lernziel
In diesem Projektteil baust du die Detailansicht eines Rezepts: Du lädst die vollständigen Daten, dekodierst die ungewöhnlich aufgebaute Zutatenliste mit einem eigenen Initializer, zeigst ein großes Bild mit allen Ladephasen von AsyncImage und verlinkst auf Video und Originalquelle.
Ausgangslage
Nach Teil 1 zeigt die App eine Liste von Desserts mit Bild und Name. Ein Tipp auf ein Rezept bewirkt noch nichts. Jetzt kommt die Detailansicht, und mit ihr die spannendste technische Herausforderung des Projekts: die Zutaten.
Schritt 1: Ein Blick auf die Details
Öffne https://www.themealdb.com/api/json/v1/1/lookup.php?i=52772 im Browser. Die Antwort enthält unter anderem:
{
"meals": [{
"idMeal": "52772",
"strMeal": "Teriyaki Chicken Casserole",
"strCategory": "Chicken",
"strArea": "Japanese",
"strInstructions": "Preheat oven to 350° F…",
"strMealThumb": "https://…/wvpsxx1468256321.jpg",
"strYoutube": "https://www.youtube.com/watch?v=4aZr5hZXP_s",
"strIngredient1": "soy sauce",
"strIngredient2": "water",
"strIngredient3": "brown sugar",
"strIngredient4": "",
"strMeasure1": "3/4 cup",
"strMeasure2": "1/2 cup",
"strMeasure3": "1/4 cup",
"strMeasure4": "",
"strSource": null
}]
}Fällt dir etwas auf? Die Zutaten sind kein Array. Es gibt zwanzig einzelne Felder strIngredient1 bis strIngredient20 und genauso viele strMeasure-Felder. Unbenutzte sind leer oder null.
So etwas kommt bei echten APIs häufiger vor, als man denkt. Mit der automatischen Codable-Unterstützung aus Lektion 3.9 bräuchten wir vierzig Eigenschaften. Viel schöner wäre ein Array ingredients: [Ingredient]. Genau das bauen wir.
Schritt 2: Das Detailmodell
Aufgabe
Leg in Models die Datei MealDetail.swift an mit:
- einer Struct
Ingredient(Identifiable,Hashable) mitnameundmeasureund einerid, die aus dem Namen besteht, - einer Struct
MealDetailmitid,name,category,area,instructions,thumbnail,youtube,source(alle außeridundnameoptional) undingredients: [Ingredient], - einer Antwort-Struct
MealDetailResponsemitmeals: [MealDetail]?.
Noch ohne Dekodieren, das kommt im nächsten Schritt.
LösungErst selbst probieren!
import Foundation
struct Ingredient: Identifiable, Hashable {
let name: String
let measure: String
var id: String { name }
}
struct MealDetail: Identifiable {
let id: String
let name: String
let category: String?
let area: String?
let instructions: String?
let thumbnail: String?
let youtube: String?
let source: String?
let ingredients: [Ingredient]
var thumbnailURL: URL? { thumbnail.flatMap { URL(string: $0) } }
var youtubeURL: URL? { youtube.flatMap { URL(string: $0) } }
var sourceURL: URL? { source.flatMap { URL(string: $0) } }
}
struct MealDetailResponse: Decodable {
let meals: [MealDetail]?
}id ist hier eine berechnete Eigenschaft. Das ist ausnahmsweise in Ordnung, weil sie aus einem festen Wert, dem Namen, abgeleitet wird und sich nicht bei jedem Zugriff ändert (Lektion 2.16).
Schritt 3: Eigenes Dekodieren
Jetzt bringen wir MealDetail bei, sich aus dem JSON zu erzeugen. Für die normalen Felder verwenden wir CodingKeys wie gewohnt. Für die zwanzig Zutatenfelder brauchen wir Schlüssel, die wir zur Laufzeit erzeugen: "strIngredient" + Nummer.
Dafür gibt es eine kleine Hilfs-Struct, die das Protokoll CodingKey erfüllt und jeden beliebigen Text als Schlüssel akzeptiert:
struct DynamicKey: CodingKey {
let stringValue: String
var intValue: Int? { nil }
init(stringValue: String) {
self.stringValue = stringValue
}
init?(intValue: Int) {
nil
}
}CodingKey verlangt beide Initializer. Wir brauchen nur den mit Text. Der für Zahlen liefert einfach nil.
Aufgabe
Mach MealDetail mit einer Extension Decodable und schreib init(from decoder: Decoder) throws:
- Lies die normalen Felder über einen Container mit
CodingKeys. - Hol dir mit
decoder.container(keyedBy: DynamicKey.self)einen zweiten Container. - Lies in einer Schleife von 1 bis 20 jeweils
strIngredientNundstrMeasureNmitdecodeIfPresent, entferne Leerzeichen und füge nur Zutaten mit nicht-leerem Namen hinzu.
LösungErst selbst probieren!
extension MealDetail: Decodable {
enum CodingKeys: String, CodingKey {
case id = "idMeal"
case name = "strMeal"
case category = "strCategory"
case area = "strArea"
case instructions = "strInstructions"
case thumbnail = "strMealThumb"
case youtube = "strYoutube"
case source = "strSource"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(String.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
category = try container.decodeIfPresent(String.self, forKey: .category)
area = try container.decodeIfPresent(String.self, forKey: .area)
instructions = try container.decodeIfPresent(String.self, forKey: .instructions)
thumbnail = try container.decodeIfPresent(String.self, forKey: .thumbnail)
youtube = try container.decodeIfPresent(String.self, forKey: .youtube)
source = try container.decodeIfPresent(String.self, forKey: .source)
let dynamic = try decoder.container(keyedBy: DynamicKey.self)
var list: [Ingredient] = []
for number in 1...20 {
let nameKey = DynamicKey(stringValue: "strIngredient\(number)")
let measureKey = DynamicKey(stringValue: "strMeasure\(number)")
let name = try dynamic.decodeIfPresent(String.self, forKey: nameKey)?
.trimmingCharacters(in: .whitespaces) ?? ""
let measure = try dynamic.decodeIfPresent(String.self, forKey: measureKey)?
.trimmingCharacters(in: .whitespaces) ?? ""
if !name.isEmpty {
list.append(Ingredient(name: name, measure: measure))
}
}
ingredients = list
}
}So funktioniert es:
decoder.container(keyedBy:)liefert einen Zugriff auf das JSON-Objekt über eine bestimmte Art von Schlüsseln. Man darf sich vom selben Decoder mehrere Container mit verschiedenen Schlüsseltypen holen.decodeverlangt, dass das Feld existiert.decodeIfPresentliefertnil, wenn es fehlt odernullist.?.trimmingCharacters(...)ist Optional Chaining,?? ""der Ersatzwert (Lektion 1.9).- Weil
CodingKeysund der eigeneinitin der Extension stehen, bleibt der normale Initializer der Struct erhalten (Lektion 1.16).
Tipp
Dieses Muster, mehrere Felder im JSON zu einem sauberen Swift-Typ zusammenzufassen, ist eine der wichtigsten Fähigkeiten im Umgang mit echten APIs. Dein Modell soll so aussehen, wie es für deine App sinnvoll ist, nicht so, wie die API es zufällig liefert.
Schritt 4: Die API erweitern
Aufgabe
Ergänze MealAPI um func detail(id: String) async throws -> MealDetail?, die lookup.php mit i aufruft.
LösungErst selbst probieren!
func detail(id: String) async throws -> MealDetail? {
let response: MealDetailResponse = try await fetch("lookup.php", query: ["i": id])
return response.meals?.first
}Schritt 5: Navigation zur Detailansicht
Aufgabe
- Mach in
MealListViewjede Zeile zu einem wertbasiertenNavigationLinkmit demMealals Wert (Lektion 2.17). - Ergänze
.navigationDestination(for: Meal.self), das eineMealDetailView(meal:)zeigt. - Baue
MealDetailViewzunächst als Platzhalter, der nur den Namen zeigt.
LösungErst selbst probieren!
case .loaded(let meals):
List(meals) { meal in
NavigationLink(value: meal) {
MealRow(meal: meal)
}
}
.listStyle(.plain)Und am content im NavigationStack:
.navigationDestination(for: Meal.self) { meal in
MealDetailView(meal: meal)
}Meal ist schon Hashable, deshalb funktioniert die wertbasierte Navigation direkt.
Schritt 6: Die Detailansicht lädt
Die Detailansicht bekommt nur die Kurzfassung (Meal) und muss die Details selbst nachladen.
Aufgabe
Baue MealDetailView:
- Eigenschaft
let meal: Meal, @State private var state: LoadState<MealDetail> = .idle,.tasklädt die Details überMealAPI().detail(id:). Kommtnilzurück, ist das ein Fehler „Rezept nicht gefunden“.- Zeig je nach Zustand einen Ladekreis, eine Fehleransicht mit „Erneut versuchen“ oder den Inhalt.
- Titel ist der Name aus
meal, damit er sofort sichtbar ist, noch bevor die Details da sind.
Für den Inhalt reicht zunächst ein Text(detail.instructions ?? "").
LösungErst selbst probieren!
import SwiftUI
struct MealDetailView: View {
let meal: Meal
@State private var state: LoadState<MealDetail> = .idle
private let api = MealAPI()
var body: some View {
content
.navigationTitle(meal.name)
.navigationBarTitleDisplayMode(.inline)
.task {
if case .idle = state {
await load()
}
}
}
@ViewBuilder
private var content: some View {
switch state {
case .idle, .loading:
ProgressView()
case .failed(let message):
ContentUnavailableView {
Label("Fehler", systemImage: "exclamationmark.triangle")
} description: {
Text(message)
} actions: {
Button("Erneut versuchen") {
Task { await load() }
}
}
case .loaded(let detail):
ScrollView {
Text(detail.instructions ?? "")
.padding()
}
}
}
private func load() async {
state = .loading
do {
if let detail = try await api.detail(id: meal.id) {
state = .loaded(detail)
} else {
state = .failed("Dieses Rezept wurde nicht gefunden.")
}
} catch {
print(error)
state = .failed("Das Rezept konnte nicht geladen werden.")
}
}
}Hier braucht es kein eigenes ViewModel: Der Zustand gehört nur dieser View, und die Logik ist kurz (Lektion 3.5).
Schritt 7: Ein schöner Inhalt
Aufgabe
Ersetze den einfachen Text durch eine eigene View MealDetailContent(detail:) mit:
- dem großen Bild über die volle Breite, Höhe 280,
- Kategorie und Herkunft als zwei kleine Kapseln (
Labelmit Hintergrund), - einem Abschnitt „Zutaten“ mit allen Zutaten: Name links, Menge rechts in gedämpfter Farbe,
- einem Abschnitt „Zubereitung“ mit der Anleitung.
LösungErst selbst probieren!
struct MealDetailContent: View {
let detail: MealDetail
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
MealHeaderImage(url: detail.thumbnailURL)
HStack {
if let category = detail.category {
Tag(text: category, icon: "fork.knife")
}
if let area = detail.area {
Tag(text: area, icon: "globe.europe.africa")
}
}
.padding(.horizontal)
VStack(alignment: .leading, spacing: 12) {
Text("Zutaten")
.font(.title2).bold()
ForEach(detail.ingredients) { ingredient in
HStack {
Text(ingredient.name.capitalized)
Spacer()
Text(ingredient.measure)
.foregroundStyle(.secondary)
}
Divider()
}
}
.padding(.horizontal)
if let instructions = detail.instructions {
VStack(alignment: .leading, spacing: 12) {
Text("Zubereitung")
.font(.title2).bold()
Text(instructions)
.lineSpacing(4)
}
.padding(.horizontal)
}
}
.padding(.bottom)
}
}
}
struct Tag: View {
let text: String
let icon: String
var body: some View {
Label(text, systemImage: icon)
.font(.caption)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Color(.secondarySystemBackground))
.clipShape(.capsule)
}
}.capitalized macht aus „soy sauce“ „Soy Sauce“. MealHeaderImage folgt im nächsten Schritt.
Schritt 8: Alle Phasen von AsyncImage
Bisher hatte AsyncImage nur zwei Zustände: Platzhalter und Bild. Was, wenn das Bild nicht geladen werden kann? Dann bleibt der Platzhalter für immer stehen. Für ein großes Titelbild ist das unschön.
AsyncImage hat eine dritte Form, die jede Phase einzeln liefert:
Aufgabe
Baue MealHeaderImage(url:) mit AsyncImage(url:) { phase in ... } und behandle mit switch die drei Phasen .empty (Ladekreis auf grauem Grund), .success(let image) (das Bild) und .failure (ein Symbol photo auf grauem Grund). Weil AsyncImagePhase in Zukunft weitere Fälle bekommen könnte, braucht der switch auch ein @unknown default.
LösungErst selbst probieren!
struct MealHeaderImage: View {
let url: URL?
var body: some View {
AsyncImage(url: url) { phase in
switch phase {
case .empty:
ZStack {
Color(.secondarySystemBackground)
ProgressView()
}
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
ZStack {
Color(.secondarySystemBackground)
Image(systemName: "photo")
.font(.largeTitle)
.foregroundStyle(.secondary)
}
@unknown default:
Color(.secondarySystemBackground)
}
}
.frame(height: 280)
.frame(maxWidth: .infinity)
.clipped()
}
}.clipped() schneidet das Bild ab, das mit .scaledToFill() über den Rahmen hinausragt (Lektion 2.6).
Schritt 9: Video und Quelle
Aufgabe
Ergänze am Ende von MealDetailContent zwei Links: „Video ansehen“ (YouTube) und „Originalrezept“ (Quelle), jeweils nur, wenn eine gültige Adresse vorhanden ist. Verwende Link("Titel", destination: url), das die Adresse im Browser oder in der passenden App öffnet.
LösungErst selbst probieren!
VStack(alignment: .leading, spacing: 12) {
if let url = detail.youtubeURL {
Link(destination: url) {
Label("Video ansehen", systemImage: "play.rectangle.fill")
}
}
if let url = detail.sourceURL {
Link(destination: url) {
Label("Originalrezept", systemImage: "safari")
}
}
}
.padding(.horizontal)Erwartetes Ergebnis
Ein Tipp auf ein Rezept zeigt erst kurz einen Ladekreis, dann ein großes Foto, zwei kleine Kapseln mit Kategorie und Herkunft, eine saubere Zutatenliste mit Mengen und die Anleitung. Ganz unten öffnen die Links Video und Quelle.
Bonus-Aufgaben
Bonus 1: Teilen
Ergänze in der Toolbar der Detailansicht einen ShareLink, der den Namen des Rezepts und, falls vorhanden, die Quelle teilt.
Bonus 2: Einkaufsliste
Mach die Zutaten abhakbar: Merk dir in einem @State-Set die Namen der abgehakten Zutaten (Lektion 1.7) und zeig sie durchgestrichen. Ein Button „Alle zurücksetzen“ leert das Set.
Zusammenfassung
- Echte APIs liefern Daten nicht immer so, wie die App sie braucht. Dein Modell sollte zu deiner App passen.
- Mit einem eigenen
init(from:)und einemDynamicKeyfasst du verstreute Felder zu einem Array zusammen. decodeIfPresentliest optionale Felder, die fehlen odernullsein können.- Die Detailansicht lädt nach, zeigt den Titel aber sofort aus der Kurzfassung.
AsyncImagemit Phasen behandelt Laden, Erfolg und Fehler einzeln.Linköffnet Adressen im Browser oder in der passenden App.