Projekt D · Teil 1 · Stufe 3 – Fortgeschritten

Rezept-App mit Online-API (Teil 1)

ca. 15 Minuten Lesezeit

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.

TeilInhaltLektionen
1API-Schicht, Ladezustände, Rezeptliste mit Bildern3.6–3.11
2Detailansicht mit Zutaten, eigenem Dekodieren und Bild-Zuständen3.9, 3.10, 3.11
3Kategorien, Suche, Aktualisieren, Fehlerbehandlung3.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:

ZweckAdresse
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/.

Anfänger

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.

Fortgeschritten

Aufgabe

Leg in Models die Datei Meal.swift an mit:

  • MealResponse mit einem optionalen Array meals,
  • Meal (Decodable, Identifiable, Hashable) mit id, name und thumbnail (optional) sowie den passenden CodingKeys.
  • Eine berechnete Eigenschaft thumbnailURL: URL?, die aus dem Text eine Adresse macht.
  • Zwei Beispiel-Rezepte als static let examples in einer Extension für Previews (Lektion 1.16).
LösungErst selbst probieren!
Swift
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. Ist thumbnail nil oder kein gültiger Link, ist das Ergebnis nil (Lektion 1.9, 🔴-FAQ).
  • Obwohl Meal Decodable ist, gibt es weiterhin den automatischen Initializer mit allen Eigenschaften (Lektion 1.12). Damit legen wir die Beispieldaten an. Weil thumbnail eine Konstante ohne Startwert ist, muss sie dort angegeben werden, hier als nil.

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.

Profi

Aufgabe

Leg in Networking die Datei MealAPI.swift an:

  1. Ein Enum APIError: Error mit badStatus(Int).
  2. Eine Struct MealAPI mit Basisadresse und URLSession (Standard .shared).
  3. 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.
  4. Eine öffentliche Methode meals(in category: String) async throws -> [Meal], die filter.php mit c aufruft.
LösungErst selbst probieren!
Swift
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

Anfänger

Aufgabe

Leg in Models die Datei LoadState.swift mit dem generischen Enum LoadState<Value> aus Lektion 3.11 an.

LösungErst selbst probieren!
Swift
enum LoadState<Value> {
    case idle
    case loading
    case loaded(Value)
    case failed(String)
}

Schritt 5: Das ViewModel

Fortgeschritten

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!
Swift
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

Fortgeschritten

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!
Swift
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

Fortgeschritten

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!
Swift
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.

Screenshot folgt: Rezeptliste mit Vorschaubildern

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

Profi

Aufgabe

Ermögliche Previews ohne Internet:

  1. Gib MealListViewModel einen Initializer mit einem optionalen Startzustand.
  2. Gib MealListView einen Initializer, der ein ViewModel übernimmt (mit _viewModel = State(initialValue:), Lektion 3.2).
  3. Lade in .task nur, wenn der Zustand .idle ist.
  4. Leg Previews für „Laden“, „Fehler“, „Leer“ und „Mit Daten“ an.
LösungErst selbst probieren!

Im ViewModel:

Swift
init(state: LoadState<[Meal]> = .idle) {
    self.state = state
}

In der View:

Swift
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

Fortgeschritten

Bonus 1: Anzahl im Titel

Zeig unter der Liste, wie viele Rezepte geladen wurden, etwa „24 Rezepte“, als Fußzeile der Liste.

Profi

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 jeden Decodable-Typ. Der Typ ergibt sich aus der Typangabe beim Aufruf.
  • Das ViewModel hält einen LoadState und übersetzt Fehler in verständliche Meldungen.
  • AsyncImage mit ruhigem Platzhalter für Bilder in Listen.
  • Previews mit festen Zuständen machen die Entwicklung unabhängig vom Netz.

Lektion geschafft?

Markiere sie als erledigt, damit du deinen Fortschritt siehst.