Build a Recipe Discovery App: REST API, Local Favorites, and Custom Animations


Remy from Ratatouille once said that anyone can cook — and with a great recipe browser on their iPhone, they might actually pull it off. Imagine an app where Remy could browse thousands of dishes by category, tap a beautifully animated card to see the full recipe, and save his personal favorites for later. That’s exactly what you’re going to build.

In this tutorial, you’ll build Remy’s Recipe Collection — a fully functional iOS app that fetches real recipe data from the free TheMealDB API, renders food cards using AsyncImage, persists favorites with SwiftData, and uses matchedGeometryEffect for a smooth hero transition from card to detail view. Along the way, you’ll learn how to decode complex nested JSON with Codable, implement pull-to-refresh, and add a load-more pagination pattern.

Prerequisites

Contents

Getting Started

Open Xcode and create a new project:

  1. Choose File › New › Project and select the App template under iOS.
  2. Set the Product Name to RemysRecipes.
  3. Set Interface to SwiftUI and Language to Swift.
  4. Make sure Storage is set to None — you’ll add SwiftData manually.
  5. Set your deployment target to iOS 18.0.

Once the project is open, delete ContentView.swift — you’ll replace it with purpose-built views. Your final file structure will look like this:

RemysRecipes/
├── Models/
│   ├── Meal.swift
│   ├── MealDetail.swift
│   └── FavoriteRecipe.swift
├── Services/
│   └── RecipeService.swift
├── Views/
│   ├── RecipeListView.swift
│   ├── RecipeCardView.swift
│   ├── RecipeDetailView.swift
│   └── FavoritesView.swift
├── ViewModels/
│   └── RecipeViewModel.swift
└── RemysRecipesApp.swift

Create each folder by adding new Swift files in the correct groups. You won’t need any third-party packages — this project uses only Apple frameworks.

Note: TheMealDB’s free tier is completely open: no API key, no authentication, no rate limiting (within reason). It’s perfect for tutorials and personal projects.

Step 1: Modeling the Data

TheMealDB returns a quirky JSON structure. Rather than clean normalized objects, it uses flat dictionaries with numbered keys like strIngredient1 through strIngredient20. Your Codable models need to handle this gracefully.

The Meal Summary Model

Create Models/Meal.swift. This model represents a single item in a category listing — just the ID, name, and thumbnail URL:

import Foundation

// Represents one recipe card in a category listing.
// The API wraps results in a {"meals": [...]} envelope.
struct MealListResponse: Codable {
    let meals: [Meal]
}

struct Meal: Codable, Identifiable, Hashable {
    let id: String
    let name: String
    let thumbnailURL: String

    enum CodingKeys: String, CodingKey {
        case id = "idMeal"
        case name = "strMeal"
        case thumbnailURL = "strMealThumb"
    }

    // Memberwise init — needed because CodingKeys suppresses auto-synthesis
    init(id: String, name: String, thumbnailURL: String) {
        self.id = id
        self.name = name
        self.thumbnailURL = thumbnailURL
    }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        name = try c.decode(String.self, forKey: .name)
        thumbnailURL = try c.decode(String.self, forKey: .thumbnailURL)
    }
}

The CodingKeys enum maps the API’s verbose property names (strMeal, strMealThumb) to clean Swift names. This is a standard Codable technique you’ll use throughout this step.

Note the Hashable conformance — SwiftUI’s NavigationLink(value:) and .navigationDestination(for:) require the value type to be Hashable. Since CodingKeys suppresses the automatic memberwise initializer, you also need the explicit init(id:name:thumbnailURL:) to create Meal instances from saved favorites later.

The Meal Detail Model

Create Models/MealDetail.swift. The detail endpoint returns all 20 possible ingredient/measure pairs — most will be empty strings. You’ll normalize them into a clean [Ingredient] array:

import Foundation

struct MealDetailResponse: Codable {
    let meals: [MealDetail]
}

struct Ingredient: Identifiable {
    let id = UUID()
    let name: String
    let measure: String
}

struct MealDetail: Codable, Identifiable {
    let id: String
    let name: String
    let category: String
    let area: String
    let instructions: String
    let thumbnailURL: String
    let youtubeURL: String?
    let sourceURL: String?

    // Raw numbered ingredient/measure pairs from the API
    private var rawIngredients: [String?]
    private var rawMeasures: [String?]

    // Computed property that zips and filters empty pairs
    var ingredients: [Ingredient] {
        zip(rawIngredients, rawMeasures)
            .compactMap { name, measure -> Ingredient? in
                guard let name, !name.isEmpty else { return nil }
                return Ingredient(name: name, measure: measure ?? "")
            }
    }

    enum CodingKeys: String, CodingKey {
        case id = "idMeal"
        case name = "strMeal"
        case category = "strCategory"
        case area = "strArea"
        case instructions = "strInstructions"
        case thumbnailURL = "strMealThumb"
        case youtubeURL = "strYoutube"
        case sourceURL = "strSource"

        // Numbered keys — there are 20 of each
        case i1 = "strIngredient1",  i2 = "strIngredient2",  i3 = "strIngredient3"
        case i4 = "strIngredient4",  i5 = "strIngredient5",  i6 = "strIngredient6"
        case i7 = "strIngredient7",  i8 = "strIngredient8",  i9 = "strIngredient9"
        case i10 = "strIngredient10", i11 = "strIngredient11", i12 = "strIngredient12"
        case i13 = "strIngredient13", i14 = "strIngredient14", i15 = "strIngredient15"
        case i16 = "strIngredient16", i17 = "strIngredient17", i18 = "strIngredient18"
        case i19 = "strIngredient19", i20 = "strIngredient20"

        case m1 = "strMeasure1",  m2 = "strMeasure2",  m3 = "strMeasure3"
        case m4 = "strMeasure4",  m5 = "strMeasure5",  m6 = "strMeasure6"
        case m7 = "strMeasure7",  m8 = "strMeasure8",  m9 = "strMeasure9"
        case m10 = "strMeasure10", m11 = "strMeasure11", m12 = "strMeasure12"
        case m13 = "strMeasure13", m14 = "strMeasure14", m15 = "strMeasure15"
        case m16 = "strMeasure16", m17 = "strMeasure17", m18 = "strMeasure18"
        case m19 = "strMeasure19", m20 = "strMeasure20"
    }

    init(from decoder: Decoder) throws {
        let c = try decoder.container(keyedBy: CodingKeys.self)
        id = try c.decode(String.self, forKey: .id)
        name = try c.decode(String.self, forKey: .name)
        category = try c.decode(String.self, forKey: .category)
        area = try c.decode(String.self, forKey: .area)
        instructions = try c.decode(String.self, forKey: .instructions)
        thumbnailURL = try c.decode(String.self, forKey: .thumbnailURL)
        youtubeURL = try c.decodeIfPresent(String.self, forKey: .youtubeURL)
        sourceURL = try c.decodeIfPresent(String.self, forKey: .sourceURL)

        rawIngredients = [
            try c.decodeIfPresent(String.self, forKey: .i1),
            try c.decodeIfPresent(String.self, forKey: .i2),
            try c.decodeIfPresent(String.self, forKey: .i3),
            try c.decodeIfPresent(String.self, forKey: .i4),
            try c.decodeIfPresent(String.self, forKey: .i5),
            try c.decodeIfPresent(String.self, forKey: .i6),
            try c.decodeIfPresent(String.self, forKey: .i7),
            try c.decodeIfPresent(String.self, forKey: .i8),
            try c.decodeIfPresent(String.self, forKey: .i9),
            try c.decodeIfPresent(String.self, forKey: .i10),
            try c.decodeIfPresent(String.self, forKey: .i11),
            try c.decodeIfPresent(String.self, forKey: .i12),
            try c.decodeIfPresent(String.self, forKey: .i13),
            try c.decodeIfPresent(String.self, forKey: .i14),
            try c.decodeIfPresent(String.self, forKey: .i15),
            try c.decodeIfPresent(String.self, forKey: .i16),
            try c.decodeIfPresent(String.self, forKey: .i17),
            try c.decodeIfPresent(String.self, forKey: .i18),
            try c.decodeIfPresent(String.self, forKey: .i19),
            try c.decodeIfPresent(String.self, forKey: .i20),
        ]

        rawMeasures = [
            try c.decodeIfPresent(String.self, forKey: .m1),
            try c.decodeIfPresent(String.self, forKey: .m2),
            try c.decodeIfPresent(String.self, forKey: .m3),
            try c.decodeIfPresent(String.self, forKey: .m4),
            try c.decodeIfPresent(String.self, forKey: .m5),
            try c.decodeIfPresent(String.self, forKey: .m6),
            try c.decodeIfPresent(String.self, forKey: .m7),
            try c.decodeIfPresent(String.self, forKey: .m8),
            try c.decodeIfPresent(String.self, forKey: .m9),
            try c.decodeIfPresent(String.self, forKey: .m10),
            try c.decodeIfPresent(String.self, forKey: .m11),
            try c.decodeIfPresent(String.self, forKey: .m12),
            try c.decodeIfPresent(String.self, forKey: .m13),
            try c.decodeIfPresent(String.self, forKey: .m14),
            try c.decodeIfPresent(String.self, forKey: .m15),
            try c.decodeIfPresent(String.self, forKey: .m16),
            try c.decodeIfPresent(String.self, forKey: .m17),
            try c.decodeIfPresent(String.self, forKey: .m18),
            try c.decodeIfPresent(String.self, forKey: .m19),
            try c.decodeIfPresent(String.self, forKey: .m20),
        ]
    }
}

The custom init(from:) initializer is necessary because rawIngredients and rawMeasures are not part of the public API surface — they exist purely to support the ingredients computed property. The Codable synthesizer can’t generate this for you, so you write it by hand.

The FavoriteRecipe Model

Create Models/FavoriteRecipe.swift. This is the SwiftData model that persists to disk:

import SwiftData
import Foundation

@Model
final class FavoriteRecipe {
    var mealID: String
    var name: String
    var thumbnailURL: String
    var savedDate: Date

    init(meal: Meal) {
        self.mealID = meal.id
        self.name = meal.name
        self.thumbnailURL = meal.thumbnailURL
        self.savedDate = Date()
    }
}

This class is deliberately lightweight — it stores only the data you need to display a favorites card without making another network request.

Step 2: Building the Recipe Service

Create Services/RecipeService.swift. This service is responsible for all network calls using Swift 6’s structured concurrency:

import Foundation

// All networking errors the UI can show to the user
enum RecipeServiceError: LocalizedError {
    case badURL
    case networkError(Error)
    case decodingError(Error)
    case noResults

    var errorDescription: String? {
        switch self {
        case .badURL: "Invalid API endpoint."
        case .networkError(let e): "Network error: \(e.localizedDescription)"
        case .decodingError(let e): "Failed to decode response: \(e.localizedDescription)"
        case .noResults: "No recipes found for this category."
        }
    }
}

// A Swift actor guarantees all state mutations happen on a single serial executor,
// eliminating any possibility of data races in Swift 6's strict concurrency model.
actor RecipeService {
    static let shared = RecipeService()
    private let baseURL = "https://www.themealdb.com/api/json/v1/1"
    private let decoder = JSONDecoder()

    // Fetches all available meal categories (e.g., Beef, Chicken, Dessert)
    func fetchCategories() async throws -> [String] {
        let url = try makeURL(path: "/categories.php")
        let (data, _) = try await URLSession.shared.data(from: url)
        let response = try decoder.decode(CategoryListResponse.self, from: data)
        return response.categories.map(\.name)
    }

    // Fetches recipe summaries for a given category
    func fetchMeals(category: String) async throws -> [Meal] {
        var components = URLComponents(string: "\(baseURL)/filter.php")!
        components.queryItems = [URLQueryItem(name: "c", value: category)]
        guard let url = components.url else { throw RecipeServiceError.badURL }

        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            let response = try decoder.decode(MealListResponse.self, from: data)
            guard !response.meals.isEmpty else { throw RecipeServiceError.noResults }
            return response.meals
        } catch let error as DecodingError {
            throw RecipeServiceError.decodingError(error)
        } catch let error as RecipeServiceError {
            throw error
        } catch {
            throw RecipeServiceError.networkError(error)
        }
    }

    // Fetches the full detail for a single meal, including ingredients and instructions
    func fetchMealDetail(id: String) async throws -> MealDetail {
        var components = URLComponents(string: "\(baseURL)/lookup.php")!
        components.queryItems = [URLQueryItem(name: "i", value: id)]
        guard let url = components.url else { throw RecipeServiceError.badURL }

        let (data, _) = try await URLSession.shared.data(from: url)
        let response = try decoder.decode(MealDetailResponse.self, from: data)
        guard let detail = response.meals.first else { throw RecipeServiceError.noResults }
        return detail
    }

    private func makeURL(path: String) throws -> URL {
        guard let url = URL(string: "\(baseURL)\(path)") else {
            throw RecipeServiceError.badURL
        }
        return url
    }
}

// Supporting type for the categories endpoint
private struct CategoryListResponse: Codable {
    let categories: [Category]
    struct Category: Codable {
        let name: String
        enum CodingKeys: String, CodingKey { case name = "strCategory" }
    }
}

By declaring RecipeService as an actor, you get automatic data-race protection at compile time. Swift 6’s strict concurrency checking would flag a class-based service for any potential concurrent access to its state — the actor eliminates that entirely.

The ViewModel

Create ViewModels/RecipeViewModel.swift. This is the @Observable class that your views observe:

import SwiftUI
import Observation

@Observable
@MainActor
final class RecipeViewModel {
    var meals: [Meal] = []
    var categories: [String] = []
    var selectedCategory: String = "Beef"
    var isLoading = false
    var errorMessage: String?
    var displayedMeals: [Meal] = [] // Subset shown to support pagination

    private let pageSize = 20
    private var allMeals: [Meal] = []

    func loadCategories() async {
        do {
            categories = try await RecipeService.shared.fetchCategories()
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    func loadMeals(category: String) async {
        isLoading = true
        errorMessage = nil
        defer { isLoading = false }

        do {
            allMeals = try await RecipeService.shared.fetchMeals(category: category)
            // Start with the first page
            displayedMeals = Array(allMeals.prefix(pageSize))
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    // Called when the user scrolls to the bottom of the list
    func loadMoreIfNeeded(currentMeal meal: Meal) async {
        guard let last = displayedMeals.last, last.id == meal.id else { return }
        let nextBatch = allMeals.dropFirst(displayedMeals.count).prefix(pageSize)
        guard !nextBatch.isEmpty else { return }

        // Small delay to make the loading indicator visible before appending
        try? await Task.sleep(for: .milliseconds(300))
        displayedMeals.append(contentsOf: nextBatch)
    }

    func refresh() async {
        await loadMeals(category: selectedCategory)
    }
}

@Observable (from the Observation framework, new in iOS 17) replaces ObservableObject and eliminates the need for individual @Published properties. SwiftUI automatically tracks which properties each view reads and only re-renders when those specific properties change. @MainActor ensures that all UI updates happen on the main thread.

Checkpoint: At this point you have your models, service, and view model in place. Build the project (Cmd+B). You should have zero compile errors. If the decoder throws errors about unknown keys, double-check your CodingKeys enums in MealDetail.swift — a single typo in a numbered key like strIngredient11 will cause silent decoding failures.

Step 3: Building the Recipe List with AsyncImage Cards

Now for the fun part — the visual layer. You’ll build a LazyVGrid of recipe cards, each loading its thumbnail with AsyncImage.

The Recipe Card

Create Views/RecipeCardView.swift:

import SwiftUI

struct RecipeCardView: View {
    let meal: Meal
    let namespace: Namespace.ID
    let isFavorite: Bool

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            // AsyncImage handles the async loading, placeholder, and error states
            AsyncImage(url: URL(string: meal.thumbnailURL)) { phase in
                switch phase {
                case .empty:
                    Rectangle()
                        .fill(Color(.systemGray5))
                        .overlay {
                            ProgressView()
                        }
                case .success(let image):
                    image
                        .resizable()
                        .scaledToFill()
                        // matchedGeometryEffect links this image to the detail view's hero image
                        .matchedGeometryEffect(id: "thumb-\(meal.id)", in: namespace)
                case .failure:
                    Rectangle()
                        .fill(Color(.systemGray4))
                        .overlay {
                            Image(systemName: "fork.knife.circle")
                                .font(.largeTitle)
                                .foregroundStyle(.secondary)
                        }
                @unknown default:
                    EmptyView()
                }
            }
            .frame(height: 140)
            .clipped()

            VStack(alignment: .leading, spacing: 4) {
                Text(meal.name)
                    .font(.subheadline.weight(.semibold))
                    .lineLimit(2)
                    .matchedGeometryEffect(id: "name-\(meal.id)", in: namespace)

                if isFavorite {
                    Label("Saved", systemImage: "heart.fill")
                        .font(.caption2)
                        .foregroundStyle(.red)
                }
            }
            .padding(10)
        }
        .background(.background)
        .clipShape(RoundedRectangle(cornerRadius: 12))
        .shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)
    }
}

The namespace and matchedGeometryEffect calls are placeholders for now — they’ll connect to the detail view in Step 6. The AsyncImage phase-based switch handles all three states — loading, success, and failure — which is the recommended pattern in Apple’s documentation.

The Recipe List View

Create Views/RecipeListView.swift:

import SwiftUI
import SwiftData

struct RecipeListView: View {
    @State private var viewModel = RecipeViewModel()
    @State private var selectedMeal: Meal?
    @Namespace private var heroNamespace

    // Query all saved favorites so cards can show the heart badge
    @Query private var favorites: [FavoriteRecipe]

    private let columns = [
        GridItem(.flexible(), spacing: 16),
        GridItem(.flexible(), spacing: 16)
    ]

    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading && viewModel.displayedMeals.isEmpty {
                    loadingPlaceholder
                } else if let error = viewModel.errorMessage {
                    errorView(message: error)
                } else {
                    recipeGrid
                }
            }
            .navigationTitle("Remy's Recipes")
            .toolbar {
                categoryPicker
            }
        }
        .task {
            await viewModel.loadCategories()
            await viewModel.loadMeals(category: viewModel.selectedCategory)
        }
        .sheet(item: $selectedMeal) { meal in
            RecipeDetailView(meal: meal, namespace: heroNamespace)
        }
    }

    // MARK: - Sub-views

    private var recipeGrid: some View {
        ScrollView {
            LazyVGrid(columns: columns, spacing: 16) {
                ForEach(viewModel.displayedMeals) { meal in
                    RecipeCardView(
                        meal: meal,
                        namespace: heroNamespace,
                        isFavorite: favorites.contains { $0.mealID == meal.id }
                    )
                    .onTapGesture {
                        selectedMeal = meal
                    }
                    // Trigger pagination when the last visible card appears
                    .task {
                        await viewModel.loadMoreIfNeeded(currentMeal: meal)
                    }
                }
            }
            .padding(16)
        }
        .refreshable {
            await viewModel.refresh()
        }
    }

    @ToolbarContentBuilder
    private var categoryPicker: some ToolbarContent {
        ToolbarItem(placement: .topBarTrailing) {
            Menu {
                ForEach(viewModel.categories, id: \.self) { category in
                    Button(category) {
                        viewModel.selectedCategory = category
                        Task { await viewModel.loadMeals(category: category) }
                    }
                }
            } label: {
                Label(viewModel.selectedCategory, systemImage: "line.3.horizontal.decrease.circle")
            }
        }
    }

    private var loadingPlaceholder: some View {
        LazyVGrid(columns: columns, spacing: 16) {
            ForEach(0..<8, id: \.self) { _ in
                RoundedRectangle(cornerRadius: 12)
                    .fill(Color(.systemGray5))
                    .frame(height: 190)
                    .redacted(reason: .placeholder)
                    .shimmering() // You'll add this extension below
            }
        }
        .padding(16)
    }

    private func errorView(message: String) -> some View {
        ContentUnavailableView(
            "Remy's Kitchen is Closed",
            systemImage: "wifi.slash",
            description: Text(message)
        )
    }
}

The .refreshable modifier wraps the ScrollView and handles the pull-to-refresh gesture automatically — SwiftUI calls your async closure and shows the system spinner while it runs. The LazyVGrid combined with .task on each item creates a neat pagination trigger: the task fires when the cell is about to appear, and loadMoreIfNeeded only appends data if the cell is the last one.

You referenced shimmering() in the placeholder. Add a simple shimmer view modifier. Create Views/ShimmerViewModifier.swift:

import SwiftUI

struct ShimmerModifier: ViewModifier {
    @State private var phase: CGFloat = 0

    func body(content: Content) -> some View {
        content
            .overlay {
                LinearGradient(
                    stops: [
                        .init(color: .clear, location: phase - 0.3),
                        .init(color: .white.opacity(0.4), location: phase),
                        .init(color: .clear, location: phase + 0.3),
                    ],
                    startPoint: .leading,
                    endPoint: .trailing
                )
                .animation(
                    .linear(duration: 1.2).repeatForever(autoreverses: false),
                    value: phase
                )
            }
            .onAppear { phase = 1.3 }
            .clipped()
    }
}

extension View {
    func shimmering() -> some View {
        modifier(ShimmerModifier())
    }
}

Checkpoint: Build and run. You should see a two-column grid of recipe cards from the “Beef” category, each showing an animated thumbnail loaded from the network. Pulling down on the list should trigger a refresh. Tapping a card will present a blank sheet (the detail view is next). If images don’t load, check that your Info.plist does not have NSAppTransportSecurity restrictions that block HTTP — but since TheMealDB uses HTTPS, you should be fine.

Step 4: Building the Recipe Detail View

Create Views/RecipeDetailView.swift. This is the full-screen recipe view with the ingredients list, instructions, and a YouTube link:

import SwiftUI
import SwiftData

struct RecipeDetailView: View {
    let meal: Meal
    let namespace: Namespace.ID

    @Environment(\.modelContext) private var modelContext
    @Environment(\.dismiss) private var dismiss
    @Query private var favorites: [FavoriteRecipe]

    @State private var detail: MealDetail?
    @State private var isLoading = true
    @State private var errorMessage: String?

    private var isFavorite: Bool {
        favorites.contains { $0.mealID == meal.id }
    }

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 0) {
                heroImage
                contentSection
            }
        }
        .ignoresSafeArea(edges: .top)
        .overlay(alignment: .topTrailing) { closeButton }
        .overlay(alignment: .topLeading) { favoriteButton }
        .task {
            await loadDetail()
        }
    }

    // MARK: - Sub-views

    private var heroImage: some View {
        AsyncImage(url: URL(string: meal.thumbnailURL)) { phase in
            switch phase {
            case .success(let image):
                image
                    .resizable()
                    .scaledToFill()
                    // This matchedGeometryEffect ID must match the one in RecipeCardView
                    .matchedGeometryEffect(id: "thumb-\(meal.id)", in: namespace)
            default:
                Rectangle()
                    .fill(Color(.systemGray4))
                    .matchedGeometryEffect(id: "thumb-\(meal.id)", in: namespace)
            }
        }
        .frame(height: 300)
        .clipped()
    }

    private var contentSection: some View {
        VStack(alignment: .leading, spacing: 20) {
            Text(meal.name)
                .font(.title.bold())
                .matchedGeometryEffect(id: "name-\(meal.id)", in: namespace)

            if isLoading {
                ProgressView("Fetching recipe from Remy's notebook…")
                    .frame(maxWidth: .infinity)
            } else if let error = errorMessage {
                Text(error)
                    .foregroundStyle(.red)
            } else if let detail {
                categoryAreaRow(detail: detail)
                ingredientsList(ingredients: detail.ingredients)
                instructionsSection(text: detail.instructions)
                linksSection(detail: detail)
            }
        }
        .padding(20)
    }

    private func categoryAreaRow(detail: MealDetail) -> some View {
        HStack(spacing: 8) {
            Label(detail.category, systemImage: "tag")
            Text("•")
            Label(detail.area, systemImage: "globe")
        }
        .font(.subheadline)
        .foregroundStyle(.secondary)
    }

    private func ingredientsList(ingredients: [Ingredient]) -> some View {
        VStack(alignment: .leading, spacing: 10) {
            Text("Ingredients")
                .font(.headline)

            ForEach(ingredients) { ingredient in
                HStack {
                    Text(ingredient.name)
                        .font(.body)
                    Spacer()
                    Text(ingredient.measure)
                        .font(.body)
                        .foregroundStyle(.secondary)
                }
                Divider()
            }
        }
    }

    private func instructionsSection(text: String) -> some View {
        VStack(alignment: .leading, spacing: 8) {
            Text("Instructions")
                .font(.headline)
            Text(text)
                .font(.body)
                .lineSpacing(4)
        }
    }

    private func linksSection(detail: MealDetail) -> some View {
        VStack(spacing: 12) {
            if let youtubeURLString = detail.youtubeURL,
               let url = URL(string: youtubeURLString),
               !youtubeURLString.isEmpty {
                Link(destination: url) {
                    Label("Watch on YouTube", systemImage: "play.rectangle.fill")
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color.red)
                        .foregroundStyle(.white)
                        .clipShape(RoundedRectangle(cornerRadius: 10))
                }
            }
            if let sourceURLString = detail.sourceURL,
               let url = URL(string: sourceURLString),
               !sourceURLString.isEmpty {
                Link(destination: url) {
                    Label("Original Recipe", systemImage: "link")
                        .frame(maxWidth: .infinity)
                        .padding()
                        .background(Color(.systemGray5))
                        .clipShape(RoundedRectangle(cornerRadius: 10))
                }
            }
        }
    }

    private var closeButton: some View {
        Button {
            dismiss()
        } label: {
            Image(systemName: "xmark.circle.fill")
                .font(.title2)
                .symbolRenderingMode(.palette)
                .foregroundStyle(.white, .black.opacity(0.4))
        }
        .padding()
    }

    private var favoriteButton: some View {
        Button {
            toggleFavorite()
        } label: {
            Image(systemName: isFavorite ? "heart.fill" : "heart")
                .font(.title2)
                .symbolRenderingMode(.palette)
                .foregroundStyle(isFavorite ? .red : .white, .black.opacity(0.4))
        }
        .padding()
    }

    // MARK: - Actions

    private func loadDetail() async {
        isLoading = true
        defer { isLoading = false }
        do {
            detail = try await RecipeService.shared.fetchMealDetail(id: meal.id)
        } catch {
            errorMessage = error.localizedDescription
        }
    }

    private func toggleFavorite() {
        if let existing = favorites.first(where: { $0.mealID == meal.id }) {
            modelContext.delete(existing)
        } else {
            let newFavorite = FavoriteRecipe(meal: meal)
            modelContext.insert(newFavorite)
        }
    }
}

The detail view fetches the full meal data asynchronously when it appears, using the meal summary (ID and thumbnail) it received from the list to display something immediately while the network request is in flight. This pattern — render what you have, then enhance — is what makes the app feel responsive.

Checkpoint: Build and run. Tap a recipe card. You should see the sheet slide up showing the recipe name and thumbnail, then the loading spinner replaced by the full ingredient list and instructions. Try tapping the heart button — the icon should fill red. If the heart disappears after you dismiss the sheet, check that your modelContext is correctly injected (you’ll wire that up in the next step).

Step 5: Adding Favorites with SwiftData

Now configure the ModelContainer in your app entry point and add a Favorites tab. Open RemysRecipesApp.swift and replace its content:

import SwiftUI
import SwiftData

@main
struct RemysRecipesApp: App {
    var body: some Scene {
        WindowGroup {
            MainTabView()
        }
        .modelContainer(for: FavoriteRecipe.self)
    }
}

The .modelContainer(for:) modifier creates the SwiftData store and injects the ModelContext into the environment for all child views. Any view that uses @Query or @Environment(\.modelContext) in the hierarchy automatically gets access to the store.

Building the Favorites View

Create Views/FavoritesView.swift:

import SwiftUI
import SwiftData

struct FavoritesView: View {
    @Query(sort: \FavoriteRecipe.savedDate, order: .reverse)
    private var favorites: [FavoriteRecipe]

    @State private var selectedMeal: Meal?
    @Namespace private var heroNamespace

    var body: some View {
        NavigationStack {
            Group {
                if favorites.isEmpty {
                    emptyState
                } else {
                    favoritesList
                }
            }
            .navigationTitle("Remy's Favorites")
        }
        .sheet(item: $selectedMeal) { meal in
            RecipeDetailView(meal: meal, namespace: heroNamespace)
        }
    }

    private var favoritesList: some View {
        let columns = [GridItem(.flexible(), spacing: 16), GridItem(.flexible(), spacing: 16)]

        return ScrollView {
            LazyVGrid(columns: columns, spacing: 16) {
                ForEach(favorites) { favorite in
                    // Convert FavoriteRecipe back to the Meal summary type for the card
                    let meal = Meal(
                        id: favorite.mealID,
                        name: favorite.name,
                        thumbnailURL: favorite.thumbnailURL
                    )
                    RecipeCardView(meal: meal, namespace: heroNamespace, isFavorite: true)
                        .onTapGesture { selectedMeal = meal }
                }
            }
            .padding(16)
        }
    }

    private var emptyState: some View {
        ContentUnavailableView(
            "No Favorites Yet",
            systemImage: "heart.slash",
            description: Text("Tap the heart on any recipe to save it here. Remy recommends starting with anything French.")
        )
    }
}

Adding the Tab View

Create Views/MainTabView.swift:

import SwiftUI

struct MainTabView: View {
    var body: some View {
        TabView {
            RecipeListView()
                .tabItem {
                    Label("Discover", systemImage: "fork.knife")
                }

            FavoritesView()
                .tabItem {
                    Label("Favorites", systemImage: "heart")
                }
        }
    }
}

Checkpoint: Build and run. The app should now have two tabs — “Discover” and “Favorites.” Open a recipe, tap the heart, then go to the Favorites tab. The saved recipe should appear in the grid. Dismiss and re-launch the app — the favorite should still be there, persisted by SwiftData. If the favorite disappears on re-launch, verify the modelContainer modifier is on the WindowGroup in RemysRecipesApp.swift, not on a child view.

Step 6: Implementing the Hero Animation

The hero transition uses matchedGeometryEffect to smoothly animate the recipe thumbnail and name from the card position into the full-screen detail sheet. You already placed the effect calls in RecipeCardView and RecipeDetailView — this step wires them together properly and adds the transition behavior.

The trick with matchedGeometryEffect and sheets is that SwiftUI cannot animate between two different view hierarchies (the list and the sheet) using the effect alone. The cleanest workaround for iOS 18 is to use NavigationStack with a .navigationTransition(.zoom(...)) instead of a sheet, which gives you a built-in zoom transition that reads very similarly to a hero effect.

Open Views/RecipeListView.swift and update recipeGrid to use navigation instead of a sheet:

// Replace the .sheet modifier and selectedMeal @State with a NavigationStack path
@State private var navigationPath = NavigationPath()

// Update recipeGrid to push, not present
private var recipeGrid: some View {
    ScrollView {
        LazyVGrid(columns: columns, spacing: 16) {
            ForEach(viewModel.displayedMeals) { meal in
                NavigationLink(value: meal) {
                    RecipeCardView(
                        meal: meal,
                        namespace: heroNamespace,
                        isFavorite: favorites.contains { $0.mealID == meal.id }
                    )
                }
                .buttonStyle(.plain)
                .task {
                    await viewModel.loadMoreIfNeeded(currentMeal: meal)
                }
            }
        }
        .padding(16)
    }
    .refreshable {
        await viewModel.refresh()
    }
}

Then wrap the NavigationStack to handle navigation destinations:

NavigationStack {
    Group { ... }
    .navigationTitle("Remy's Recipes")
    .toolbar { categoryPicker }
    .navigationDestination(for: Meal.self) { meal in
        RecipeDetailView(meal: meal, namespace: heroNamespace)
            .navigationTransition(.zoom(
                sourceID: "card-\(meal.id)",
                in: heroNamespace
            ))
    }
}

In RecipeCardView, add the matching source ID to the outermost container:

// In RecipeCardView's body, wrap everything in:
VStack(alignment: .leading, spacing: 0) {
    // ... existing content
}
.matchedTransitionSource(id: "card-\(meal.id)", in: namespace) // ← Add this
.background(.background)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.08), radius: 6, x: 0, y: 2)

The .navigationTransition(.zoom(...)) and .matchedTransitionSource APIs are new in iOS 18 and provide a system-quality zoom animation with no manual geometry tracking required. This replaces the manual matchedGeometryEffect approach for push transitions.

Note: matchedGeometryEffect still works well for animating properties within the same view hierarchy (like expanding a card in-place). For cross-hierarchy navigation transitions, prefer the iOS 18 .zoom navigation transition API.

Checkpoint: Build and run. Tap any recipe card — you should see a smooth zoom transition expanding the card into the full-screen detail view. The back swipe gesture should collapse the view back into the card. If you see a plain slide instead of a zoom, verify that matchedTransitionSource is on the card’s outermost view and the sourceID strings match exactly.

Step 7: Pull-to-Refresh and Load-More Pagination

Both features are already wired in from Step 3. This step focuses on polishing the UX: showing a visible “Loading more…” indicator at the bottom of the list, and handling the edge case where all meals in a category have been loaded.

Update RecipeViewModel to track whether there are more pages:

// Add to RecipeViewModel
var hasMoreMeals: Bool {
    displayedMeals.count < allMeals.count
}

var isLoadingMore = false

func loadMoreIfNeeded(currentMeal meal: Meal) async {
    guard let last = displayedMeals.last,
          last.id == meal.id,
          hasMoreMeals,
          !isLoadingMore else { return }

    isLoadingMore = true
    defer { isLoadingMore = false }

    // Simulate network latency for categories already loaded client-side
    try? await Task.sleep(for: .milliseconds(400))
    let nextBatch = allMeals.dropFirst(displayedMeals.count).prefix(pageSize)
    displayedMeals.append(contentsOf: nextBatch)
}

Now add the load-more footer to RecipeListView’s recipeGrid. After the LazyVGrid, add:

// Load-more footer — placed inside the ScrollView after the grid
if viewModel.isLoadingMore {
    ProgressView()
        .padding(.vertical, 20)
        .frame(maxWidth: .infinity)
} else if !viewModel.hasMoreMeals && !viewModel.displayedMeals.isEmpty {
    Text("You've seen all of Remy's \(viewModel.selectedCategory) recipes!")
        .font(.footnote)
        .foregroundStyle(.secondary)
        .padding(.vertical, 20)
        .frame(maxWidth: .infinity)
}

Handling the Pull-to-Refresh State

The .refreshable modifier in SwiftUI keeps the system spinner active until your async task completes. Because viewModel.refresh() is already an async function that sets and clears isLoading, the spinner dismisses automatically when the data loads. However, if a previous load-more is still in flight when the user pulls, you should cancel it. Add a cancellation check to refresh():

func refresh() async {
    // Cancel any in-progress load-more before refreshing
    isLoadingMore = false
    await loadMeals(category: selectedCategory)
}

Checkpoint: Build and run. Scroll to the bottom of a category with many recipes (try “Chicken” — it has over 30). You should see a ProgressView spinner appear briefly at the bottom, then new cards appear. When you reach the last card, the “You’ve seen all of Remy’s…” message should appear. Pull down from the top to refresh — new data should load and the list should reset to page one.

Where to Go From Here?

Congratulations! You’ve built Remy’s Recipe Collection — a fully functional iOS recipe browser with real API data, SwiftData favorites, iOS 18 zoom transitions, pull-to-refresh, and client-side pagination.

Here’s what you built:

  • Decoded a complex, flat JSON structure from a real public API using custom Codable initializers
  • Used AsyncImage with all three loading phases for a polished image-loading experience
  • Structured an actor-based network service that’s Swift 6-compatible and data-race-free
  • Persisted favorites using SwiftData with @Model, @Query, and modelContainer
  • Implemented the iOS 18 .zoom navigation transition with matchedTransitionSource
  • Built client-side pagination with a load-more footer and a pull-to-refresh gesture

Ideas for extending this project:

  • Meal planner: Add a MealPlan SwiftData model with a date, and let users schedule favorite recipes to a weekly calendar using DateComponents.
  • Shopping list: Aggregate ingredients from multiple saved recipes and dedup them into a checklist view.
  • Nutritional info: Integrate with a nutrition API (like Open Food Facts) to overlay macros on the detail view.
  • Search: TheMealDB has a /search.php?s= endpoint — add a searchable modifier to the list view.
  • Offline mode: Cache decoded MealDetail objects in SwiftData so previously viewed recipes work without a connection.