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:
- Programmatic navigation - Path-based navigation with
NavigationPath - Type-safe destinations -
navigationDestination(for:)with Hashable types - Multiple destination types - Handle different types in one stack
- 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:
- Complex navigation flows - Multi-step wizards, conditional routing
- Deep linking - Need to construct navigation state from URLs
- Decoupling views - Views shouldn't know about the full navigation hierarchy
- Testing navigation - Want to test navigation logic separately
- 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:
- ViewModel publishes routes - View observes and handles navigation
- Coordinator/Router - Separate object manages navigation
- 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:
- Single source of truth - One place for data access logic
- Testability - Mock repositories for testing
- Flexibility - Swap implementations (API ↔ local, different APIs)
- 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 Size | Recommended Architecture |
|---|---|
| Simple (5-10 screens) | MVVM with inline navigation |
| Medium (10-30 screens) | MVVM + Coordinator |
| Large (30+ screens) | Clean Architecture + Coordinators |
| Enterprise | TCA 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
| Pattern | Use Case |
|---|---|
| NavigationStack | Modern programmatic navigation |
| NavigationPath | Type-erased navigation state |
| MVVM | Standard separation of concerns |
| Repository | Abstract data access |
| Coordinator | Complex navigation flows |
| Clean Architecture | Large 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.