A step-by-step guide to building a modern SwiftUI Recipe Book app for iOS and macOS.
SwiftUI Recipe Book App
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
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
- Open Xcode
- File → New → Project
- Choose “App” template (Multiplatform)
- Name it “RecipeBook”
- Select SwiftUI interface and Swift language
- Important: Check “Use Core Data” ✓
- Create the project
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
- Open
RecipeBook.xcdatamodeld - Click ”+” to add a new entity, name it
RecipeEntity - Add these attributes:
| Attribute | Type | Optional |
|---|---|---|
| id | UUID | No |
| title | String | No |
| recipeDescription | String | No |
| ingredients | String | No |
| instructions | String | No |
| prepTime | Integer 16 | No |
| cookTime | Integer 16 | No |
| servings | Integer 16 | No |
| category | String | No |
| difficulty | String | No |
| isFavorite | Boolean | No |
| imageData | Binary Data | Yes |
| sourceURL | String | Yes |
| createdAt | Date | No |
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()
}
#endifComponent 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()
}
#endifComponent 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()
}
#endifStep 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
}
}
}
#endifStep 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
- Select a simulator or device (iPhone 15 Pro recommended)
- Press Cmd + B to build
- Press Cmd + R to run
- 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
Navigation & Layout
NavigationStackwith type-safe navigationListwithForEachand.onDeleteLazyVGridfor recipe grid.sheet()for modal presentation.alert()for confirmationsScrollViewwith lazy loading
MVVM Architecture
- Model:
Recipe,RecipeCategory,Difficulty - View: All view files
- ViewModel:
RecipeViewModelwith@Publishedproperties
Custom Components
FloatingLabelTextField: Reusable animated text fieldRecipeCard: Complex card with image, metadata, and interactionsCategoryChip: 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/awaitfor 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
- Open
RecipeBook.xcdatamodeld - Verify entity name is exactly “RecipeEntity”
- Clean build folder (Cmd + Shift + K)
- Rebuild project
Issue 2: Image Picker Not Showing
Issue 2 Error Details
App crashes or picker doesn’t appear
Issue 2 Resolution Steps
- 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
- Check simulator/device has internet
- Add to
Info.plistfor 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
- Make sure
PersistenceController.previewis set up correctly - Check all required parameters are provided
- Use
#Previewinstead 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
- Add more categories: Smoothies, Salads, Soups
- Star rating: Add a 1-5 star rating to recipes
- Cooking timer: Built-in timer for cooking steps
- Shopping list: Generate shopping list from ingredients
- Dark mode testing: Test and polish dark mode appearance
Medium Enhancements
- Recipe sharing: Share recipes via share sheet
- Export to PDF: Generate PDF of recipe
- Ingredients scaling: Adjust ingredient amounts by servings
- Recipe notes: Add personal notes to recipes
- Meal planning: Plan meals for the week
Advanced Enhancements
- User accounts: Add Firebase authentication
- Cloud sync: Sync recipes across devices with CloudKit
- Recipe import: Import from URLs or other apps
- Nutrition info: Calculate calories and macros
- 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
- State Management: All property wrappers in real scenarios
- Navigation: Type-safe navigation with NavigationStack
- Data Flow: MVVM architecture implementation
- Custom Views: Building reusable components
- UIKit Bridge: Integrating UIKit when needed
- Core Data: Complete database integration
- Networking: Async/await API calls
- 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
- Extend the app: Add 2-3 features from customization ideas
- Refactor code: Improve organization, extract more components
- Add tests: Write unit tests for ViewModel
- Polish UI: Improve spacing, colors, animations
Build More Projects
Use these concepts to build:
- Todo App: Tasks, categories, reminders
- Expense Tracker: Income, expenses, charts
- Fitness Logger: Workouts, progress tracking
- Note Taking App: Rich text, folders, search
- Weather App: Location, forecasts, maps
Advanced Topics
- Combine Framework: Reactive programming
- SwiftUI App Lifecycle: Scene management
- Widgets: Home screen widgets
- App Clips: Lightweight app experiences
- 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 & badgesNotes:
- Folders are grouped by feature and responsibility (Models, View Models, Views, Services, Utilities).
Assets.xcassetsholds all app icons and color sets.RecipeBook.xcdatamodeldcontains 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.mdis 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
- State Management:
- When should you use
@State,@StateObject,@ObservedObject, and@Binding? Give a real example for each from this project.
- When should you use
- MVVM Separation:
- How does the MVVM pattern help keep your code maintainable in this app? Which files are Model, View, and ViewModel?
- Navigation:
- What are the benefits of using
NavigationStackover the oldNavigationView? How does it improve type safety?
- What are the benefits of using
UI & Components
- Grid vs. List:
- Why is
LazyVGridused for the recipe list instead of aVStackorList? What are the trade-offs?
- Why is
- Custom Components:
- How would you make
RecipeCardorCategoryChipeven more reusable for other projects?
- How would you make
- Coordinator Pattern:
- What problem does the Coordinator pattern solve in the
ImagePicker? Where else might you use it?
- What problem does the Coordinator pattern solve in the
Data & Networking
- Core Data Context:
- What is an
NSManagedObjectContextand why is it injected into SwiftUI views? How does it relate to data persistence?
- What is an
- Async/Await:
- What are the main benefits of using
async/awaitfor network calls in Swift? How does it improve code readability and error handling?
- What are the main benefits of using
- Error Handling:
- How does the app handle network or Core Data errors? Where could error handling be improved?
Advanced & Real-World
- Sheet vs. Navigation:
- When should you use
.sheet()for modal presentation vs.NavigationLinkfor navigation? Give a scenario for each from this app.
- When should you use
- Testing:
- How would you write a unit test for adding a recipe? What would you mock?
- Performance:
- What are some potential performance bottlenecks in this app, and how could you address them?
- Accessibility:
- What steps would you take to make this app accessible to users with disabilities? Which SwiftUI features help with accessibility?
- App Architecture:
- If you wanted to add user authentication or cloud sync, how would you refactor the current architecture to support these features?
- Dependency Injection:
- How could you use dependency injection to make your ViewModels and Services more testable?
- App Lifecycle:
- How does the SwiftUI app lifecycle differ from UIKit? What are the implications for state management and scene handling?
- 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?
- Interview Prep:
- How would you explain the difference between value types and reference types in Swift, and why does it matter for SwiftUI?
- 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
errorMessagein 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.

