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
- Xcode 16+ with an iOS 18 deployment target
- Familiarity with networking basics and async/await
- Familiarity with SwiftData persistence
- Some experience with SwiftUI animations (hero transitions are introduced fresh here)
Contents
- Getting Started
- Step 1: Modeling the Data
- Step 2: Building the Recipe Service
- Step 3: Building the Recipe List with AsyncImage Cards
- Step 4: Building the Recipe Detail View
- Step 5: Adding Favorites with SwiftData
- Step 6: Implementing the Hero Animation
- Step 7: Pull-to-Refresh and Load-More Pagination
- Where to Go From Here?
Getting Started
Open Xcode and create a new project:
- Choose File › New › Project and select the App template under iOS.
- Set the Product Name to
RemysRecipes. - Set Interface to SwiftUI and Language to Swift.
- Make sure Storage is set to None — you’ll add SwiftData manually.
- 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
CodingKeysenums inMealDetail.swift— a single typo in a numbered key likestrIngredient11will 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
NSAppTransportSecurityrestrictions 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
modelContextis 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
modelContainermodifier is on theWindowGroupinRemysRecipesApp.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:
matchedGeometryEffectstill 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.zoomnavigation 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
matchedTransitionSourceis on the card’s outermost view and thesourceIDstrings 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
ProgressViewspinner 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
Codableinitializers - Used
AsyncImagewith 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, andmodelContainer - Implemented the iOS 18
.zoomnavigation transition withmatchedTransitionSource - Built client-side pagination with a load-more footer and a pull-to-refresh gesture
Ideas for extending this project:
- Meal planner: Add a
MealPlanSwiftData model with a date, and let users schedule favorite recipes to a weekly calendar usingDateComponents. - 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 asearchablemodifier to the list view. - Offline mode: Cache decoded
MealDetailobjects in SwiftData so previously viewed recipes work without a connection.
Related Posts
- Codable Deep Dive — goes further into custom
Codablestrategies, including dynamic keys and nested containers - SwiftUI Performance Optimization — learn how to profile
LazyVGridwith Instruments and avoid common re-render pitfalls - Building a Production Networking Layer — architect a full networking layer with retry logic, request interception, and response caching