Skip to content

Latest commit

 

History

History
812 lines (619 loc) · 19.2 KB

File metadata and controls

812 lines (619 loc) · 19.2 KB

MVVM Architecture

Overview

The MVVM (Model-View-ViewModel) implementation demonstrates a professional architecture pattern for building maintainable, testable SwiftUI applications. It separates concerns into three distinct layers: Models, Views, and ViewModels.

Architecture Diagram

graph TB
    subgraph "View Layer"
        V1[MVVMContentView]
        V2[MVVMSidebarView]
        V3[MVVMContentListView]
        V4[MVVMDetailView]
        V5[MVVMSettingsContentView]
    end

    subgraph "ViewModel Layer"
        VM1["MVVMAppViewModel
        @Observable"]
        VM2["MVVMSidebarViewModel
        @Observable"]
        VM3["MVVMContentListViewModel
        @Observable"]
        VM4["MVVMSettingsViewModel
        @Observable"]
    end

    subgraph "Model Layer"
        M1["MVVMListItem
        struct"]
        M2["MVVMSidebarCategory
        enum"]
    end

    V1 -- "@State" --> VM1
    V2 -- "reads" --> VM2
    V3 -- "reads" --> VM3
    V5 -- "@Bindable" --> VM4

    VM1 -- "owns" --> VM2
    VM1 -- "owns" --> VM3
    VM1 -- "owns" --> VM4

    VM3 -- "manages" --> M1
    VM2 -- "uses" --> M2
    VM1 -- "uses" --> M2

    style VM1 fill:#e1f5ff
    style VM2 fill:#e1f5ff
    style VM3 fill:#e1f5ff
    style VM4 fill:#e1f5ff
    style M1 fill:#fff4e1
    style M2 fill:#fff4e1
Loading

When to Use MVVM

Use MVVM when:

  • Building production applications
  • App has complex business logic
  • You need unit testing
  • Multiple views share state
  • Team wants clear separation of concerns
  • App will grow over time

Consider simpler patterns when:

  • Building quick prototypes
  • App is very simple
  • Learning SwiftUI basics
  • No testing requirements

Core Concepts

The @Observable Macro

The @Observable macro is Swift's modern way to make classes observable:

@Observable
class MyViewModel {
    var count = 0  // No @Published needed!
}

What it does:

  • Makes all properties automatically observable
  • SwiftUI views update when properties they read change
  • Compiles to efficient code at build time
  • No runtime overhead from Combine framework

How it works:

  1. Macro generates tracking code at compile time
  2. When a property changes, SwiftUI is notified
  3. Only views that read the changed property update
  4. This is more precise than older approaches

The @MainActor Attribute

@MainActor
class MyViewModel {
    // All operations happen on the main thread
}

What it does:

  • Ensures all code runs on the main thread
  • Required for UI updates in SwiftUI
  • Prevents threading issues
  • Compiler enforces thread safety

Property Wrappers in MVVM

graph LR
    A["@State"] -- "Creates & Owns" --> B[ViewModel]
    C["Plain Property"] -- "Passed from Parent" --> B
    D["@Bindable"] -- "Two-way Binding" --> B

    style A fill:#90EE90
    style C fill:#FFB6C1
    style D fill:#87CEEB
Loading

File Structure

Examples/MVVM/
├── Models/                        # Pure data structures
│   ├── MVVMSidebarCategory.swift  # Enum defining categories
│   └── MVVMListItem.swift         # Data model for items
│
├── ViewModels/                    # Business logic & state
│   ├── MVVMAppViewModel.swift     # Main coordinator
│   ├── MVVMSidebarViewModel.swift # Sidebar logic
│   ├── MVVMContentListViewModel.swift  # List management
│   └── MVVMSettingsViewModel.swift     # Settings logic
│
└── Views/                         # UI components
    ├── MVVMContentView.swift      # Root view
    ├── MVVMSidebarView.swift      # Sidebar (first pane)
    ├── MVVMContentListView.swift  # Content list (second pane)
    ├── MVVMDetailView.swift       # Detail (third pane)
    ├── MVVMSettingsContentView.swift  # Settings form
    ├── MVVMOverviewTab.swift      # Detail tab
    ├── MVVMDetailsTab.swift       # Detail tab
    ├── MVVMOptionsTab.swift       # Detail tab
    ├── MVVMInfoRow.swift          # Reusable component
    └── MVVMDetailRow.swift        # Reusable component

Data Flow

sequenceDiagram
    actor User
    participant View
    participant ViewModel
    participant Model

    User->>View: Taps "Add Item"
    View->>ViewModel: addItem(to: category)
    ViewModel->>Model: Create MVVMListItem
    ViewModel->>ViewModel: Update allItems
    Note over ViewModel: @Observable triggers update
    ViewModel-->>View: Automatic UI update
    View-->>User: Shows new item
Loading

Layer Details

1. Models (Data)

Purpose: Pure data structures with no logic

MVVMListItem.swift:

struct MVVMListItem: Identifiable, Hashable {
    let id = UUID()
    let title: String
    let subtitle: String
    let status: String
    let createdDate: String
    let modifiedDate: String
}

Characteristics:

  • Immutable where possible (let)
  • Conforms to protocols (Identifiable, Hashable)
  • No business logic
  • No dependencies on other layers
  • Easy to test

MVVMSidebarCategory.swift:

enum MVVMSidebarCategory: String, CaseIterable {
    case category1 = "Category 1"
    case category2 = "Category 2"
    case category3 = "Category 3"
    case settings = "Settings"

    var icon: String { /* ... */ }
    var showsItemList: Bool { /* ... */ }
}

2. ViewModels (Logic)

Purpose: Manage state and business logic

MVVMAppViewModel - Main Coordinator

@Observable
@MainActor
class MVVMAppViewModel {
    // State
    var selectedCategory: MVVMSidebarCategory? = .category1
    var selectedItem: MVVMListItem? = nil

    // Child ViewModels
    let sidebarViewModel = MVVMSidebarViewModel()
    let contentListViewModel = MVVMContentListViewModel()
    let settingsViewModel = MVVMSettingsViewModel()

    // Computed Properties
    var showsItemList: Bool {
        selectedCategory?.showsItemList ?? false
    }
}

Responsibilities:

  • Coordinate between different parts of the app
  • Own and manage child ViewModels
  • Provide computed properties for derived state
  • Handle state changes (e.g., didSet)

Key Code Explained:

var selectedCategory: MVVMSidebarCategory? = .category1 {
    didSet {
        selectedItem = nil
    }
}

What this does:

  • var makes it mutable (can change)
  • ? means it's optional (can be nil)
  • = .category1 sets the initial value
  • didSet runs code after the value changes
  • selectedItem = nil clears the selection when category changes
var showsItemList: Bool {
    selectedCategory?.showsItemList ?? false
}

What this does:

  • ?. (optional chaining) safely accesses showsItemList if selectedCategory exists
  • If selectedCategory is nil, it doesn't try to access showsItemList
  • ?? (nil-coalescing) provides a default value (false) if the left side is nil
  • Result: Returns true if category exists and shows a list, otherwise false

MVVMContentListViewModel - Data Management

@Observable
@MainActor
class MVVMContentListViewModel {
    private(set) var allItems: [MVVMSidebarCategory: [MVVMListItem]] = [...]

    func items(for category: MVVMSidebarCategory) -> [MVVMListItem] {
        allItems[category] ?? []
    }

    func addItem(to category: MVVMSidebarCategory) {
        let newItem = MVVMListItem(...)
        allItems[category, default: []].append(newItem)
    }

    func deleteItem(_ item: MVVMListItem, from category: MVVMSidebarCategory) {
        allItems[category]?.removeAll { $0.id == item.id }
    }
}

Responsibilities:

  • Store and manage data
  • Provide data access methods
  • Handle CRUD operations
  • Encapsulate data mutations

Key Code Explained:

private(set) var allItems: [MVVMSidebarCategory: [MVVMListItem]]

What this does:

  • private(set) means only this class can modify it, but others can read it
  • Dictionary type [Key: Value] maps categories to arrays of items
  • This encapsulation prevents accidental data corruption
allItems[category, default: []].append(newItem)

What this does:

  • [category, default: []] gets the array for the category
  • If no array exists, uses empty array [] as default
  • .append(newItem) adds the item to the array
  • This safely adds items even if category doesn't exist yet

3. Views (UI)

Purpose: Display UI and respond to user interaction

MVVMContentView - Root View

struct MVVMContentView: View {
    @State private var viewModel = MVVMAppViewModel()

    var body: some View {
        NavigationSplitView {
            MVVMSidebarView(
                selectedCategory: $viewModel.selectedCategory,
                viewModel: viewModel.sidebarViewModel
            )
        } content: {
            if let category = viewModel.selectedCategory {
                if category.showsItemList {
                    MVVMContentListView(
                        category: category,
                        selectedItem: $viewModel.selectedItem,
                        viewModel: viewModel.contentListViewModel
                    )
                } else {
                    MVVMSettingsContentView(
                        viewModel: viewModel.settingsViewModel
                    )
                }
            }
        } detail: {
            if viewModel.showsItemList {
                MVVMDetailView(selectedItem: viewModel.selectedItem)
            }
        }
    }
}

Key Code Explained:

@State private var viewModel = MVVMAppViewModel()

What this does:

  • @State tells SwiftUI to track this value
  • When properties in viewModel change, view updates
  • private means only this view can access it
  • Creates one instance that persists across view updates
$viewModel.selectedCategory

What this does:

  • $ creates a binding (two-way connection)
  • Child view can read AND write this value
  • Changes in child automatically update parent
  • Changes in parent automatically update child
if let category = viewModel.selectedCategory {
    // Use category here
}

What this does:

  • if let safely unwraps optional
  • Only executes if selectedCategory is not nil
  • category is the unwrapped value (not optional inside block)

MVVMContentListView - List Display

struct MVVMContentListView: View {
    let category: MVVMSidebarCategory
    @Binding var selectedItem: MVVMListItem?
    var viewModel: MVVMContentListViewModel

    var body: some View {
        List(viewModel.items(for: category), selection: $selectedItem) { item in
            NavigationLink(value: item) {
                VStack(alignment: .leading, spacing: 4) {
                    Text(item.title).font(.headline)
                    Text(item.subtitle).font(.subheadline)
                }
            }
            .swipeActions {
                Button(role: .destructive) {
                    viewModel.deleteItem(item, from: category)
                } label: {
                    Label("Delete", systemImage: "trash")
                }
            }
        }
        .toolbar {
            Button {
                viewModel.addItem(to: category)
            } label: {
                Label("Add Item", systemImage: "plus.circle")
            }
        }
    }
}

Key Code Explained:

let category: MVVMSidebarCategory

What this does:

  • let means it's read-only (constant)
  • Passed from parent view
  • Can't be changed by this view
@Binding var selectedItem: MVVMListItem?

What this does:

  • @Binding creates two-way connection to parent's state
  • This view can read and write the value
  • Changes here update the parent automatically
  • ? means it's optional (can be nil)
var viewModel: MVVMContentListViewModel

What this does:

  • Plain property (no wrapper needed!)
  • With @Observable, SwiftUI automatically observes it
  • View updates when properties it reads change
  • Passed from parent view

MVVMSettingsContentView - Form with Bindings

struct MVVMSettingsContentView: View {
    @Bindable var viewModel: MVVMSettingsViewModel

    var body: some View {
        Form {
            Toggle("Dark Mode", isOn: $viewModel.isDarkModeEnabled)
            Slider(value: $viewModel.fontSize, in: 10...24)

            Button("Save", action: viewModel.saveSettings)
        }
    }
}

Key Code Explained:

@Bindable var viewModel: MVVMSettingsViewModel

What this does:

  • @Bindable allows creating bindings to ViewModel properties
  • Needed when you want to use $viewModel.property syntax
  • Creates two-way connections for controls like TextField, Toggle, Slider
$viewModel.isDarkModeEnabled

What this does:

  • $ creates a binding to the ViewModel property
  • Toggle can read and write the value
  • Changes update the ViewModel immediately
  • ViewModel's @Observable triggers view updates

Common Patterns

Pattern 1: Coordinator ViewModel

graph TB
    A["MVVMAppViewModel
    Coordinator"] --> B[MVVMSidebarViewModel]
    A --> C[MVVMContentListViewModel]
    A --> D[MVVMSettingsViewModel]

    style A fill:#FFD700
    style B fill:#87CEEB
    style C fill:#87CEEB
    style D fill:#87CEEB
Loading

Benefits:

  • Single source of truth
  • Clear ownership hierarchy
  • Easy to reason about state
  • Testable coordination logic

Pattern 2: Computed Properties

@Observable
class SearchViewModel {
    var searchText = ""
    var allItems: [Item] = []

    // Automatically recomputes when dependencies change
    var filteredItems: [Item] {
        if searchText.isEmpty {
            return allItems
        }
        return allItems.filter { $0.name.contains(searchText) }
    }
}

Benefits:

  • No manual update logic
  • Always in sync
  • Easy to understand
  • Efficient (only recomputes when accessed)

Pattern 3: Methods for Actions

@Observable
class ListViewModel {
    var items: [Item] = []

    func addItem(_ item: Item) {
        items.append(item)
        // Could also: save to database, sync with server, etc.
    }

    func deleteItem(_ item: Item) {
        items.removeAll { $0.id == item.id }
        // Could also: update database, notify server, etc.
    }
}

Benefits:

  • Encapsulates logic
  • Easy to test
  • Can add side effects (logging, analytics, etc.)
  • Clear API for views

Testing ViewModels

ViewModels are easy to test because they're just Swift classes:

import Testing
@testable import SwiftUIExamples

@Test func testAddItem() {
    // Arrange
    let viewModel = MVVMContentListViewModel()
    let initialCount = viewModel.items(for: .category1).count

    // Act
    viewModel.addItem(to: .category1)

    // Assert
    #expect(viewModel.items(for: .category1).count == initialCount + 1)
}

@Test func testCategoryChange() {
    // Arrange
    let viewModel = MVVMAppViewModel()
    let item = MVVMListItem(title: "Test", subtitle: "Test")
    viewModel.selectedItem = item

    // Act
    viewModel.selectedCategory = .category2

    // Assert
    #expect(viewModel.selectedItem == nil)
}

@Test func testDeleteItem() {
    // Arrange
    let viewModel = MVVMContentListViewModel()
    let items = viewModel.items(for: .category1)
    let itemToDelete = items.first!

    // Act
    viewModel.deleteItem(itemToDelete, from: .category1)

    // Assert
    #expect(!viewModel.items(for: .category1).contains(itemToDelete))
}

Advantages

✅ Testability

  • ViewModels are pure Swift classes
  • Easy to write unit tests
  • No UI testing needed for logic
  • Fast test execution

✅ Separation of Concerns

  • Models: Data
  • ViewModels: Logic
  • Views: UI
  • Each layer has one responsibility

✅ Reusability

  • ViewModels can be shared between views
  • Business logic is decoupled from UI
  • Easy to create variations

✅ Maintainability

  • Changes are localized
  • Easy to find code
  • Clear dependencies
  • Scales well

✅ Team Collaboration

  • Multiple people can work on different layers
  • Clear contracts between layers
  • Less merge conflicts

Trade-offs

More Files

  • More structure means more files
  • Need to navigate between files
  • Mitigation: Good naming and organization

Learning Curve

  • Need to understand MVVM pattern
  • More concepts to learn
  • Mitigation: Good documentation and examples

Initial Overhead

  • Takes longer to set up
  • Mitigation: Worth it for medium+ sized apps

Swift Operators Explained

Optional Chaining (?.)

let icon = category?.icon  // Returns Optional<String>

What it does:

  • Safely access properties on optionals
  • If category is nil, returns nil
  • If category exists, returns its icon
  • No crash if optional is nil

Nil-Coalescing (??)

let icon = category?.icon ?? "circle"  // Returns String

What it does:

  • Provides a default value when optional is nil
  • If left side is nil, returns right side
  • If left side has a value, returns that value
  • Converts Optional<String> to String

Logical AND (&&)

if isEnabled && hasData {
    // Both must be true
}

What it does:

  • Returns true only if both sides are true
  • Short-circuits: if left is false, doesn't check right
  • Used for compound conditions

Property Observers (didSet)

var value = 0 {
    didSet {
        print("Changed from \(oldValue) to \(value)")
    }
}

What it does:

  • Runs code after property changes
  • Can access oldValue (previous value)
  • Useful for side effects
  • Can trigger other updates

Best Practices

DO

✅ Keep ViewModels focused on one concern ✅ Use @Observable for ViewModels ✅ Use @MainActor for UI-related ViewModels ✅ Make ViewModels testable (no UI dependencies) ✅ Use computed properties for derived state ✅ Encapsulate data mutations in methods ✅ Use private(set) for data that shouldn't be modified externally

DON'T

❌ Put UI code in ViewModels ❌ Make ViewModels depend on SwiftUI ❌ Create ViewModels for every view (only when needed) ❌ Mix business logic in views ❌ Make properties mutable when they don't need to be ❌ Forget to use @MainActor for UI ViewModels

Migration from Views Only

If you started with the Views Only pattern, here's how to migrate:

graph LR
    A[Views Only] -- "1. Extract Models" --> B[Separate Data]
    B -- "2. Create ViewModels" --> C[Add Logic Layer]
    C -- "3. Update Views" --> D[MVVM Complete]

    style A fill:#FFB6C1
    style D fill:#90EE90
Loading
  1. Extract Models: Move data structures to separate files
  2. Create ViewModels: Move state and logic from views to ViewModels
  3. Update Views: Use @State for ViewModels, remove business logic
  4. Add Tests: Write unit tests for ViewModels

Summary

MVVM provides:

  • Clear Separation: Each layer has distinct responsibilities
  • Testability: Business logic can be unit tested
  • Scalability: Easy to extend as app grows
  • Maintainability: Changes are localized and predictable
  • Modern Swift: Uses @Observable and other modern features

This architecture is ideal for production SwiftUI applications where maintainability, testability, and scalability are priorities.

Next Steps:

  • Explore the code in Examples/MVVM/
  • Read the inline comments in each file
  • Try writing tests for the ViewModels
  • Compare with the Views Only implementation
  • Experiment with adding new features