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.
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
✅ 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
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:
- Macro generates tracking code at compile time
- When a property changes, SwiftUI is notified
- Only views that read the changed property update
- This is more precise than older approaches
@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
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
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
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
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 { /* ... */ }
}Purpose: Manage state and business logic
@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:
varmakes it mutable (can change)?means it's optional (can benil)= .category1sets the initial valuedidSetruns code after the value changesselectedItem = nilclears the selection when category changes
var showsItemList: Bool {
selectedCategory?.showsItemList ?? false
}What this does:
?.(optional chaining) safely accessesshowsItemListifselectedCategoryexists- If
selectedCategoryisnil, it doesn't try to accessshowsItemList ??(nil-coalescing) provides a default value (false) if the left side isnil- Result: Returns
trueif category exists and shows a list, otherwisefalse
@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
Purpose: Display UI and respond to user interaction
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:
@Statetells SwiftUI to track this value- When properties in
viewModelchange, view updates privatemeans only this view can access it- Creates one instance that persists across view updates
$viewModel.selectedCategoryWhat 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 letsafely unwraps optional- Only executes if
selectedCategoryis notnil categoryis the unwrapped value (not optional inside block)
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: MVVMSidebarCategoryWhat this does:
letmeans it's read-only (constant)- Passed from parent view
- Can't be changed by this view
@Binding var selectedItem: MVVMListItem?What this does:
@Bindingcreates 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 benil)
var viewModel: MVVMContentListViewModelWhat this does:
- Plain property (no wrapper needed!)
- With
@Observable, SwiftUI automatically observes it - View updates when properties it reads change
- Passed from parent view
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: MVVMSettingsViewModelWhat this does:
@Bindableallows creating bindings to ViewModel properties- Needed when you want to use
$viewModel.propertysyntax - Creates two-way connections for controls like
TextField,Toggle,Slider
$viewModel.isDarkModeEnabledWhat this does:
$creates a binding to the ViewModel propertyTogglecan read and write the value- Changes update the ViewModel immediately
- ViewModel's
@Observabletriggers view updates
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
Benefits:
- Single source of truth
- Clear ownership hierarchy
- Easy to reason about state
- Testable coordination logic
@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)
@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
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))
}- ViewModels are pure Swift classes
- Easy to write unit tests
- No UI testing needed for logic
- Fast test execution
- Models: Data
- ViewModels: Logic
- Views: UI
- Each layer has one responsibility
- ViewModels can be shared between views
- Business logic is decoupled from UI
- Easy to create variations
- Changes are localized
- Easy to find code
- Clear dependencies
- Scales well
- Multiple people can work on different layers
- Clear contracts between layers
- Less merge conflicts
- More structure means more files
- Need to navigate between files
- Mitigation: Good naming and organization
- Need to understand MVVM pattern
- More concepts to learn
- Mitigation: Good documentation and examples
- Takes longer to set up
- Mitigation: Worth it for medium+ sized apps
let icon = category?.icon // Returns Optional<String>What it does:
- Safely access properties on optionals
- If
categoryisnil, returnsnil - If
categoryexists, returns itsicon - No crash if optional is
nil
let icon = category?.icon ?? "circle" // Returns StringWhat 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>toString
if isEnabled && hasData {
// Both must be true
}What it does:
- Returns
trueonly if both sides aretrue - Short-circuits: if left is
false, doesn't check right - Used for compound conditions
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
✅ 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
❌ 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
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
- Extract Models: Move data structures to separate files
- Create ViewModels: Move state and logic from views to ViewModels
- Update Views: Use
@Statefor ViewModels, remove business logic - Add Tests: Write unit tests for ViewModels
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
@Observableand 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