Lernziel
In diesem Projektteil baust du das Fundament einer Rezept-App mit echten Daten aus dem Internet: Datenmodell, eine generische API-Schicht für TheMealDB, ein ViewModel mit sauberen Ladezuständen und eine Liste mit Bildern aus dem Netz.
Worum es geht
In Projekt D baust du eine Rezept-App, die ihre Inhalte aus der freien Online-Datenbank TheMealDB lädt. Du kennst die API aus Lektion 3.10.
| Teil | Inhalt | Lektionen |
|---|---|---|
| 1 | API-Schicht, Ladezustände, Rezeptliste mit Bildern | 3.6–3.11 |
| 2 | Detailansicht mit Zutaten, eigenem Dekodieren und Bild-Zuständen | 3.9, 3.10, 3.11 |
| 3 | Kategorien, Suche, Aktualisieren, Fehlerbehandlung | 3.1–3.3, 3.12 |
Am Ende hast du eine App, die sich anfühlt wie eine echte: Sie lädt, zeigt Fortschritt, erklärt Fehler und reagiert flüssig auf Eingaben.
So arbeitest du mit diesem Projekt
Wie gewohnt: Erst selbst versuchen, dann aufklappen. Öffne parallel die API-Adressen im Browser, um zu sehen, welche Daten zurückkommen. Das ist bei echten APIs die halbe Arbeit.
Schritt 1: Die API erkunden
Wir verwenden diese Funktionen von TheMealDB:
| Zweck | Adresse |
|---|---|
| Alle Kategorien | …/categories.php |
| Rezepte einer Kategorie | …/filter.php?c=Dessert |
| Ein Rezept mit allen Details | …/lookup.php?i=52772 |
| Suche nach Name | …/search.php?s=pasta |
| Zufälliges Rezept | …/random.php |
Die Basisadresse ist https://www.themealdb.com/api/json/v1/1/.
Aufgabe
Öffne die Adresse https://www.themealdb.com/api/json/v1/1/filter.php?c=Dessert im Browser. Welche Felder hat ein Rezept in dieser Antwort? Vergleich mit lookup.php?i=52772. Was fällt dir auf?
LösungErst selbst probieren!
filter.php liefert pro Rezept nur drei Felder: idMeal, strMeal und strMealThumb. lookup.php liefert dagegen alles: Kategorie, Herkunft, Anleitung, bis zu 20 Zutaten mit Mengen, Video-Link und mehr.
Das ist typisch für APIs: Listen liefern eine Kurzfassung, Details gibt es nur einzeln. Für unsere App heißt das: Die Liste verwendet ein schlankes Modell, die Detailansicht ein ausführliches.
Schritt 2: Projekt und Modelle
Leg ein neues Projekt an: iOS → App, Name Rezepte, Interface SwiftUI. Leg die Gruppen Models, Networking und Views an.
Aufgabe
Leg in Models die Datei Meal.swift an mit:
MealResponsemit einem optionalen Arraymeals,Meal(Decodable,Identifiable,Hashable) mitid,nameundthumbnail(optional) sowie den passendenCodingKeys.- Eine berechnete Eigenschaft
thumbnailURL: URL?, die aus dem Text eine Adresse macht. - Zwei Beispiel-Rezepte als
static let examplesin einer Extension für Previews (Lektion 1.16).
LösungErst selbst probieren!
import Foundation
struct MealResponse: Decodable {
let meals: [Meal]?
}
struct Meal: Decodable, Identifiable, Hashable {
let id: String
let name: String
let thumbnail: String?
var thumbnailURL: URL? {
thumbnail.flatMap { URL(string: $0) }
}
enum CodingKeys: String, CodingKey {
case id = "idMeal"
case name = "strMeal"
case thumbnail = "strMealThumb"
}
}
extension Meal {
static let examples = [
Meal(id: "52772", name: "Teriyaki Chicken Casserole", thumbnail: nil),
Meal(id: "52771", name: "Spicy Arrabiata Penne", thumbnail: nil)
]
}thumbnail.flatMap { URL(string: $0) }wandelt das optionale Text-Feld in eine optionale Adresse um. Istthumbnailniloder kein gültiger Link, ist das Ergebnisnil(Lektion 1.9, 🔴-FAQ).- Obwohl
MealDecodableist, gibt es weiterhin den automatischen Initializer mit allen Eigenschaften (Lektion 1.12). Damit legen wir die Beispieldaten an. Weilthumbnaileine Konstante ohne Startwert ist, muss sie dort angegeben werden, hier alsnil.
Schritt 3: Eine generische API-Schicht
In Lektion 3.10 lieferte fetch immer [Meal]. Für Kategorien und Details brauchen wir aber andere Typen. Mit einem Generic (Lektion 1.18) schreiben wir die Logik einmal für alle.
Aufgabe
Leg in Networking die Datei MealAPI.swift an:
- Ein Enum
APIError: ErrormitbadStatus(Int). - Eine Struct
MealAPImit Basisadresse undURLSession(Standard.shared). - Eine private, generische Methode
fetch<T: Decodable>(_ path: String, query: [String: String] = [:]) async throws -> T, die Adresse baut, lädt, den Status prüft und dekodiert. - Eine öffentliche Methode
meals(in category: String) async throws -> [Meal], diefilter.phpmitcaufruft.
LösungErst selbst probieren!
import Foundation
enum APIError: Error {
case badStatus(Int)
}
struct MealAPI {
private let baseURL = URL(string: "https://www.themealdb.com/api/json/v1/1/")!
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func meals(in category: String) async throws -> [Meal] {
let response: MealResponse = try await fetch("filter.php", query: ["c": category])
return response.meals ?? []
}
private func fetch<T: Decodable>(_ path: String, query: [String: String] = [:]) async throws -> T {
var url = baseURL.appending(path: path)
if !query.isEmpty {
url.append(queryItems: query.map { URLQueryItem(name: $0.key, value: $0.value) })
}
let (data, response) = try await session.data(from: url)
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
throw APIError.badStatus(http.statusCode)
}
return try JSONDecoder().decode(T.self, from: data)
}
}Wie weiß fetch, welchen Typ es dekodieren soll? Aus dem Rückgabetyp: In let response: MealResponse = try await fetch(...) steht der gewünschte Typ links. Swift setzt ihn für T ein. Deshalb steht dort die ausdrückliche Typangabe : MealResponse (Lektion 1.2).
Schritt 4: Der Ladezustand
Aufgabe
Leg in Models die Datei LoadState.swift mit dem generischen Enum LoadState<Value> aus Lektion 3.11 an.
LösungErst selbst probieren!
enum LoadState<Value> {
case idle
case loading
case loaded(Value)
case failed(String)
}Schritt 5: Das ViewModel
Aufgabe
Leg in Views ein @MainActor @Observable-ViewModel MealListViewModel an:
state: LoadState<[Meal]>, Startwert.idle,category: String, Startwert"Dessert",- eine private
MealAPI, func load() async, die den Zustand setzt, lädt und Fehler in eine verständliche Meldung übersetzt.
LösungErst selbst probieren!
import Foundation
@MainActor
@Observable
class MealListViewModel {
var state: LoadState<[Meal]> = .idle
var category = "Dessert"
private let api = MealAPI()
func load() async {
state = .loading
do {
let meals = try await api.meals(in: category)
state = .loaded(meals)
} catch {
print("Laden fehlgeschlagen: \(error)")
state = .failed("Die Rezepte konnten nicht geladen werden. Prüfe deine Internetverbindung und versuch es erneut.")
}
}
}Die technische Meldung landet mit print in der Konsole, für dich als Entwickler. Der Nutzer bekommt einen verständlichen Text (Lektion 3.11).
Schritt 6: Eine Zeile mit Bild
Aufgabe
Baue eine View MealRow, die ein Rezept bekommt und links ein Vorschaubild (64 × 64, abgerundete Ecken) und daneben den Namen zeigt. Verwende AsyncImage. Während das Bild lädt, ist ein grauer Platzhalter zu sehen.
LösungErst selbst probieren!
import SwiftUI
struct MealRow: View {
let meal: Meal
var body: some View {
HStack(spacing: 12) {
AsyncImage(url: meal.thumbnailURL) { image in
image
.resizable()
.scaledToFill()
} placeholder: {
Color(.secondarySystemBackground)
}
.frame(width: 64, height: 64)
.clipShape(.rect(cornerRadius: 10))
Text(meal.name)
.font(.headline)
.lineLimit(2)
}
.padding(.vertical, 4)
}
}
#Preview(traits: .sizeThatFitsLayout) {
MealRow(meal: Meal.examples[0])
.padding()
}Eine graue Fläche als Platzhalter ist in Listen oft ruhiger als viele drehende Ladekreise.
Schritt 7: Die Liste
Aufgabe
Benenne ContentView in MealListView um. Sie besitzt das ViewModel, zeigt in einem NavigationStack mit dem Titel der Kategorie je nach Zustand einen Ladekreis, eine Fehleransicht mit „Erneut versuchen“, eine Leeransicht oder die Liste, und lädt beim Erscheinen.
LösungErst selbst probieren!
import SwiftUI
struct MealListView: View {
@State private var viewModel = MealListViewModel()
var body: some View {
NavigationStack {
content
.navigationTitle(viewModel.category)
.task {
await viewModel.load()
}
}
}
@ViewBuilder
private var content: some View {
switch viewModel.state {
case .idle, .loading:
ProgressView("Rezepte werden geladen…")
case .failed(let message):
ContentUnavailableView {
Label("Keine Verbindung", systemImage: "wifi.exclamationmark")
} description: {
Text(message)
} actions: {
Button("Erneut versuchen") {
Task { await viewModel.load() }
}
.buttonStyle(.borderedProminent)
}
case .loaded(let meals) where meals.isEmpty:
ContentUnavailableView(
"Keine Rezepte",
systemImage: "fork.knife",
description: Text("In dieser Kategorie gibt es keine Rezepte.")
)
case .loaded(let meals):
List(meals) { meal in
MealRow(meal: meal)
}
.listStyle(.plain)
}
}
}
#Preview {
MealListView()
}Erwartetes Ergebnis
Kurz ein Ladekreis, dann eine Liste mit Desserts, jedes mit Foto. Beim schnellen Scrollen erscheinen Bilder nacheinander, während sie geladen werden.
Die Preview lädt echte Daten aus dem Internet. Das ist praktisch, macht Previews aber langsam und abhängig vom Netz. Im nächsten Teil sehen wir, wie man Previews mit festen Zuständen füttert.
Schritt 8: Previews mit festen Zuständen
Aufgabe
Ermögliche Previews ohne Internet:
- Gib
MealListViewModeleinen Initializer mit einem optionalen Startzustand. - Gib
MealListVieweinen Initializer, der ein ViewModel übernimmt (mit_viewModel = State(initialValue:), Lektion 3.2). - Lade in
.tasknur, wenn der Zustand.idleist. - Leg Previews für „Laden“, „Fehler“, „Leer“ und „Mit Daten“ an.
LösungErst selbst probieren!
Im ViewModel:
init(state: LoadState<[Meal]> = .idle) {
self.state = state
}In der View:
struct MealListView: View {
@State private var viewModel: MealListViewModel
init(viewModel: MealListViewModel = MealListViewModel()) {
_viewModel = State(initialValue: viewModel)
}
var body: some View {
NavigationStack {
content
.navigationTitle(viewModel.category)
.task {
if case .idle = viewModel.state {
await viewModel.load()
}
}
}
}
// … content wie bisher …
}
#Preview("Daten") {
MealListView(viewModel: MealListViewModel(state: .loaded(Meal.examples)))
}
#Preview("Laden") {
MealListView(viewModel: MealListViewModel(state: .loading))
}
#Preview("Fehler") {
MealListView(viewModel: MealListViewModel(state: .failed("Testfehler")))
}
#Preview("Leer") {
MealListView(viewModel: MealListViewModel(state: .loaded([])))
}Die Prüfung if case .idle hat einen schönen Nebeneffekt: Kehrt der Nutzer später zur Liste zurück, wird nicht unnötig neu geladen (Lektion 3.7, „.task wird mehrfach ausgeführt“).
Bonus-Aufgaben
Bonus 1: Anzahl im Titel
Zeig unter der Liste, wie viele Rezepte geladen wurden, etwa „24 Rezepte“, als Fußzeile der Liste.
Bonus 2: Skeleton statt Ladekreis
Zeig beim Laden statt des Ladekreises eine Liste mit fünf Platzhalter-Zeilen. Verwende MealRow mit Beispieldaten und .redacted(reason: .placeholder) (Lektion 3.11, 🟡-FAQ).
Zusammenfassung
- APIs liefern oft eine Kurzfassung in Listen und Details einzeln.
- Eine generische
fetch-Methode lädt, prüft und dekodiert jedenDecodable-Typ. Der Typ ergibt sich aus der Typangabe beim Aufruf. - Das ViewModel hält einen
LoadStateund übersetzt Fehler in verständliche Meldungen. AsyncImagemit ruhigem Platzhalter für Bilder in Listen.- Previews mit festen Zuständen machen die Entwicklung unabhängig vom Netz.