A step-by-step guide to building a modern SwiftUI Recipe Book app for iOS and macOS.

SwiftUI Recipe Book App

GitHub


Step-by-Step Complete Project Guide

Welcome to the ultimate hands-on guide for building a modern SwiftUI Recipe Book app from scratch! This section will walk you through each phase of the project, from initial setup to advanced features, with clear explanations and practical code samples.

If you’d like to see how these patterns hold up in real-world production code (and what breaks), pair this tutorial with the Advanced SwiftUI: Lessons From Mistakes posts such as SwiftUI @StateObject vs @ObservedObject: The ObservedOrStateObject Pattern and SwiftUI @Published Crash: “Pointer Being Freed Was Not Allocated” Explained.

What you’ll achieve:

  • Set up a robust SwiftUI project structure
  • Design and implement a Core Data model for recipes
  • Build reusable UI components and custom views
  • Integrate networking to fetch recipes from an API
  • Add image support and cross-platform compatibility (iOS & macOS)
  • Apply MVVM architecture and best practices
  • Enable searching, filtering, and favorites
  • Test and preview your app effectively

Whether you’re a beginner or looking to deepen your SwiftUI skills, follow along step by step to create a beautiful, production-ready app.

This guide will walk you through building a complete Recipe Book app that demonstrates all SwiftUI concepts. You’ll create an app where users can browse recipes, add new ones, mark favorites, and sync data.


Project Overview

The Recipe Book app is a modern, cross-platform SwiftUI project designed to help you master real-world app development. You’ll build a beautiful and functional recipe manager that works seamlessly on both macOS and iOS.

Key Features:

  • Organize recipes by category (breakfast, lunch, dinner, etc.)
  • Add, edit, and delete your own recipes
  • Mark favorites for quick access
  • Powerful search and filter capabilities
  • Persistent storage with Core Data
  • Fetch new recipes from a public API
  • Custom, reusable UI components
  • Responsive design for Mac and iPhone/iPad

macOS app screenshot iOS app screenshot

What You’ll Learn:

  • SwiftUI fundamentals and advanced techniques
  • State management with @State, @Binding, @ObservedObject, and @EnvironmentObject
  • MVVM architecture in practice
  • Building and using custom SwiftUI components
  • Integrating Core Data for persistence
  • Networking and decoding JSON APIs
  • Animations, transitions, and UI polish
  • UIKit integration (Image Picker)
  • Testing and previewing SwiftUI views

By the end, you’ll have a production-quality app and a deep understanding of how to architect, build, and polish a SwiftUI project from start to finish.


Step 1: Create the Project

  1. Open Xcode
  2. File → New → Project
  3. Choose “App” template (Multiplatform)
  4. Name it “RecipeBook”
  5. Select SwiftUI interface and Swift language
  6. Important: Check “Use Core Data” ✓
  7. Create the project

alt text alt text


Step 2: Project Structure

Create these groups (folders) in your project:

RecipeBook/
├── Models/
├── ViewModels/
├── Views/
│   ├── Components/
│   ├── RecipeList/
│   └── RecipeDetail/
├── Services/
├── Utilities/
└── Resources/

How to create groups: Right-click on RecipeBook folder → New Group

Remove automatically created files (Right-click Delete)

  • Persistence.swift

Step 3: Create the Model

File: Models/Recipe.swift

import Foundation
import SwiftUI

// MARK: - Recipe Model
struct Recipe: Identifiable, Codable, Hashable {
    var id: UUID = UUID()
    var title: String
    var description: String
    var ingredients: [String]
    var instructions: String
    var prepTime: Int // minutes
    var cookTime: Int
    var servings: Int
    var category: RecipeCategory
    var difficulty: Difficulty
    var isFavorite: Bool = false
    var imageData: Data?
    var sourceURL: String?
    var createdAt: Date = Date()
    
    var totalTime: Int {
        prepTime + cookTime
    }


    #if os(iOS) || os(tvOS)
    var image: UIImage? {
        guard let data = imageData else { return nil }

        return UIImage(data: data)
    }
    #elseif os(macOS)
    var image: NSImage? {
        guard let data = imageData else { return nil }

        return NSImage(data: data)
    }
    #endif

    enum CodingKeys: String, CodingKey {
        case id
        case title
        case description
        case ingredients
        case instructions
        case prepTime
        case cookTime
        case servings
        case category
        case difficulty
        case isFavorite
        case imageData
        case sourceURL
        case createdAt
    }

}

// MARK: - Recipe Category
enum RecipeCategory: String, CaseIterable, Codable {
    case breakfast = "Breakfast"
    case lunch = "Lunch"
    case dinner = "Dinner"
    case dessert = "Dessert"
    case snack = "Snack"
    case appetizer = "Appetizer"
    var icon: String {
        switch self {
        case .breakfast: return "sun.horizon.fill"
        case .lunch: return "fork.knife"
        case .dinner: return "moon.stars.fill"
        case .dessert: return "birthday.cake.fill"
        case .snack: return "carrot.fill"
        case .appetizer: return "cup.and.saucer.fill"
        }
    }
    var color: Color {
        switch self {
        case .breakfast: return .orange
        case .lunch: return .green
        case .dinner: return .purple
        case .dessert: return .pink
        case .snack: return .yellow
        case .appetizer: return .blue
        }
    }
}

// MARK: - Difficulty Level
enum Difficulty: String, CaseIterable, Codable {
    case easy = "Easy"
    case medium = "Medium"
    case hard = "Hard"
    var color: Color {
        switch self {
        case .easy: return .green
        case .medium: return .orange
        case .hard: return .red
        }
    }
}

// MARK: - Sample Data
extension Recipe {
    static let sampleRecipes: [Recipe] = [
        Recipe(
            title: "Eggs",
            description: "Fried Eggs",
            ingredients: ["Eggs", "Salt"],
            instructions: "Fry the eggs",
            prepTime: 1,
            cookTime: 5,
            servings: 4,
            category: .breakfast,
            difficulty: .easy,
            isFavorite: true
        ),
        Recipe(
            title: "Classic Pancakes",
            description: "Fluffy homemade pancakes perfect for breakfast",
            ingredients: ["2 cups flour", "2 eggs", "1.5 cups milk", "2 tbsp sugar", "2 tsp baking powder"],
            instructions: "1. Mix dry ingredients\n2. Add wet ingredients\n3. Cook on griddle until golden",
            prepTime: 10,
            cookTime: 15,
            servings: 4,
            category: .breakfast,
            difficulty: .easy,
            isFavorite: true
        ),
        Recipe(
            title: "Spaghetti Carbonara",
            description: "Traditional Italian pasta with creamy egg sauce",
            ingredients: ["400g spaghetti", "200g pancetta", "4 eggs", "100g parmesan", "Black pepper"],
            instructions: "1. Cook pasta\n2. Fry pancetta\n3. Mix eggs and cheese\n4. Combine everything",
            prepTime: 10,
            cookTime: 20,
            servings: 4,
            category: .dinner,
            difficulty: .medium
        ),
        Recipe(
            title: "Chocolate Chip Cookies",
            description: "Chewy and delicious homemade cookies",
            ingredients: ["2 cups flour", "1 cup butter", "1 cup sugar", "2 eggs", "2 cups chocolate chips"],
            instructions: "1. Cream butter and sugar\n2. Add eggs and flour\n3. Fold in chips\n4. Bake at 350°F",
            prepTime: 15,
            cookTime: 12,
            servings: 24,
            category: .dessert,
            difficulty: .easy,
            isFavorite: true
        )
    ]
}

Step 4: Update Core Data Model

  1. Open RecipeBook.xcdatamodeld
  2. Click ”+” to add a new entity, name it RecipeEntity
  3. Add these attributes:
AttributeTypeOptional
idUUIDNo
titleStringNo
recipeDescriptionStringNo
ingredientsStringNo
instructionsStringNo
prepTimeInteger 16No
cookTimeInteger 16No
servingsInteger 16No
categoryStringNo
difficultyStringNo
isFavoriteBooleanNo
imageDataBinary DataYes
sourceURLStringYes
createdAtDateNo

Create PersistenceController:

File: Services/PersistenceController.swift

import CoreData

class PersistenceController {
    static let shared = PersistenceController()
    
    let container: NSPersistentContainer
    
    init(inMemory: Bool = false) {
        container = NSPersistentContainer(name: "RecipeBook")
        
        if inMemory {
            container.persistentStoreDescriptions.first?.url = URL(fileURLWithPath: "/dev/null")
        }
        
        container.loadPersistentStores { description, error in
            if let error = error {
                fatalError("Core Data failed to load: \(error.localizedDescription)")
            }
        }
        
        container.viewContext.automaticallyMergesChangesFromParent = true
    }
    
    // For previews
    static var preview: PersistenceController = {
        let controller = PersistenceController(inMemory: true)
        let context = controller.container.viewContext
        
        // Add sample data
        for recipe in Recipe.sampleRecipes {
            let entity = RecipeEntity(context: context)
            entity.id = recipe.id
            entity.title = recipe.title
            entity.recipeDescription = recipe.description
            entity.ingredients = recipe.ingredients.joined(separator: "||")
            entity.instructions = recipe.instructions
            entity.prepTime = Int16(recipe.prepTime)
            entity.cookTime = Int16(recipe.cookTime)
            entity.servings = Int16(recipe.servings)
            entity.category = recipe.category.rawValue
            entity.difficulty = recipe.difficulty.rawValue
            entity.isFavorite = recipe.isFavorite
            entity.imageData = recipe.imageData
            entity.sourceURL = recipe.sourceURL
            entity.createdAt = recipe.createdAt
        }
        
        try? context.save()
        return controller
    }()
}

// MARK: - Core Data Extensions
// RecipeEntity definition is located in RecipeBook.xcdatamodeld
extension RecipeEntity {
    func toRecipe() -> Recipe {
        Recipe(
            id: id ?? UUID(),
            title: title ?? "",
            description: recipeDescription ?? "",
            ingredients: (ingredients ?? "").components(separatedBy: "||"),
            instructions: instructions ?? "",
            prepTime: Int(prepTime),
            cookTime: Int(cookTime),
            servings: Int(servings),
            category: RecipeCategory(rawValue: category ?? "") ?? .lunch,
            difficulty: Difficulty(rawValue: difficulty ?? "") ?? .medium,
            isFavorite: isFavorite,
            imageData: imageData,
            sourceURL: sourceURL,
            createdAt: createdAt ?? Date()
        )
    }
}

Step 5: Create Network Service

File: Services/NetworkService.swift

import Foundation

// MARK: - Network Recipe Model
struct NetworkRecipe: Codable {
    let idMeal: String
    let strMeal: String
    let strCategory: String
    let strInstructions: String
    let strMealThumb: String?
    let strSource: String?
    
    let strIngredient1: String?
    let strIngredient2: String?
    let strIngredient3: String?
    let strIngredient4: String?
    let strIngredient5: String?
    let strIngredient6: String?
    let strIngredient7: String?
    let strIngredient8: String?
    let strIngredient9: String?
    let strIngredient10: String?
    let strIngredient11: String?
    let strIngredient12: String?
    let strIngredient13: String?
    let strIngredient14: String?
    let strIngredient15: String?
    let strIngredient16: String?
    let strIngredient17: String?
    let strIngredient18: String?
    let strIngredient19: String?
    let strIngredient20: String?
    
    let strMeasure1: String?
    let strMeasure2: String?
    let strMeasure3: String?
    let strMeasure4: String?
    let strMeasure5: String?
    let strMeasure6: String?
    let strMeasure7: String?
    let strMeasure8: String?
    let strMeasure9: String?
    let strMeasure10: String?
    let strMeasure11: String?
    let strMeasure12: String?
    let strMeasure13: String?
    let strMeasure14: String?
    let strMeasure15: String?
    let strMeasure16: String?
    let strMeasure17: String?
    let strMeasure18: String?
    let strMeasure19: String?
    let strMeasure20: String?
    
    func toRecipe() async -> Recipe {
        let ingredients = parseIngredients()
        let ingredientCount = ingredients.count
        let difficulty = difficultyLevel(for: ingredientCount)
        let prepTime = prepTimeEstimate(for: ingredientCount)
        let imageData = await fetchImageData()
        
        return Recipe(
            id: UUID(),
            title: strMeal,
            description: "Source: TheMealDB",
            ingredients: ingredients,
            instructions: strInstructions,
            prepTime: prepTime,
            cookTime: 30,
            servings: 4,
            category: mapCategory(strCategory),
            difficulty: difficulty,
            imageData: imageData,
            sourceURL: strSource
        )
    }
    
    private func parseIngredients() -> [String] {
        let ingredientValues: [String?] = [
            strIngredient1,
            strIngredient2,
            strIngredient3,
            strIngredient4,
            strIngredient5,
            strIngredient6,
            strIngredient7,
            strIngredient8,
            strIngredient9,
            strIngredient10,
            strIngredient11,
            strIngredient12,
            strIngredient13,
            strIngredient14,
            strIngredient15,
            strIngredient16,
            strIngredient17,
            strIngredient18,
            strIngredient19,
            strIngredient20
        ]
        let measureValues: [String?] = [
            strMeasure1,
            strMeasure2,
            strMeasure3,
            strMeasure4,
            strMeasure5,
            strMeasure6,
            strMeasure7,
            strMeasure8,
            strMeasure9,
            strMeasure10,
            strMeasure11,
            strMeasure12,
            strMeasure13,
            strMeasure14,
            strMeasure15,
            strMeasure16,
            strMeasure17,
            strMeasure18,
            strMeasure19,
            strMeasure20
        ]
        
        return zip(ingredientValues, measureValues).compactMap { ingredient, measure in
            let trimmedIngredient = ingredient?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
            guard !trimmedIngredient.isEmpty else { return nil }
            let trimmedMeasure = measure?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
            if trimmedMeasure.isEmpty {
                return trimmedIngredient
            } else {
                return "\(trimmedMeasure) \(trimmedIngredient)"
            }
        }
    }
    
    private func mapCategory(_ apiCategory: String) -> RecipeCategory {
        switch apiCategory.lowercased() {
        case "breakfast": return .breakfast
        case "dessert": return .dessert
        case "starter": return .appetizer
        default: return .dinner
        }
    }
    
    private func fetchImageData() async -> Data? {
        guard let thumb = strMealThumb, let url = URL(string: thumb) else { return nil }
        do {
            let (data, _) = try await URLSession.shared.data(from: url)
            return data
        } catch {
            return nil
        }
    }
    
    private func difficultyLevel(for ingredientCount: Int) -> Difficulty {
        switch ingredientCount {
        case ...4:
            return .easy
        case 5...8:
            return .medium
        default:
            return .hard
        }
    }
    
    private func prepTimeEstimate(for ingredientCount: Int) -> Int {
        switch ingredientCount {
        case ...4:
            return 10
        case 5...8:
            return 20
        default:
            return 30
        }
    }
}

struct MealResponse: Codable {
    let meals: [NetworkRecipe]
}

// MARK: - Network Service
class NetworkService {
    static let shared = NetworkService()
    private init() {}
    
    func fetchRandomRecipe() async throws -> Recipe {
        let urlString = "https://www.themealdb.com/api/json/v1/1/random.php"
        guard let url = URL(string: urlString) else {
            throw NetworkError.invalidURL
        }
        
        let (data, _) = try await URLSession.shared.data(from: url)
        let response = try JSONDecoder().decode(MealResponse.self, from: data)
        
        guard let meal = response.meals.first else {
            throw NetworkError.noData
        }
        
        return await meal.toRecipe()
    }
    
    func searchRecipes(query: String) async throws -> [Recipe] {
        let urlString = "https://www.themealdb.com/api/json/v1/1/search.php?s=\(query)"
        guard let url = URL(string: urlString.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "") else {
            throw NetworkError.invalidURL
        }
        
        let (data, _) = try await URLSession.shared.data(from: url)
        let response = try JSONDecoder().decode(MealResponse.self, from: data)
        
        return await withTaskGroup(of: Recipe.self) { group -> [Recipe] in
            for meal in response.meals {
                group.addTask {
                    await meal.toRecipe()
                }
            }
            var recipes: [Recipe] = []
            for await recipe in group {
                recipes.append(recipe)
            }
            return recipes
        }
    }
}

enum NetworkError: LocalizedError {
    case invalidURL
    case noData
    
    var errorDescription: String? {
        switch self {
        case .invalidURL: return "Invalid URL"
        case .noData: return "No data received"
        }
    }
}

Step 6: Create Custom Components

Utility: Shared Colors

File: Utilities/Common.swift

import SwiftUI

struct Common {
    static var defaultSeparatorColor: Color {
        #if os(iOS) || os(tvOS)
        return Color(.separator)
        #else
        return Color.gray.opacity(0.4) // macOS fallback
        #endif
    }
    
    static var defaultBackgroundColor: Color {
        #if os(iOS) || os(tvOS)
        return Color(.systemGray5)
        #else
        return Color.gray // macOS fallback
        #endif
    }
}

These helpers keep visual styling consistent across iOS, tvOS, and macOS targets and are used throughout the component snippets that follow.

Component 1: Floating Label TextField

File: Views/Components/FloatingLabelTextField.swift

import SwiftUI

#if canImport(UIKit)
import UIKit
#endif

enum PlatformKeyboardType {
    case `default`, email, numberPad

    #if canImport(UIKit)
    var uiKeyboardType: UIKeyboardType {
        switch self {
        case .default: return .default
        case .email: return .emailAddress
        case .numberPad: return .numberPad
        }
    }
    #endif
}

struct FloatingLabelTextField: View {
    let title: String
    var placeholder: String = ""
    var isSecure: Bool = false
    var keyboardType: PlatformKeyboardType = .default

    @Binding var text: String
    @FocusState private var isFocused: Bool

    private var effectivePlaceholder: String {
        placeholder.isEmpty ? title : placeholder
    }

    private var showFloatingLabel: Bool {
        !text.isEmpty || isFocused
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            if showFloatingLabel {
                Text(title)
                    .font(.caption)
                    .foregroundStyle(.secondary)
                    .transition(.opacity.combined(with: .move(edge: .bottom)))
                    .padding(.bottom, 4)
            }

            ZStack(alignment: .leading) {
                Group {
                    if isSecure {
                        SecureField("", text: $text)
                    } else {
                        TextField("", text: $text)
                    }
                }
                .font(.body)
                #if canImport(UIKit)
                .keyboardType(keyboardType.uiKeyboardType)
                #endif
                .focused($isFocused)

                if text.isEmpty {
                    Text(effectivePlaceholder)
                        .font(.body)
                        .foregroundStyle(.tertiary)
                        .padding(.leading, 5.0)
                }
            }
            .padding(.vertical, 12)
            .padding(.horizontal, 4)

            Rectangle()
                .fill(isFocused ? Color.accentColor : Common.defaultSeparatorColor)
                .frame(height: isFocused ? 2 : 1)
                .animation(.easeInOut(duration: 0.2), value: isFocused)
        }
        .animation(.easeInOut(duration: 0.2), value: showFloatingLabel)
    }
}

#if DEBUG
private struct FloatingLabelPreviewContainer: View {
    @State private var titleText: String = ""

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 32) {
                Group {
                    Text("Floating Label Text Fields")
                        .font(.headline)

                    FloatingLabelTextField(
                        title: "Meal",
                        placeholder: "Meal",
                        isSecure: false,
                        keyboardType: .default,
                        text: $titleText
                    )
                    FloatingLabelTextField(
                        title: "Time",
                        placeholder: "Time",
                        isSecure: false,
                        keyboardType: .default,
                        text: .constant("Noon")
                    )
                }
            }
            .padding(24)
        }
        .background(Color(.white))
    }
}

#Preview("FloatingLabelTextField") {
    FloatingLabelPreviewContainer()
}
#endif

Component 2: Recipe Card

File: Views/Components/RecipeCard.swift

import SwiftUI

struct RecipeCard: View {
    let recipe: Recipe
    let maxWidth: CGFloat?
    let onFavoriteToggle: () -> Void
    private let imageHeight: CGFloat = 160
    private let cardHeight: CGFloat = 300
    private let contentHorizontalPadding: CGFloat = 20
    
    init(recipe: Recipe, maxWidth: CGFloat? = nil, onFavoriteToggle: @escaping () -> Void) {
        self.recipe = recipe
        self.maxWidth = maxWidth
        self.onFavoriteToggle = onFavoriteToggle
    }
    
    var body: some View {
        VStack(alignment: .leading, spacing: 0) {
            // Image or placeholder
            ZStack(alignment: .topTrailing) {
                if let image = recipe.image {
                    #if os(iOS) || os(tvOS)
                    Image(uiImage: image)
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(maxWidth: maxWidth ?? .infinity)
                        .frame(height: imageHeight)
                        .clipped()
                    #elseif os(macOS)
                    Image(nsImage: image)
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                        .frame(maxWidth: maxWidth ?? .infinity)
                        .frame(height: imageHeight)
                        .clipped()
                    #endif
                } else {
                    Rectangle()
                        .fill(recipe.category.color.gradient)
                        .frame(height: imageHeight)
                        .overlay {
                            Image(systemName: recipe.category.icon)
                                .font(.system(size: 50))
                                .foregroundStyle(.white.opacity(0.7))
                        }
                }

                HStack(alignment: .center) {
                    // Top-left difficulty badge
                    Text(recipe.difficulty.rawValue)
                        .font(.caption)
                        .fontWeight(.semibold)
                        .foregroundStyle(recipe.difficulty.color)
                        .padding(8)
                    Spacer()
                    // Top-right favorite button
                    Button(action: onFavoriteToggle) {
                        Image(systemName: recipe.isFavorite ? "heart.fill" : "heart")
                            .font(.system(size: 12, weight: .semibold))
                            .foregroundStyle(recipe.isFavorite ? .red : .white)
                            .frame(width: 20, height: 20)
                            .background(
                                Circle()
                                    .fill(.white.opacity(0.15))
                            )
                    }
                    .buttonStyle(.plain)
                    .padding(6)
                    .contentShape(Circle())
                }
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.horizontal, contentHorizontalPadding / 4)
                .padding(.top, 12)
            }
            
            VStack(alignment: .leading, spacing: 8) {
                // Title
                Text(recipe.title)
                    .font(.headline)
                    .lineLimit(2)
                    .fixedSize(horizontal: false, vertical: true)
                
                // Description
                Text(recipe.description)
                    .font(.caption)
                    .foregroundStyle(.secondary)
                    .lineLimit(2)
                    .fixedSize(horizontal: false, vertical: true)
                
                Spacer(minLength: 4)
                
                // Metadata
                VStack(alignment: .leading, spacing: 4) {
                    Label("\(recipe.totalTime) min", systemImage: "clock")
                    Label("\(recipe.servings)", systemImage: "person.2")
                }
                .font(.caption)
                .foregroundStyle(.secondary)
            }
            .padding(.horizontal, contentHorizontalPadding)
            .padding(.vertical, 12)
            .frame(maxWidth: .infinity, alignment: .leading)
        }
        .frame(maxWidth: maxWidth ?? .infinity)
        .frame(height: cardHeight)
        .background(Common.defaultSeparatorColor)
        .cornerRadius(16)
        .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 4)
    }
}

#if DEBUG
private struct RecipeCardPreviewContainer: View {
    @State private var recipe = Recipe(
        title: "Fried Eggs",
        description: "Fried eggs with a lot of salty salt that contains sodium",
        ingredients: ["Eggs", "Salt"],
        instructions: "Fry the eggs",
        prepTime: 2,
        cookTime: 10,
        servings: 1,
        category: .breakfast,
        difficulty: .easy
    )

    var body: some View {
        ScrollView {
            HStack(alignment: .center, spacing: 32) {
                Group {
                    RecipeCard(recipe: recipe) { }
                    RecipeCard(recipe: recipe) { }
                }
            }
            .padding(24)
        }
        .background(Color(.white))
    }
}

#Preview("Recipe Card") {
    RecipeCardPreviewContainer()
}
#endif

Component 3: Category Filter Chip

File: Views/Components/CategoryChip.swift

import SwiftUI

struct CategoryChip: View {
    let category: RecipeCategory
    let isSelected: Bool
    let action: () -> Void
    
    var body: some View {
        Button(action: action) {
            HStack(spacing: 6) {
                Image(systemName: category.icon)
                    .font(.caption)
                Text(category.rawValue)
                    .font(.subheadline)
                    .fontWeight(.medium)
            }
            .padding(.horizontal, 16)
            .padding(.vertical, 8)
            .background(isSelected ? category.color : Common.defaultBackgroundColor)
            .foregroundStyle(isSelected ? .white : .primary)
            .cornerRadius(20)
        }
        .animation(.easeInOut(duration: 0.2), value: isSelected)
    }
}

#if DEBUG
private struct CategoryChipPreviewContainer: View {
    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 32) {
                Group {
                    Text("Recipe Card")
                        .font(.headline)
                    
                    CategoryChip(
                        category: .breakfast,
                        isSelected: true,
                        action: { }
                    )
                }
            }
            .padding(24)
        }
        .background(Color(.white))
    }
}

#Preview("Category Chip") {
    CategoryChipPreviewContainer()
}
#endif

Step 7: Create ViewModel

File: ViewModels/RecipeViewModel.swift

import SwiftUI
import CoreData
import Combine

@MainActor
class RecipeViewModel: ObservableObject {
    enum SearchSource: String, CaseIterable {
        case local = "Local"
        case remote = "Remote"
        
        var title: String { rawValue }
    }
    
    @Published var recipes: [Recipe] = []
    @Published var filteredRecipes: [Recipe] = []
    @Published var selectedCategory: RecipeCategory?
    @Published var searchText: String = ""
    @Published var searchSource: SearchSource = .local
    @Published var isLoading: Bool = false
    @Published var errorMessage: String?
    
    private let context: NSManagedObjectContext
    private let networkService = NetworkService.shared
    
    init(context: NSManagedObjectContext) {
        self.context = context
        fetchRecipes()
    }
    
    // MARK: - Fetch Recipes from Core Data
    func fetchRecipes() {
        let request = RecipeEntity.fetchRequest()
        request.sortDescriptors = [NSSortDescriptor(keyPath: \RecipeEntity.createdAt, ascending: false)]
        
        do {
            let entities = try context.fetch(request)
            recipes = entities.map { $0.toRecipe() }
            applyFilters()
        } catch {
            errorMessage = "Failed to fetch recipes: \(error.localizedDescription)"
        }
    }
    
    // MARK: - Add Recipe
    func addRecipe(_ recipe: Recipe) {
        let entity = RecipeEntity(context: context)
        entity.id = recipe.id
        entity.title = recipe.title
        entity.recipeDescription = recipe.description
        entity.ingredients = recipe.ingredients.joined(separator: "||")
        entity.instructions = recipe.instructions
        entity.prepTime = Int16(recipe.prepTime)
        entity.cookTime = Int16(recipe.cookTime)
        entity.servings = Int16(recipe.servings)
        entity.category = recipe.category.rawValue
        entity.difficulty = recipe.difficulty.rawValue
        entity.isFavorite = recipe.isFavorite
        entity.imageData = recipe.imageData
        entity.sourceURL = recipe.sourceURL
        entity.createdAt = recipe.createdAt
        
        saveContext()
        fetchRecipes()
    }
    
    // MARK: - Update Recipe
    func updateRecipe(_ recipe: Recipe) {
        let request = RecipeEntity.fetchRequest()
        request.predicate = NSPredicate(format: "id == %@", recipe.id as CVarArg)
        
        do {
            let entities = try context.fetch(request)
            if let entity = entities.first {
                entity.title = recipe.title
                entity.recipeDescription = recipe.description
                entity.ingredients = recipe.ingredients.joined(separator: "||")
                entity.instructions = recipe.instructions
                entity.prepTime = Int16(recipe.prepTime)
                entity.cookTime = Int16(recipe.cookTime)
                entity.servings = Int16(recipe.servings)
                entity.category = recipe.category.rawValue
                entity.difficulty = recipe.difficulty.rawValue
                entity.isFavorite = recipe.isFavorite
                entity.imageData = recipe.imageData
                entity.sourceURL = recipe.sourceURL
                
                saveContext()
                fetchRecipes()
            }
        } catch {
            errorMessage = "Failed to update recipe: \(error.localizedDescription)"
        }
    }
    
    // MARK: - Delete Recipe
    func deleteRecipe(_ recipe: Recipe) {
        let request = RecipeEntity.fetchRequest()
        request.predicate = NSPredicate(format: "id == %@", recipe.id as CVarArg)
        
        do {
            let entities = try context.fetch(request)
            if let entity = entities.first {
                context.delete(entity)
                saveContext()
                fetchRecipes()
            }
        } catch {
            errorMessage = "Failed to delete recipe: \(error.localizedDescription)"
        }
    }
    
    // MARK: - Toggle Favorite
    func toggleFavorite(_ recipe: Recipe) {
        var updatedRecipe = recipe
        updatedRecipe.isFavorite.toggle()
        updateRecipe(updatedRecipe)
    }
    
    // MARK: - Fetch Random Recipe from Network
    func fetchRandomRecipe() async {
        isLoading = true
        errorMessage = nil
        
        do {
            let recipe = try await networkService.fetchRandomRecipe()
            addRecipe(recipe)
        } catch {
            errorMessage = "Failed to fetch recipe: \(error.localizedDescription)"
        }
        
        isLoading = false
    }
    
    // MARK: - Filtering
    func applyFilters() {
        var result = recipes
        
        // Category filter
        if let category = selectedCategory {
            result = result.filter { $0.category == category }
        }
        
        // Search filter
        if !searchText.isEmpty {
            result = result.filter {
                $0.title.localizedCaseInsensitiveContains(searchText) ||
                $0.description.localizedCaseInsensitiveContains(searchText)
            }
        }
        
        filteredRecipes = result
    }
    
    func selectCategory(_ category: RecipeCategory?) {
        selectedCategory = category
        applyFilters()
    }
    
    func setSearchSource(_ source: SearchSource) {
        guard searchSource != source else { return }
        Task { @MainActor in
            self.searchSource = source
            self.applyFilters()
        }
    }
    
    func updateSearchText(_ text: String) {
        Task { @MainActor [text] in
            if self.searchText != text {
                self.searchText = text
            }
            if self.searchSource == .local {
                self.applyFilters()
            }
        }
    }
    
    func submitSearch() async {
        if searchSource == .local {
            applyFilters()
        } else {
            await importRemoteRecipe(for: searchText)
        }
    }
    
    private func importRemoteRecipe(for text: String) async {
        let query = text.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !query.isEmpty else { return }
        isLoading = true
        errorMessage = nil
        do {
            let remoteResults = try await networkService.searchRecipes(query: query)
            if let selected = remoteResults.randomElement() {
                await MainActor.run {
                    let duplicate = recipes.contains(where: { $0.id == selected.id || ($0.title == selected.title && $0.instructions == selected.instructions) })
                    searchText = ""
                    searchSource = .local
                    selectedCategory = nil
                    if duplicate {
                        applyFilters()
                    } else {
                        addRecipe(selected)
                    }
                }
            } else {
                await MainActor.run {
                    errorMessage = "No recipes found for \(query)"
                }
            }
        } catch {
            await MainActor.run {
                errorMessage = "Search failed: \(error.localizedDescription)"
            }
        }
        await MainActor.run {
            isLoading = false
        }
    }
    
    // MARK: - Helper
    private func saveContext() {
        do {
            try context.save()
        } catch {
            errorMessage = "Failed to save: \(error.localizedDescription)"
        }
    }
}

Step 8: Create Image Picker (UIKit Integration)

File: Views/Components/ImagePicker.swift

import SwiftUI

#if os(iOS)
import PhotosUI

struct ImagePicker: UIViewControllerRepresentable {
    @Binding var selectedImage: UIImage?
    @Binding var isPresented: Bool

    func makeUIViewController(context: Context) -> PHPickerViewController {
        var configuration = PHPickerConfiguration()
        configuration.filter = .images
        configuration.selectionLimit = 1

        let picker = PHPickerViewController(configuration: configuration)
        picker.delegate = context.coordinator
        return picker
    }

    func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(selectedImage: $selectedImage, isPresented: $isPresented)
    }

    final class Coordinator: NSObject, PHPickerViewControllerDelegate {
        private var selectedImage: Binding<UIImage?>
        private var isPresented: Binding<Bool>

        init(selectedImage: Binding<UIImage?>, isPresented: Binding<Bool>) {
            self.selectedImage = selectedImage
            self.isPresented = isPresented
        }

        func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
            isPresented.wrappedValue = false

            guard let provider = results.first?.itemProvider else { return }

            if provider.canLoadObject(ofClass: UIImage.self) {
                provider.loadObject(ofClass: UIImage.self) { image, _ in
                    DispatchQueue.main.async {
                        self.selectedImage.wrappedValue = image as? UIImage
                    }
                }
            }
        }
    }
}
#endif

#if os(macOS)
import AppKit
import UniformTypeIdentifiers

func presentImagePicker(selectedImage: Binding<NSImage?>, isPresented: Binding<Bool>) {
    let panel = NSOpenPanel()
    panel.allowedContentTypes = [.png, .jpeg, .tiff, .heic]
    panel.canChooseFiles = true
    panel.canChooseDirectories = false
    panel.allowsMultipleSelection = false
    
    panel.begin { response in
        if response == .OK,
           let url = panel.url,
           let imageData = try? Data(contentsOf: url),
           let image = NSImage(data: imageData) {
            DispatchQueue.main.async {
                selectedImage.wrappedValue = image
            }
        }
        DispatchQueue.main.async {
            isPresented.wrappedValue = false
        }
    }
}
#endif

Step 9: Create Add/Edit Recipe Views

Add Recipe View

File: Views/RecipeList/AddRecipeView.swift

import SwiftUI
import PhotosUI
import CoreData

struct AddRecipeView: View {
    @ObservedObject var viewModel: RecipeViewModel
    @Environment(\.dismiss) private var dismiss
    
    @State private var title = ""
    @State private var description = ""
    @State private var ingredientInput = ""
    @State private var ingredients: [String] = []
    @State private var instructions = ""
    @State private var prepTime = 15
    @State private var cookTime = 30
    @State private var servings = 4
    @State private var category: RecipeCategory = .lunch
    @State private var difficulty: Difficulty = .medium
    #if os(iOS) || os(tvOS)
    @State private var selectedImage: UIImage?
    #elseif os(macOS)
    @State private var selectedImage: NSImage?
    #endif
    @State private var showingImagePicker = false
    
    var body: some View {
        NavigationStack {
            Form {
                // Basic Info Section
                Section("Basic Information") {
                    FloatingLabelTextField(
                        title: "Recipe Title",
                        placeholder: "e.g., Chocolate Chip Cookies",
                        text: $title
                    )
                    
                    FloatingLabelTextField(
                        title: "Description",
                        placeholder: "Brief description of your recipe",
                        text: $description
                    )
                }
                
                // Image Section
                Section("Photo") {
                    if let image = selectedImage {
                        #if os(iOS) || os(tvOS)
                        Image(uiImage: image)
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(height: 200)
                            .clipped()
                            .cornerRadius(8)
                        #elseif os(macOS)
                        Image(nsImage: image)
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(height: 200)
                            .clipped()
                            .cornerRadius(8)
                        #endif
                        
                        Button("Change Photo", systemImage: "photo") {
                            showingImagePicker = true
                        }
                    } else {
                        Button("Add Photo", systemImage: "camera") {
                            showingImagePicker = true
                        }
                    }
                }
                
                // Category and Difficulty
                Section("Details") {
                    Picker("Category", selection: $category) {
                        ForEach(RecipeCategory.allCases, id: \.self) { cat in
                            HStack {
                                Image(systemName: cat.icon)
                                Text(cat.rawValue)
                            }
                            .tag(cat)
                        }
                    }
                    
                    Picker("Difficulty", selection: $difficulty) {
                        ForEach(Difficulty.allCases, id: \.self) { diff in
                            Text(diff.rawValue).tag(diff)
                        }
                    }
                    
                    Stepper("Prep Time: \(prepTime) min", value: $prepTime, in: 5...120, step: 5)
                    Stepper("Cook Time: \(cookTime) min", value: $cookTime, in: 5...240, step: 5)
                    Stepper("Servings: \(servings)", value: $servings, in: 1...20)
                }
                
                // Ingredients Section
                Section("Ingredients") {
                    HStack {
                        TextField("Add ingredient", text: $ingredientInput)
                        
                        Button("Add") {
                            if !ingredientInput.isEmpty {
                                ingredients.append(ingredientInput)
                                ingredientInput = ""
                            }
                        }
                        .disabled(ingredientInput.isEmpty)
                    }
                    
                    ForEach(ingredients, id: \.self) { ingredient in
                        Text(ingredient)
                    }
                    .onDelete { indexSet in
                        ingredients.remove(atOffsets: indexSet)
                    }
                }
                
                // Instructions Section
                Section("Instructions") {
                    TextEditor(text: $instructions)
                        .frame(minHeight: 150)
                }
            }
            .formStyle(.grouped)
            .navigationTitle("New Recipe")
            #if os(iOS)
            .navigationBarTitleDisplayMode(.inline)
            #endif
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") {
                        dismiss()
                    }
                }
                
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        saveRecipe()
                    }
                    .disabled(!isValid)
                }
            }
            #if os(iOS)
            .sheet(isPresented: $showingImagePicker) {
                ImagePicker(selectedImage: $selectedImage, isPresented: $showingImagePicker)
            }
            #elseif os(macOS)
            .onChange(of: showingImagePicker) { _, isPresented in
                if isPresented {
                    presentImagePicker(selectedImage: $selectedImage, isPresented: $showingImagePicker)
                }
            }
            #endif
        }
    }
    
    private var isValid: Bool {
        !title.isEmpty && !ingredients.isEmpty && !instructions.isEmpty
    }
    
    private func saveRecipe() {
        let recipe = Recipe(
            title: title,
            description: description,
            ingredients: ingredients,
            instructions: instructions,
            prepTime: prepTime,
            cookTime: cookTime,
            servings: servings,
            category: category,
            difficulty: difficulty,
            imageData: imageData
        )
        
        viewModel.addRecipe(recipe)
        dismiss()
    }
    
    private var imageData: Data? {
        guard let selectedImage = selectedImage else { return nil }
        #if os(iOS) || os(tvOS)
        return selectedImage.jpegData(compressionQuality: 0.7)
        #elseif os(macOS)
        return jpegDataFrom(image: selectedImage)
        #endif
    }
    #if os(macOS)
    private func jpegDataFrom(image: NSImage) -> Data {
        let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil)!
        let bitmapRep = NSBitmapImageRep(cgImage: cgImage)
        let jpegData = bitmapRep.representation(using: NSBitmapImageRep.FileType.jpeg, properties: [:])!
        return jpegData
    }
    #endif
}

Edit Recipe View

File: Views/RecipeDetail/EditRecipeView.swift

import SwiftUI

struct EditRecipeView: View {
    let recipe: Recipe
    @ObservedObject var viewModel: RecipeViewModel
    @Environment(\.dismiss) private var dismiss
    
    @State private var title: String
    @State private var description: String
    @State private var ingredientInput = ""
    @State private var ingredients: [String]
    @State private var instructions: String
    @State private var prepTime: Int
    @State private var cookTime: Int
    @State private var servings: Int
    @State private var category: RecipeCategory
    @State private var difficulty: Difficulty
    #if os(iOS) || os(tvOS)
    @State private var selectedImage: UIImage?
    #elseif os(macOS)
    @State private var selectedImage: NSImage?
    #endif
    @State private var showingImagePicker = false
    
    init(recipe: Recipe, viewModel: RecipeViewModel) {
        self.recipe = recipe
        self.viewModel = viewModel
        
        _title = State(initialValue: recipe.title)
        _description = State(initialValue: recipe.description)
        _ingredients = State(initialValue: recipe.ingredients)
        _instructions = State(initialValue: recipe.instructions)
        _prepTime = State(initialValue: recipe.prepTime)
        _cookTime = State(initialValue: recipe.cookTime)
        _servings = State(initialValue: recipe.servings)
        _category = State(initialValue: recipe.category)
        _difficulty = State(initialValue: recipe.difficulty)
        _selectedImage = State(initialValue: recipe.image)
    }
    
    var body: some View {
        NavigationStack {
            Form {
                Section("Basic Information") {
                    FloatingLabelTextField(
                        title: "Recipe Title",
                        text: $title
                    )
                    
                    FloatingLabelTextField(
                        title: "Description",
                        text: $description
                    )
                }
                
                Section("Photo") {
                    if let image = selectedImage {
                        #if os(iOS) || os(tvOS)
                        Image(uiImage: image)
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(height: 200)
                            .clipped()
                            .cornerRadius(8)
                        #elseif os(macOS)
                        Image(nsImage: image)
                            .resizable()
                            .aspectRatio(contentMode: .fill)
                            .frame(height: 200)
                            .clipped()
                            .cornerRadius(8)
                        #endif
                        
                        Button("Change Photo", systemImage: "photo") {
                            showingImagePicker = true
                        }
                    } else {
                        Button("Add Photo", systemImage: "camera") {
                            showingImagePicker = true
                        }
                    }
                }
                
                Section("Details") {
                    Picker("Category", selection: $category) {
                        ForEach(RecipeCategory.allCases, id: \.self) { cat in
                            HStack {
                                Image(systemName: cat.icon)
                                Text(cat.rawValue)
                            }
                            .tag(cat)
                        }
                    }
                    
                    Picker("Difficulty", selection: $difficulty) {
                        ForEach(Difficulty.allCases, id: \.self) { diff in
                            Text(diff.rawValue).tag(diff)
                        }
                    }
                    
                    Stepper("Prep Time: \(prepTime) min", value: $prepTime, in: 5...120, step: 5)
                    Stepper("Cook Time: \(cookTime) min", value: $cookTime, in: 5...240, step: 5)
                    Stepper("Servings: \(servings)", value: $servings, in: 1...20)
                }
                
                Section("Ingredients") {
                    HStack {
                        TextField("Add ingredient", text: $ingredientInput)
                        
                        Button("Add") {
                            if !ingredientInput.isEmpty {
                                ingredients.append(ingredientInput)
                                ingredientInput = ""
                            }
                        }
                        .disabled(ingredientInput.isEmpty)
                    }
                    
                    ForEach(ingredients, id: \.self) { ingredient in
                        Text(ingredient)
                    }
                    .onDelete { indexSet in
                        ingredients.remove(atOffsets: indexSet)
                    }
                }
                
                Section("Instructions") {
                    TextEditor(text: $instructions)
                        .frame(minHeight: 150)
                }
            }
            .navigationTitle("Edit Recipe")
            #if os(iOS)
            .navigationBarTitleDisplayMode(.inline)
            #endif
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") {
                        dismiss()
                    }
                }
                
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        updateRecipe()
                    }
                }
            }
            #if os(iOS)
            .sheet(isPresented: $showingImagePicker) {
                ImagePicker(selectedImage: $selectedImage, isPresented: $showingImagePicker)
            }
            #elseif os(macOS)
            .onChange(of: showingImagePicker) { _, isPresented in
                if isPresented {
                    presentImagePicker(selectedImage: $selectedImage, isPresented: $showingImagePicker)
                }
            }
            #endif
        }
    }
    
    private func updateRecipe() {
        var updatedRecipe = recipe
        updatedRecipe.title = title
        updatedRecipe.description = description
        updatedRecipe.ingredients = ingredients
        updatedRecipe.instructions = instructions
        updatedRecipe.prepTime = prepTime
        updatedRecipe.cookTime = cookTime
        updatedRecipe.servings = servings
        updatedRecipe.category = category
        updatedRecipe.difficulty = difficulty
        updatedRecipe.imageData = imageData
        
        viewModel.updateRecipe(updatedRecipe)
        dismiss()
    }
    
    private var imageData: Data? {
        guard let selectedImage = selectedImage else { return nil }
        #if os(iOS) || os(tvOS)
        return selectedImage.jpegData(compressionQuality: 0.7)
        #elseif os(macOS)
        return jpegDataFrom(image: selectedImage)
        #endif
    }
    #if os(macOS)
    private func jpegDataFrom(image: NSImage) -> Data {
        let cgImage = image.cgImage(forProposedRect: nil, context: nil, hints: nil)!
        let bitmapRep = NSBitmapImageRep(cgImage: cgImage)
        let jpegData = bitmapRep.representation(using: NSBitmapImageRep.FileType.jpeg, properties: [:])!
        return jpegData
    }
    #endif
}

Step 10: Create Main Views

View 1: Recipe Detail View

File: Views/RecipeDetail/RecipeDetailView.swift

import SwiftUI

struct RecipeDetailView: View {
    let recipeId: UUID
    @ObservedObject var viewModel: RecipeViewModel
    @Environment(\.dismiss) private var dismiss
    @State private var showingEditSheet = false
    @State private var showingDeleteAlert = false

    private var recipe: Recipe? {
        viewModel.recipes.first(where: { $0.id == recipeId })
    }
    
    var body: some View {
        Group {
            if let recipe = recipe {
                ScrollView {
                    VStack(alignment: .leading, spacing: 20) {
                        // Header Image
                        headerImage(for: recipe)
                        
                        // Title and Metadata
                        VStack(alignment: .leading, spacing: 12) {
                            HStack {
                                VStack(alignment: .leading, spacing: 4) {
                                    Text(recipe.title)
                                        .font(.title)
                                        .fontWeight(.bold)
                                    
                                    Text(recipe.category.rawValue)
                                        .font(.subheadline)
                                        .foregroundStyle(recipe.category.color)
                                }
                                
                                Spacer()
                                
                                Button {
                                    viewModel.toggleFavorite(recipe)
                                } label: {
                                    Image(systemName: recipe.isFavorite ? "heart.fill" : "heart")
                                        .font(.title2)
                                        .foregroundStyle(recipe.isFavorite ? .red : .gray)
                                }
                            }
                            
                            Text(recipe.description)
                                .font(.body)
                                .foregroundStyle(.secondary)
                            
                            // Time and Servings
                            HStack(spacing: 20) {
                                InfoCard(icon: "clock", title: "Prep", value: "\(recipe.prepTime) min")
                                InfoCard(icon: "flame", title: "Cook", value: "\(recipe.cookTime) min")
                                InfoCard(icon: "person.2", title: "Servings", value: "\(recipe.servings)")
                                
                                Spacer()
                                
                                DifficultyBadge(difficulty: recipe.difficulty)
                            }
                        }
                        .padding(.horizontal)
                        
                        Divider()
                            .padding(.horizontal)
                        
                        // Ingredients
                        VStack(alignment: .leading, spacing: 12) {
                            Text("Ingredients")
                                .font(.title2)
                                .fontWeight(.bold)
                            
                            ForEach(Array(recipe.ingredients.enumerated()), id: \.offset) { _, ingredient in
                                HStack(alignment: .top, spacing: 12) {
                                    Image(systemName: "checkmark.circle.fill")
                                        .foregroundStyle(recipe.category.color)
                                    Text(ingredient)
                                        .font(.body)
                                }
                            }
                        }
                        .padding(.horizontal)
                        
                        Divider()
                            .padding(.horizontal)
                        
                        // Instructions
                        VStack(alignment: .leading, spacing: 12) {
                            Text("Instructions")
                                .font(.title2)
                                .fontWeight(.bold)
                            
                            Text(recipe.instructions)
                                .font(.body)
                                .lineSpacing(6)
                        }
                        .padding(.horizontal)
                        
                        if let source = recipe.sourceURL,
                           let url = URL(string: source),
                           !source.isEmpty {
                            Divider()
                                .padding(.horizontal)
                            VStack(alignment: .leading, spacing: 8) {
                                Text("Source")
                                    .font(.title3)
                                    .fontWeight(.semibold)
                                Link(destination: url) {
                                    Label("View Original Recipe", systemImage: "safari")
                                        .font(.body)
                                }
                                .buttonStyle(.borderedProminent)
                            }
                            .padding(.horizontal)
                        }
                    }
                    .padding(.bottom, 20)
                }
                #if os(iOS) || os(tvOS)
                .navigationBarTitleDisplayMode(.inline)
                #endif
                .toolbar {
                    ToolbarItem(placement: toolbarPlacement) {
                        Menu {
                            Button {
                                showingEditSheet = true
                            } label: {
                                Label("Edit", systemImage: "pencil")
                            }
                            
                            Button(role: .destructive) {
                                showingDeleteAlert = true
                            } label: {
                                Label("Delete", systemImage: "trash")
                            }
                        } label: {
                            Image(systemName: "ellipsis.circle")
                        }
                    }
                }
                .sheet(isPresented: $showingEditSheet) {
                    EditRecipeView(recipe: recipe, viewModel: viewModel)
                }
                .alert("Delete Recipe", isPresented: $showingDeleteAlert) {
                    Button("Cancel", role: .cancel) { }
                    Button("Delete", role: .destructive) {
                        viewModel.deleteRecipe(recipe)
                        dismiss()
                    }
                } message: {
                    Text("Are you sure you want to delete this recipe?")
                }
            } else {
                Text("Recipe not found")
                    .font(.headline)
                    .foregroundStyle(.secondary)
            }
        }
    }
    
    private func headerImage(for recipe: Recipe) -> some View {
        ZStack(alignment: .bottomLeading) {
            if let image = recipe.image {
                #if os(iOS) || os(tvOS)
                Image(uiImage: image)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(height: 250)
                    .clipped()
                #elseif os(macOS)
                Image(nsImage: image)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(height: 250)
                    .clipped()
                #endif
            } else {
                Rectangle()
                    .fill(recipe.category.color.gradient)
                    .frame(height: 250)
                    .overlay {
                        Image(systemName: recipe.category.icon)
                            .font(.system(size: 80))
                            .foregroundStyle(.white.opacity(0.5))
                    }
            }
        }
    }
    
    private var toolbarPlacement: ToolbarItemPlacement {
        #if os(iOS) || os(tvOS)
        .topBarTrailing
        #elseif os(macOS)
        .automatic
        #endif
    }
}

// MARK: - Info Card Component
struct InfoCard: View {
    let icon: String
    let title: String
    let value: String
    
    var body: some View {
        VStack(spacing: 4) {
            Image(systemName: icon)
                .font(.title3)
                .foregroundStyle(.secondary)
            Text(title)
                .font(.caption)
                .foregroundStyle(.secondary)
            Text(value)
                .font(.subheadline)
                .fontWeight(.semibold)
        }
        .frame(maxWidth: .infinity)
        .padding(.vertical, 12)
        .background(Common.defaultBackgroundColor)
        .cornerRadius(10)
    }
}

// MARK: - Difficulty Badge
struct DifficultyBadge: View {
    let difficulty: Difficulty
    
    var body: some View {
        Text(difficulty.rawValue)
            .font(.caption)
            .fontWeight(.bold)
            .foregroundStyle(.white)
            .padding(.horizontal, 12)
            .padding(.vertical, 6)
            .background(difficulty.color)
            .cornerRadius(8)
    }
}

View 2: Recipe List View

File: Views/RecipeList/RecipeListView.swift

import SwiftUI

struct RecipeListView: View {
    @StateObject private var viewModel: RecipeViewModel
    @State private var showingAddRecipe = false
    
    init(context: NSManagedObjectContext) {
        _viewModel = StateObject(wrappedValue: RecipeViewModel(context: context))
    }
    
    private var toolbarPlacement: ToolbarItemPlacement {
        #if os(iOS) || os(tvOS)
        .topBarTrailing
        #elseif os(macOS)
        .automatic
        #endif
    }
    
    var body: some View {
        NavigationStack {
            VStack(spacing: 0) {
                // Category Filter
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: 12) {
                        Button(action: { viewModel.selectCategory(nil) }) {
                            Text("All")
                                .font(.subheadline)
                                .fontWeight(.medium)
                                .padding(.horizontal, 16)
                                .padding(.vertical, 8)
                                .background(viewModel.selectedCategory == nil ? Color.accentColor : Common.defaultBackgroundColor)
                                .foregroundStyle(viewModel.selectedCategory == nil ? .white : .primary)
                                .cornerRadius(20)
                        }
                        .animation(.easeInOut(duration: 0.2), value: viewModel.selectedCategory)
                        
                        ForEach(RecipeCategory.allCases, id: \.self) { category in
                            CategoryChip(
                                category: category,
                                isSelected: viewModel.selectedCategory == category,
                                action: { viewModel.selectCategory(category) }
                            )
                        }
                    }
                    .padding(.horizontal)
                    .padding(.vertical, 12)
                }
                .background(Color(Common.defaultBackgroundColor))
                
                Divider()
                
                // Recipe Grid
                if viewModel.filteredRecipes.isEmpty {
                    emptyState
                } else {
                    recipeGrid
                }
            }
            .navigationTitle("Recipe Book")
            .searchable(text: $viewModel.searchText, prompt: "Search recipes")
            .onChange(of: viewModel.searchText) { _, _ in
                viewModel.applyFilters()
            }
            .toolbar {
                ToolbarItem(placement: toolbarPlacement) {
                    Menu {
                        Button {
                            showingAddRecipe = true
                        } label: {
                            Label("Add Recipe", systemImage: "plus")
                        }
                        
                        Button {
                            Task {
                                await viewModel.fetchRandomRecipe()
                            }
                        } label: {
                            Label("Get Random Recipe", systemImage: "shuffle")
                        }
                    } label: {
                        Image(systemName: "plus.circle.fill")
                            .font(.title3)
                    }
                }
            }
            .sheet(isPresented: $showingAddRecipe) {
                AddRecipeView(viewModel: viewModel)
            }
            .overlay {
                if viewModel.isLoading {
                    ProgressView()
                        .scaleEffect(1.5)
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                        .background(.ultraThinMaterial)
                }
            }
        }
    }
    
    private var recipeGrid: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 160), spacing: 16)], spacing: 16) {
                ForEach(viewModel.filteredRecipes) { recipe in
                    NavigationLink(value: recipe) {
                        RecipeCard(recipe: recipe) {
                            viewModel.toggleFavorite(recipe)
                        }
                    }
                    .buttonStyle(.plain)
                }
            }
            .padding()
        }
        .navigationDestination(for: Recipe.self) { recipe in
            RecipeDetailView(recipeId: recipe.id, viewModel: viewModel)
        }
    }
    
    private var emptyState: some View {
        VStack(spacing: 20) {
            Image(systemName: "book.closed")
                .font(.system(size: 60))
                .foregroundStyle(.secondary)
            
            Text("No Recipes Yet")
                .font(.title2)
                .fontWeight(.semibold)
            
            Text("Add your first recipe or import one from the web")
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .multilineTextAlignment(.center)
            
            Button {
                showingAddRecipe = true
            } label: {
                Label("Add Recipe", systemImage: "plus")
                    .fontWeight(.semibold)
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}

#Preview("Recipe List View") {
    RecipeListView(context: PersistenceController.preview.container.viewContext)
}

Step 11: Update App Entry Point

File: RecipeBookApp.swift

import SwiftUI

@main
struct RecipeBookApp: App {
    let persistenceController = PersistenceController.shared
    
    var body: some Scene {
        WindowGroup {
            RecipeListView(context: persistenceController.container.viewContext)
                .environment(\.managedObjectContext, persistenceController.container.viewContext)
        }
    }
}

Step 12: Build and Run

  1. Select a simulator or device (iPhone 15 Pro recommended)
  2. Press Cmd + B to build
  3. Press Cmd + R to run
  4. Test the features:
    • View the recipe list
    • Filter by category
    • Search recipes
    • Add a new recipe
    • Add photo from photo library
    • Toggle favorites
    • View recipe details
    • Edit a recipe
    • Delete a recipe
    • Fetch random recipe from API

Step 13: Concepts Demonstrated

Views & Modifiers

  • Custom views: RecipeCard, CategoryChip, InfoCard
  • Modifiers: .padding(), .background(), .cornerRadius(), .shadow()

State Management

  • @State: Local UI state (search text, sheet presentation)
  • @Binding: Two-way data flow (ImagePicker)
  • @StateObject: ViewModel ownership in views
  • @ObservedObject: Observing ViewModel changes
  • @Environment: Access to Core Data context and dismiss
  • NavigationStack with type-safe navigation
  • List with ForEach and .onDelete
  • LazyVGrid for recipe grid
  • .sheet() for modal presentation
  • .alert() for confirmations
  • ScrollView with lazy loading

MVVM Architecture

  • Model: Recipe, RecipeCategory, Difficulty
  • View: All view files
  • ViewModel: RecipeViewModel with @Published properties

Custom Components

  • FloatingLabelTextField: Reusable animated text field
  • RecipeCard: Complex card with image, metadata, and interactions
  • CategoryChip: Toggle button with animation

Animations & Transitions

  • Floating label animation
  • Category selection animation
  • Smooth transitions between views
  • Loading state overlay

UIKit Integration

  • ImagePicker: UIViewControllerRepresentable
  • PHPickerViewController coordinator pattern
  • Image handling and compression

Networking

  • async/await for network calls
  • URLSession integration
  • Error handling with custom NetworkError
  • JSON decoding with Codable

Core Data Persistence

  • NSPersistentContainer setup
  • CRUD operations (Create, Read, Update, Delete)
  • FetchRequest for data retrieval
  • Entity to Model conversion
  • Context saving and error handling

Previews & Testing

  • Multiple preview configurations
  • Mock data for previews
  • Preview with in-memory Core Data

Step 14: Test All Features

Feature Checklist

Test each feature systematically:

1. Recipe List

  • Empty state shows when no recipes
  • Sample recipes display in grid
  • Categories filter correctly
  • Search filters recipes in real-time

2. Add Recipe

  • Form validates required fields
  • Can add multiple ingredients
  • Can remove ingredients
  • Photo picker works
  • Recipe saves to Core Data
  • New recipe appears in list

3. Recipe Detail

  • All information displays correctly
  • Images show or placeholder appears
  • Favorite toggle works
  • Edit button opens edit sheet
  • Delete shows confirmation alert

4. Edit Recipe

  • Pre-fills with existing data
  • Changes save correctly
  • Updated recipe reflects in list

5. Network Integration

  • Random recipe fetches from API
  • Loading indicator shows
  • Error messages display if network fails
  • Fetched recipe saves to Core Data

6. Animations

  • Floating label animates smoothly
  • Category chips animate on selection
  • Sheet presentations are smooth
  • Navigation transitions work

Step 15: Common Issues & Solutions

Issue 1: Core Data Entity Not Found

Issue 1 Error Details

“Could not find entity named RecipeEntity”

Issue 1 Resolution Steps

  1. Open RecipeBook.xcdatamodeld
  2. Verify entity name is exactly “RecipeEntity”
  3. Clean build folder (Cmd + Shift + K)
  4. Rebuild project

Issue 2: Image Picker Not Showing

Issue 2 Error Details

App crashes or picker doesn’t appear

Issue 2 Resolution Steps

  1. Add to Info.plist:
<key>NSPhotoLibraryUsageDescription</key>
<string>We need access to select recipe photos</string>

Issue 3: Network Request Fails

Issue 3 Error Details

“The Internet connection appears to be offline”

Issue 3 Resolution Steps

  1. Check simulator/device has internet
  2. Add to Info.plist for HTTP requests:
<key>NSAppTransportSecurity</key>
<dict>
    <key>NSAllowsArbitraryLoads</key>
    <true/>
</dict>

Issue 4: Preview Crashes

Issue 4 Error Details

Preview fails to load

Issue 4 Resolution Steps

  1. Make sure PersistenceController.preview is set up correctly
  2. Check all required parameters are provided
  3. Use #Preview instead of older preview syntax

Required Entitlements for macOS Networking

When building the Mac target, enable the sandboxed network client entitlement so external API calls succeed.

File: RecipeBook.entitlements

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>com.apple.security.app-sandbox</key>
    <true/>
    <key>com.apple.security.network.client</key>
    <true/>
</dict>
</plist>

The com.apple.security.network.client flag grants outbound network access inside the sandbox; without it, macOS builds cannot reach TheMealDB API or other remote services.


Step 16: Customization Ideas

Easy Enhancements

  1. Add more categories: Smoothies, Salads, Soups
  2. Star rating: Add a 1-5 star rating to recipes
  3. Cooking timer: Built-in timer for cooking steps
  4. Shopping list: Generate shopping list from ingredients
  5. Dark mode testing: Test and polish dark mode appearance

Medium Enhancements

  1. Recipe sharing: Share recipes via share sheet
  2. Export to PDF: Generate PDF of recipe
  3. Ingredients scaling: Adjust ingredient amounts by servings
  4. Recipe notes: Add personal notes to recipes
  5. Meal planning: Plan meals for the week

Advanced Enhancements

  1. User accounts: Add Firebase authentication
  2. Cloud sync: Sync recipes across devices with CloudKit
  3. Recipe import: Import from URLs or other apps
  4. Nutrition info: Calculate calories and macros
  5. Social features: Share recipes with friends

Step 17: Key Learnings Summary

What You Built

A complete, production-ready recipe management app with:

  • 15+ custom SwiftUI views
  • Full CRUD functionality
  • Network integration
  • Persistent storage
  • Professional UI/UX
  • Animations and transitions

SwiftUI Skills Mastered

  1. State Management: All property wrappers in real scenarios
  2. Navigation: Type-safe navigation with NavigationStack
  3. Data Flow: MVVM architecture implementation
  4. Custom Views: Building reusable components
  5. UIKit Bridge: Integrating UIKit when needed
  6. Core Data: Complete database integration
  7. Networking: Async/await API calls
  8. Animations: Smooth, professional animations

Development Best Practices

  • Separation of concerns (MVVM)
  • Reusable components
  • Error handling
  • Code organization
  • Preview-driven development
  • Type safety

Step 18: Next Steps

Immediate Practice

  1. Extend the app: Add 2-3 features from customization ideas
  2. Refactor code: Improve organization, extract more components
  3. Add tests: Write unit tests for ViewModel
  4. Polish UI: Improve spacing, colors, animations

Build More Projects

Use these concepts to build:

  1. Todo App: Tasks, categories, reminders
  2. Expense Tracker: Income, expenses, charts
  3. Fitness Logger: Workouts, progress tracking
  4. Note Taking App: Rich text, folders, search
  5. Weather App: Location, forecasts, maps

Advanced Topics

  1. Combine Framework: Reactive programming
  2. SwiftUI App Lifecycle: Scene management
  3. Widgets: Home screen widgets
  4. App Clips: Lightweight app experiences
  5. App Store Submission: Prepare for release

Step 19: Complete File Structure Reference

Below is the full, modern file structure for the Recipe Book app. This layout follows best practices for SwiftUI, MVVM, and modular code organization. Each folder and file is purposefully named for clarity and scalability.

RecipeBook/
├── RecipeBookApp.swift                  # App entry point (main)
├── Info.plist                           # App configuration
├── RecipeBook.entitlements              # macOS/iOS entitlements
├── Assets.xcassets/                     # App icons & colors
│   ├── AppIcon.appiconset/
│   └── AccentColor.colorset/
├── Models/
│   └── Recipe.swift                     # Data models (Recipe, enums)
├── View Models/
│   └── RecipeViewModel.swift            # Business logic (MVVM)
├── Views/
│   ├── Components/
│   │   ├── FloatingLabelTextField.swift # Custom animated text field
│   │   ├── RecipeCard.swift             # Recipe card UI
│   │   ├── CategoryChip.swift           # Category filter chip
│   │   └── ImagePicker.swift            # UIKit image picker bridge
│   ├── RecipeList/
│   │   ├── RecipeListView.swift         # Main recipe list/grid
│   │   └── AddRecipeView.swift          # Add new recipe form
│   └── RecipeDetail/
│       ├── RecipeDetailView.swift       # Recipe detail screen
│       └── EditRecipeView.swift         # Edit recipe form
├── Services/
│   ├── PersistenceController.swift      # Core Data stack & helpers
│   └── NetworkService.swift             # API/networking logic
├── Utilities/
│   └── Common.swift                     # Shared colors, helpers
├── Resources/                           # (Optional) Static resources
├── RecipeBook.xcdatamodeld/
│   └── RecipeBook.xcdatamodel/          # Core Data model (XML)
│       └── contents
├── RecipeBookTests/
│   └── RecipeBookTests.swift            # Unit tests
├── RecipeBookUITests/
│   ├── RecipeBookUITests.swift          # UI tests
│   └── RecipeBookUITestsLaunchTests.swift
├── swiftui-project-guide/
│   └── swiftui-project-guide.md         # This project guide
└── README.md                            # Project overview & badges

Notes:

  • Folders are grouped by feature and responsibility (Models, View Models, Views, Services, Utilities).
  • Assets.xcassets holds all app icons and color sets.
  • RecipeBook.xcdatamodeld contains the Core Data schema (edit in Xcode’s model editor).
  • Resources/ is for any static files (images, JSON, etc.) you may add.
  • swiftui-project-guide.md is your living documentation.
  • All test targets are included for TDD and UI validation.

This structure is scalable for larger apps and easy to navigate for new contributors.


Congratulations

You’ve built a complete SwiftUI application from scratch! This project demonstrates:

  • Professional iOS Development - Industry-standard patterns and practices
  • Full Stack Skills - UI, business logic, data persistence, networking
  • Modern Swift - Latest SwiftUI features and async/await
  • Real-World Experience - Handling images, network errors, data validation

You now have:

  • A portfolio-ready project
  • Understanding of all core SwiftUI concepts
  • Foundation for building any iOS app
  • Code you can reference for future projects

Quiz Yourself

Challenge your SwiftUI and app architecture knowledge with these practical questions. Try to answer them before checking the tips below!

Core SwiftUI & MVVM

  1. State Management:
    • When should you use @State, @StateObject, @ObservedObject, and @Binding? Give a real example for each from this project.
  2. MVVM Separation:
    • How does the MVVM pattern help keep your code maintainable in this app? Which files are Model, View, and ViewModel?
  3. Navigation:
    • What are the benefits of using NavigationStack over the old NavigationView? How does it improve type safety?

UI & Components

  1. Grid vs. List:
    • Why is LazyVGrid used for the recipe list instead of a VStack or List? What are the trade-offs?
  2. Custom Components:
    • How would you make RecipeCard or CategoryChip even more reusable for other projects?
  3. Coordinator Pattern:
    • What problem does the Coordinator pattern solve in the ImagePicker? Where else might you use it?

Data & Networking

  1. Core Data Context:
    • What is an NSManagedObjectContext and why is it injected into SwiftUI views? How does it relate to data persistence?
  2. Async/Await:
    • What are the main benefits of using async/await for network calls in Swift? How does it improve code readability and error handling?
  3. Error Handling:
    • How does the app handle network or Core Data errors? Where could error handling be improved?

Advanced & Real-World

  1. Sheet vs. Navigation:
    • When should you use .sheet() for modal presentation vs. NavigationLink for navigation? Give a scenario for each from this app.
  2. Testing:
    • How would you write a unit test for adding a recipe? What would you mock?
  3. Performance:
    • What are some potential performance bottlenecks in this app, and how could you address them?
  4. Accessibility:
    • What steps would you take to make this app accessible to users with disabilities? Which SwiftUI features help with accessibility?
  5. App Architecture:
    • If you wanted to add user authentication or cloud sync, how would you refactor the current architecture to support these features?
  6. Dependency Injection:
    • How could you use dependency injection to make your ViewModels and Services more testable?
  7. App Lifecycle:
    • How does the SwiftUI app lifecycle differ from UIKit? What are the implications for state management and scene handling?
  8. Publishing to App Store:
    • What are the key steps and requirements for submitting this app to the App Store? What would you need to add or change?
  9. Interview Prep:
    • How would you explain the difference between value types and reference types in Swift, and why does it matter for SwiftUI?
  10. Concurrency:
    • What are the risks of updating UI from a background thread? How does SwiftUI help you avoid these issues?

Self-Check: Answers & Tips

  • @State: Local, simple value owned by a view (e.g., form text fields).
  • @StateObject: Owns a reference type (ViewModel) for the lifetime of a view.
  • @ObservedObject: Observes a reference type owned elsewhere (e.g., child view).
  • @Binding: Two-way connection to a value owned by a parent view.
  • MVVM: Model = Recipe.swift, View = all SwiftUI view files, ViewModel = RecipeViewModel.swift.
  • NavigationStack: Enables type-safe, programmatic navigation and deep linking.
  • LazyVGrid: Efficiently lays out many items in a grid, only rendering visible cells.
  • Coordinator: Bridges UIKit delegates to SwiftUI, needed for ImagePicker.
  • NSManagedObjectContext: The Core Data “scratchpad” for changes, injected for persistence.
  • async/await: Makes async code linear and readable, with built-in error handling.
  • Error Handling: Uses errorMessage in ViewModel; could be improved with user-friendly alerts and retry options.
  • .sheet(): Use for modal forms (e.g., Add/Edit Recipe); NavigationLink for drill-down navigation (e.g., Recipe Detail).
  • Testing: Use in-memory Core Data for tests, mock network responses.
  • Performance: Watch for large images, excessive Core Data fetches, and UI blocking on main thread.

Final Thoughts

Remember: The best way to learn is by building. Take this project, modify it, break it, fix it, and make it your own!

FAQ

What will I build by following this SwiftUI Recipe Book tutorial?
You will build a fully featured Recipe Book app for iOS and macOS that uses SwiftUI, Core Data, and modern architecture patterns to manage recipes, favorites, search, and cross-platform UI.
What Swift or SwiftUI knowledge do I need before starting this tutorial?
You should be comfortable with basic Swift syntax and have a beginner level understanding of SwiftUI views, state, and navigation. The tutorial explains the deeper patterns as you go, but it assumes you can navigate Xcode and run simple SwiftUI projects.

Welcome to The infinite monkey theorem

Somewhere a monkey just typed Shakespeare in TypeScript. Be the first to read the masterpieces (and the hilarious misfires) landing on the blog.

Subscribe to The infinite monkey theorem

We fling fresh posts—no banana peels attached—straight to your inbox.