Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

SwiftUI Navigation and Architecture Patterns

May 9, 2025 · 10 min read

iOS, SwiftUI, Architecture, MVVM, Interview Prep

Introduction

Navigation and architecture are where iOS interviews get serious. Interviewers want to see that you can structure complex applications, not just build screens. SwiftUI's navigation has matured significantly, and understanding the options - along with proven architecture patterns - separates senior developers from juniors.

Building a nutrition app with multiple user flows taught me that good architecture decisions early prevent painful refactors later.


NavigationStack: Modern SwiftUI Navigation

iOS 16 introduced NavigationStack, replacing the limited NavigationView:

struct FoodLogView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List(foods) { food in
                NavigationLink(value: food) {
                    FoodRow(food: food)
                }
            }
            .navigationDestination(for: Food.self) { food in
                FoodDetailView(food: food)
            }
            .navigationDestination(for: Meal.self) { meal in
                MealDetailView(meal: meal)
            }
            .navigationTitle("Food Log")
        }
    }
}

NavigationPath: Type-Erased Stack

@Observable
class Router {
    var path = NavigationPath()

    func navigateToFood(_ food: Food) {
        path.append(food)
    }

    func navigateToMeal(_ meal: Meal) {
        path.append(meal)
    }

    func popToRoot() {
        path.removeLast(path.count)
    }

    func pop() {
        guard !path.isEmpty else { return }
        path.removeLast()
    }
}

Type-Safe Navigation

For compile-time safety, use enums:

enum AppRoute: Hashable {
    case foodDetail(Food)
    case mealLog(Date)
    case settings
    case profile(userId: String)
}

struct ContentView: View {
    @State private var path: [AppRoute] = []

    var body: some View {
        NavigationStack(path: $path) {
            HomeView()
                .navigationDestination(for: AppRoute.self) { route in
                    switch route {
                    case .foodDetail(let food):
                        FoodDetailView(food: food)
                    case .mealLog(let date):
                        MealLogView(date: date)
                    case .settings:
                        SettingsView()
                    case .profile(let userId):
                        ProfileView(userId: userId)
                    }
                }
        }
    }
}

Deep Linking Support

With type-safe routes, deep linking becomes straightforward. Parse the URL, create the appropriate route enum, and push it onto the navigation path. This pattern served us well for notification-triggered navigation.


Sheet and Full-Screen Presentation

struct DashboardView: View {
    @State private var showingAddFood = false
    @State private var showingSettings = false
    @State private var selectedFood: Food?

    var body: some View {
        VStack {
            // Content
        }
        // Boolean-based sheet
        .sheet(isPresented: $showingAddFood) {
            AddFoodView()
        }
        // Item-based sheet (automatically dismisses when nil)
        .sheet(item: $selectedFood) { food in
            FoodDetailView(food: food)
        }
        // Full screen cover
        .fullScreenCover(isPresented: $showingSettings) {
            SettingsView()
        }
    }
}

Dismissal Handling

struct AddFoodView: View {
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            Form {
                // Form content
            }
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") {
                        dismiss()
                    }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        saveFood()
                        dismiss()
                    }
                }
            }
        }
    }
}

MVVM Architecture

MVVM (Model-View-ViewModel) is the most common architecture in SwiftUI apps:

// Model
struct FoodEntry: Identifiable, Codable {
    let id: UUID
    let food: Food
    let servings: Double
    let loggedAt: Date
}

// ViewModel
@Observable
class FoodLogViewModel {
    private let repository: FoodRepository

    var entries: [FoodEntry] = []
    var isLoading = false
    var errorMessage: String?

    var todayCalories: Int {
        entries.reduce(0) { $0 + Int($1.food.calories * $1.servings) }
    }

    init(repository: FoodRepository = FoodRepository()) {
        self.repository = repository
    }

    func loadEntries(for date: Date) async {
        isLoading = true
        errorMessage = nil

        do {
            entries = try await repository.fetchEntries(for: date)
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false
    }

    func addEntry(food: Food, servings: Double) async {
        let entry = FoodEntry(
            id: UUID(),
            food: food,
            servings: servings,
            loggedAt: Date()
        )

        do {
            try await repository.save(entry)
            entries.append(entry)
        } catch {
            errorMessage = "Failed to save entry"
        }
    }

    func deleteEntry(_ entry: FoodEntry) async {
        do {
            try await repository.delete(entry)
            entries.removeAll { $0.id == entry.id }
        } catch {
            errorMessage = "Failed to delete entry"
        }
    }
}

// View
struct FoodLogView: View {
    @State private var viewModel = FoodLogViewModel()
    @State private var selectedDate = Date()

    var body: some View {
        List {
            Section {
                CalorieSummaryCard(calories: viewModel.todayCalories)
            }

            Section("Today's Entries") {
                ForEach(viewModel.entries) { entry in
                    FoodEntryRow(entry: entry)
                }
                .onDelete { indexSet in
                    Task {
                        for index in indexSet {
                            await viewModel.deleteEntry(viewModel.entries[index])
                        }
                    }
                }
            }
        }
        .overlay {
            if viewModel.isLoading {
                ProgressView()
            }
        }
        .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
            Button("OK") { viewModel.errorMessage = nil }
        } message: {
            Text(viewModel.errorMessage ?? "")
        }
        .task {
            await viewModel.loadEntries(for: selectedDate)
        }
    }
}

MVVM in Practice

The key benefit of MVVM is testability. The ViewModel contains all business logic with no SwiftUI dependencies - you can unit test it thoroughly. In our app, ViewModels are where most bugs get caught before they reach the UI.


Repository Pattern

Abstract data access behind repositories:

protocol FoodRepositoryProtocol {
    func fetchEntries(for date: Date) async throws -> [FoodEntry]
    func save(_ entry: FoodEntry) async throws
    func delete(_ entry: FoodEntry) async throws
    func search(query: String) async throws -> [Food]
}

class FoodRepository: FoodRepositoryProtocol {
    private let apiClient: APIClient
    private let localStorage: LocalStorage

    init(apiClient: APIClient = .shared, localStorage: LocalStorage = .shared) {
        self.apiClient = apiClient
        self.localStorage = localStorage
    }

    func fetchEntries(for date: Date) async throws -> [FoodEntry] {
        // Try local first
        if let cached = try? await localStorage.fetchEntries(for: date) {
            return cached
        }

        // Fallback to API
        let entries = try await apiClient.get("/entries", query: ["date": date.iso8601])

        // Cache locally
        try? await localStorage.save(entries)

        return entries
    }

    func search(query: String) async throws -> [Food] {
        try await apiClient.get("/foods/search", query: ["q": query])
    }

    // ... other methods
}

Testing with Mock Repositories

class MockFoodRepository: FoodRepositoryProtocol {
    var mockEntries: [FoodEntry] = []
    var shouldThrowError = false

    func fetchEntries(for date: Date) async throws -> [FoodEntry] {
        if shouldThrowError {
            throw NSError(domain: "Test", code: 1)
        }
        return mockEntries
    }

    // ... mock implementations
}

// In tests
func testLoadEntriesSuccess() async {
    let mockRepo = MockFoodRepository()
    mockRepo.mockEntries = [FoodEntry.sample]

    let viewModel = FoodLogViewModel(repository: mockRepo)
    await viewModel.loadEntries(for: Date())

    XCTAssertEqual(viewModel.entries.count, 1)
    XCTAssertFalse(viewModel.isLoading)
}

Coordinator Pattern

For complex navigation flows, the Coordinator pattern centralizes navigation logic:

@Observable
class AppCoordinator {
    var path = NavigationPath()
    var sheet: Sheet?
    var fullScreenCover: FullScreenCover?

    enum Sheet: Identifiable {
        case addFood
        case editFood(Food)
        case settings

        var id: String {
            switch self {
            case .addFood: return "addFood"
            case .editFood(let food): return "editFood-\(food.id)"
            case .settings: return "settings"
            }
        }
    }

    enum FullScreenCover: Identifiable {
        case onboarding
        case camera

        var id: String {
            switch self {
            case .onboarding: return "onboarding"
            case .camera: return "camera"
            }
        }
    }

    // Navigation Actions
    func showFoodDetail(_ food: Food) {
        path.append(food)
    }

    func showAddFood() {
        sheet = .addFood
    }

    func showCamera() {
        fullScreenCover = .camera
    }

    func dismissSheet() {
        sheet = nil
    }

    func dismissFullScreen() {
        fullScreenCover = nil
    }

    func popToRoot() {
        path.removeLast(path.count)
    }
}

// Root View
struct RootView: View {
    @State private var coordinator = AppCoordinator()

    var body: some View {
        NavigationStack(path: $coordinator.path) {
            DashboardView()
                .navigationDestination(for: Food.self) { food in
                    FoodDetailView(food: food)
                }
                .navigationDestination(for: Meal.self) { meal in
                    MealDetailView(meal: meal)
                }
        }
        .sheet(item: $coordinator.sheet) { sheet in
            switch sheet {
            case .addFood:
                AddFoodView()
            case .editFood(let food):
                EditFoodView(food: food)
            case .settings:
                SettingsView()
            }
        }
        .fullScreenCover(item: $coordinator.fullScreenCover) { cover in
            switch cover {
            case .onboarding:
                OnboardingView()
            case .camera:
                CameraView()
            }
        }
        .environment(coordinator)
    }
}

Clean Architecture Layers

For larger apps, Clean Architecture provides clear separation:

┌─────────────────────────────────────────────┐
│           Presentation Layer                │
│    (Views, ViewModels, Coordinators)        │
├─────────────────────────────────────────────┤
│              Domain Layer                   │
│     (Use Cases, Entities, Protocols)        │
├─────────────────────────────────────────────┤
│               Data Layer                    │
│  (Repositories, API, Local Storage)         │
└─────────────────────────────────────────────┘
// Domain Layer - Use Case
protocol LogFoodUseCaseProtocol {
    func execute(food: Food, servings: Double) async throws -> FoodEntry
}

class LogFoodUseCase: LogFoodUseCaseProtocol {
    private let repository: FoodRepositoryProtocol
    private let nutritionCalculator: NutritionCalculator

    init(repository: FoodRepositoryProtocol, nutritionCalculator: NutritionCalculator) {
        self.repository = repository
        self.nutritionCalculator = nutritionCalculator
    }

    func execute(food: Food, servings: Double) async throws -> FoodEntry {
        // Validate
        guard servings > 0 else {
            throw LogFoodError.invalidServings
        }

        // Calculate nutrition
        let nutrition = nutritionCalculator.calculate(food: food, servings: servings)

        // Create entry
        let entry = FoodEntry(
            id: UUID(),
            food: food,
            servings: servings,
            nutrition: nutrition,
            loggedAt: Date()
        )

        // Persist
        try await repository.save(entry)

        return entry
    }
}

Interview Questions

Q1: Explain the difference between NavigationStack and NavigationView.

Answer: NavigationStack (iOS 16+) replaces NavigationView with:

  1. Programmatic navigation - Path-based navigation with NavigationPath
  2. Type-safe destinations - navigationDestination(for:) with Hashable types
  3. Multiple destination types - Handle different types in one stack
  4. Deep linking support - Easily push multiple screens programmatically

NavigationView was limited to basic push navigation with NavigationLink and couldn't handle complex navigation flows programmatically.

Q2: When would you use the Coordinator pattern?

Answer: Use Coordinator when:

  1. Complex navigation flows - Multi-step wizards, conditional routing
  2. Deep linking - Need to construct navigation state from URLs
  3. Decoupling views - Views shouldn't know about the full navigation hierarchy
  4. Testing navigation - Want to test navigation logic separately
  5. Reusing flows - Same navigation logic across different entry points

For simple apps, navigation directly in views is fine. Coordinator adds value in complex apps.

Q3: How do you handle navigation in MVVM?

Answer: Several approaches:

  1. ViewModel publishes routes - View observes and handles navigation
  2. Coordinator/Router - Separate object manages navigation
  3. Navigation state in ViewModel - ViewModel exposes path/sheet state
// Option 1: Published routes
@Observable class ViewModel {
    var navigateTo: Route?
}

// Option 2: Coordinator
class Coordinator {
    func showDetail(_ item: Item) { path.append(item) }
}

// Option 3: Navigation state
@Observable class ViewModel {
    var path = NavigationPath()
    var presentedSheet: Sheet?
}

The key: ViewModels shouldn't import SwiftUI. Use protocols or type-erased types.

Q4: What's the Repository pattern and why use it?

Answer: Repository abstracts data access behind a clean interface:

Benefits:

  1. Single source of truth - One place for data access logic
  2. Testability - Mock repositories for testing
  3. Flexibility - Swap implementations (API ↔ local, different APIs)
  4. Caching logic - Repository decides cache strategy
protocol FoodRepository {
    func search(query: String) async throws -> [Food]
}

class RealRepository: FoodRepository { /* API calls */ }
class MockRepository: FoodRepository { /* Test data */ }

Common Mistakes

1. Navigation in ViewModel Body

// ❌ ViewModel imports SwiftUI
class ViewModel: ObservableObject {
    func navigate() {
        NavigationLink(...) // Wrong!
    }
}

// ✅ ViewModel exposes state, View handles navigation
@Observable class ViewModel {
    var selectedItem: Item?
}

2. Tight Coupling

// ❌ View knows about specific next screen
struct FoodListView: View {
    var body: some View {
        NavigationLink {
            FoodDetailView(food: food, repository: FoodRepository())
        }
    }
}

// ✅ Decouple with navigationDestination
struct FoodListView: View {
    var body: some View {
        NavigationLink(value: food) {
            FoodRow(food: food)
        }
    }
}

3. Forgetting Dismiss Environment

// ❌ Trying to dismiss without environment
struct SheetView: View {
    var dismiss: (() -> Void)? // Passed from parent
}

// ✅ Use environment
struct SheetView: View {
    @Environment(\.dismiss) private var dismiss
}

Architecture Decision Guide

App SizeRecommended Architecture
Simple (5-10 screens)MVVM with inline navigation
Medium (10-30 screens)MVVM + Coordinator
Large (30+ screens)Clean Architecture + Coordinators
EnterpriseTCA or modular Clean Architecture

Architecture Evolution

We started with basic MVVM and added patterns as complexity grew. Don't over-architect early. Start simple, refactor when pain appears. The best architecture is one your team understands and can maintain.


Summary

PatternUse Case
NavigationStackModern programmatic navigation
NavigationPathType-erased navigation state
MVVMStandard separation of concerns
RepositoryAbstract data access
CoordinatorComplex navigation flows
Clean ArchitectureLarge apps with clear layers

Navigation and architecture choices depend on app complexity and team size. Know all the patterns, apply the simplest one that solves your problem.


Part 3 of the iOS Interview Prep series.

← Previous

iOS Networking with Async/Await and Actors

Next →

SwiftUI State Management Deep Dive