swiftui-patterns

SwiftUI Architecture Patterns

Safety Notice

This listing is imported from skills.sh public index metadata. Review upstream SKILL.md and repository scripts before running.

Copy this and send it to your AI assistant to learn

Install skill "swiftui-patterns" with this command: npx skills add bluewaves-creations/bluewaves-skills/bluewaves-creations-bluewaves-skills-swiftui-patterns

SwiftUI Architecture Patterns

Comprehensive guide to modern SwiftUI architecture, @Observable macro, state management, view composition, and the ongoing MV vs MVVM debate for iOS 26 development.

Prerequisites

  • iOS 17+ for @Observable (iOS 26 recommended)

  • Xcode 26+

  • SwiftUI framework

The Architecture Debate: MV vs MVVM

The Modern Reality

With @Observable (iOS 17+), SwiftUI has evolved. The traditional MVVM pattern is not always necessary.

When to Use MV (Model-View)

// Simple screens with straightforward logic // Model is directly observable

@Observable class Note { var title: String var content: String var lastModified: Date

init(title: String = "", content: String = "") {
    self.title = title
    self.content = content
    self.lastModified = Date()
}

}

struct NoteEditorView: View { @Bindable var note: Note // Direct model binding

var body: some View {
    VStack {
        TextField("Title", text: $note.title)
        TextEditor(text: $note.content)
    }
    .onChange(of: note.content) {
        note.lastModified = Date()
    }
}

}

Use MV when:

  • Simple data display and editing

  • Logic is minimal or can live in the model

  • No complex async operations

  • Rapid prototyping

When to Use MVVM

// Complex screens needing presentation logic, async operations, // or coordination between multiple data sources

@Observable class NoteListViewModel { var notes: [Note] = [] var searchQuery = "" var isLoading = false var error: Error?

var filteredNotes: [Note] {
    guard !searchQuery.isEmpty else { return notes }
    return notes.filter {
        $0.title.localizedCaseInsensitiveContains(searchQuery)
    }
}

@MainActor
func loadNotes() async {
    isLoading = true
    defer { isLoading = false }

    do {
        notes = try await noteService.fetchAll()
    } catch {
        self.error = error
    }
}

func deleteNote(_ note: Note) async {
    await noteService.delete(note)
    notes.removeAll { $0.id == note.id }
}

}

struct NoteListView: View { @State private var viewModel = NoteListViewModel()

var body: some View {
    List(viewModel.filteredNotes) { note in
        NoteRow(note: note)
    }
    .searchable(text: $viewModel.searchQuery)
    .task {
        await viewModel.loadNotes()
    }
    .overlay {
        if viewModel.isLoading {
            ProgressView()
        }
    }
}

}

Use MVVM when:

  • Complex business logic

  • Multiple data sources to coordinate

  • Async operations (network, database)

  • Need testability

  • Team prefers clear separation

@Observable vs @ObservableObject

@Observable (iOS 17+ - Recommended)

import Observation

@Observable class UserSettings { var username = "" var theme: Theme = .system var notificationsEnabled = true

// Computed properties are tracked
var displayName: String {
    username.isEmpty ? "Guest" : username
}

}

struct SettingsView: View { var settings: UserSettings // No property wrapper needed

var body: some View {
    Form {
        TextField("Username", text: Bindable(settings).username)
        // Or with @Bindable
    }
}

}

struct SettingsViewWithBindable: View { @Bindable var settings: UserSettings

var body: some View {
    Form {
        TextField("Username", text: $settings.username)
        Toggle("Notifications", isOn: $settings.notificationsEnabled)
    }
}

}

Benefits:

  • Per-property tracking (not whole object)

  • No @Published needed

  • Cleaner syntax

  • Better performance

@ObservableObject (Legacy)

import Combine

class LegacySettings: ObservableObject { @Published var username = "" @Published var theme: Theme = .system @Published var notificationsEnabled = true

var displayName: String {
    username.isEmpty ? "Guest" : username
}

}

struct LegacySettingsView: View { @ObservedObject var settings: LegacySettings // Or @StateObject if this view owns the object

var body: some View {
    Form {
        TextField("Username", text: $settings.username)
    }
}

}

When to use:

  • Supporting iOS 16 or earlier

  • Existing codebase migration

  • Combine integration needed

Property Wrappers Deep Dive

@State

Local, view-owned mutable state:

struct CounterView: View { @State private var count = 0 @State private var history: [Int] = []

var body: some View {
    VStack {
        Text("Count: \(count)")

        Button("+1") {
            count += 1
            history.append(count)
        }
    }
}

}

Rules:

  • Always private

  • View owns the source of truth

  • Value types preferred

@Binding

Two-way connection to parent's state:

struct ParentView: View { @State private var isOn = false

var body: some View {
    ToggleView(isOn: $isOn)
}

}

struct ToggleView: View { @Binding var isOn: Bool

var body: some View {
    Toggle("Switch", isOn: $isOn)
}

}

// Creating bindings manually struct ManualBindingExample: View { @State private var value = 0

var body: some View {
    ChildView(value: Binding(
        get: { value },
        set: { newValue in
            value = min(100, max(0, newValue))  // Clamp
        }
    ))
}

}

@Bindable

Creates bindings for @Observable:

@Observable class Document { var title = "" var content = "" }

struct DocumentEditor: View { @Bindable var document: Document

var body: some View {
    VStack {
        TextField("Title", text: $document.title)
        TextEditor(text: $document.content)
    }
}

}

@Environment

Access shared values from view hierarchy:

struct ThemedView: View { @Environment(.colorScheme) var colorScheme @Environment(.dismiss) var dismiss @Environment(.horizontalSizeClass) var sizeClass @Environment(.dynamicTypeSize) var dynamicType

var body: some View {
    VStack {
        if colorScheme == .dark {
            Text("Dark Mode")
        }

        Button("Close") {
            dismiss()
        }
    }
}

}

// Custom environment values struct ThemeKey: EnvironmentKey { static let defaultValue = AppTheme.default }

extension EnvironmentValues { var appTheme: AppTheme { get { self[ThemeKey.self] } set { self[ThemeKey.self] = newValue } } }

// Usage ContentView() .environment(.appTheme, customTheme)

@Environment with @Observable

@Observable class AppState { var user: User? var isAuthenticated: Bool { user != nil } }

struct RootView: View { @State private var appState = AppState()

var body: some View {
    ContentView()
        .environment(appState)
}

}

struct ContentView: View { @Environment(AppState.self) var appState

var body: some View {
    if appState.isAuthenticated {
        MainView()
    } else {
        LoginView()
    }
}

}

View Composition Patterns

Extract Subviews

// Before: Monolithic view struct MessyProfileView: View { var user: User

var body: some View {
    VStack {
        // 50 lines of header code
        // 30 lines of stats code
        // 40 lines of activity code
    }
}

}

// After: Composed views struct ProfileView: View { var user: User

var body: some View {
    ScrollView {
        VStack(spacing: 20) {
            ProfileHeader(user: user)
            ProfileStats(user: user)
            ProfileActivity(user: user)
        }
    }
}

}

struct ProfileHeader: View { var user: User

var body: some View {
    VStack {
        AsyncImage(url: user.avatarURL)
        Text(user.name)
    }
}

}

ViewBuilder Methods

struct ContentView: View { var items: [Item] var isEditing: Bool

var body: some View {
    VStack {
        header
        itemsList
        if isEditing {
            editingControls
        }
    }
}

private var header: some View {
    Text("Items")
        .font(.largeTitle)
}

@ViewBuilder
private var itemsList: some View {
    if items.isEmpty {
        ContentUnavailableView("No Items", systemImage: "tray")
    } else {
        List(items) { item in
            ItemRow(item: item)
        }
    }
}

@ViewBuilder
private var editingControls: some View {
    HStack {
        Button("Select All") { }
        Button("Delete", role: .destructive) { }
    }
}

}

Generic Views

struct LoadingView<Content: View, T>: View { let state: LoadingState<T> let content: (T) -> Content

var body: some View {
    switch state {
    case .idle:
        Color.clear
    case .loading:
        ProgressView()
    case .loaded(let data):
        content(data)
    case .error(let error):
        ErrorView(error: error)
    }
}

}

enum LoadingState<T> { case idle case loading case loaded(T) case error(Error) }

// Usage struct UserProfileView: View { @State private var state: LoadingState<User> = .idle

var body: some View {
    LoadingView(state: state) { user in
        ProfileContent(user: user)
    }
    .task {
        state = .loading
        do {
            let user = try await fetchUser()
            state = .loaded(user)
        } catch {
            state = .error(error)
        }
    }
}

}

State Management Patterns

Single Source of Truth

@Observable class AppState { var user: User? var settings: Settings var notifications: [Notification]

static let shared = AppState()

private init() {
    self.settings = Settings()
    self.notifications = []
}

}

// Inject at root @main struct MyApp: App { @State private var appState = AppState.shared

var body: some Scene {
    WindowGroup {
        RootView()
            .environment(appState)
    }
}

}

Feature-Based State

// Each feature has its own state @Observable class NotesFeature { var notes: [Note] = [] var selectedNote: Note? var searchQuery = ""

func loadNotes() async { }
func createNote() -> Note { }
func deleteNote(_ note: Note) { }

}

@Observable class SettingsFeature { var appearance: Appearance = .system var notifications: NotificationSettings = .default

func save() async { }
func reset() { }

}

// Compose features @Observable class AppFeatures { let notes = NotesFeature() let settings = SettingsFeature() }

Action-Based Updates

@Observable class Store { private(set) var state: AppState

init(initialState: AppState = .init()) {
    self.state = initialState
}

func dispatch(_ action: Action) {
    state = reduce(state, action)
}

private func reduce(_ state: AppState, _ action: Action) -> AppState {
    var newState = state

    switch action {
    case .addItem(let item):
        newState.items.append(item)
    case .removeItem(let id):
        newState.items.removeAll { $0.id == id }
    case .updateItem(let item):
        if let index = newState.items.firstIndex(where: { $0.id == item.id }) {
            newState.items[index] = item
        }
    }

    return newState
}

}

enum Action { case addItem(Item) case removeItem(UUID) case updateItem(Item) }

Dependency Injection

Environment-Based DI

// Protocol for abstraction protocol DataServiceProtocol { func fetchItems() async throws -> [Item] }

// Production implementation class DataService: DataServiceProtocol { func fetchItems() async throws -> [Item] { // Real network call } }

// Mock for testing/previews class MockDataService: DataServiceProtocol { func fetchItems() async throws -> [Item] { [Item(name: "Mock 1"), Item(name: "Mock 2")] } }

// Environment key struct DataServiceKey: EnvironmentKey { static let defaultValue: DataServiceProtocol = DataService() }

extension EnvironmentValues { var dataService: DataServiceProtocol { get { self[DataServiceKey.self] } set { self[DataServiceKey.self] = newValue } } }

// Usage in views struct ItemListView: View { @Environment(.dataService) var dataService @State private var items: [Item] = []

var body: some View {
    List(items) { item in
        Text(item.name)
    }
    .task {
        items = try? await dataService.fetchItems() ?? []
    }
}

}

// Preview with mock #Preview { ItemListView() .environment(.dataService, MockDataService()) }

Constructor Injection

@Observable class ItemViewModel { private let service: DataServiceProtocol var items: [Item] = []

init(service: DataServiceProtocol = DataService()) {
    self.service = service
}

func load() async {
    items = (try? await service.fetchItems()) ?? []
}

}

Navigation Patterns

Coordinator Pattern

@Observable class AppCoordinator { var path = NavigationPath()

func showDetail(for item: Item) {
    path.append(item)
}

func showSettings() {
    path.append(Route.settings)
}

func pop() {
    path.removeLast()
}

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

}

enum Route: Hashable { case settings case profile(userId: String) }

struct CoordinatedApp: View { @State private var coordinator = AppCoordinator()

var body: some View {
    NavigationStack(path: $coordinator.path) {
        HomeView()
            .navigationDestination(for: Item.self) { item in
                ItemDetailView(item: item)
            }
            .navigationDestination(for: Route.self) { route in
                switch route {
                case .settings:
                    SettingsView()
                case .profile(let userId):
                    ProfileView(userId: userId)
                }
            }
    }
    .environment(coordinator)
}

}

Best Practices

  • Start Simple - Use MV pattern first, add ViewModel when needed

  • Prefer @Observable - Over @ObservableObject for new code

  • Extract Early - Break views at 50-100 lines

  • Single Responsibility - Each view does one thing

  • Testable Design - Use protocols for dependencies

  • Environment for Shared State - Pass global state via environment

  • Local State is Fine - Not everything needs to be in a store

Official Resources

  • SwiftUI Documentation

  • Observation Framework

  • WWDC23: Discover Observation in SwiftUI

  • Managing model data in your app

Source Transparency

This detail page is rendered from real SKILL.md content. Trust labels are metadata-based hints, not a safety guarantee.

Related Skills

Related by shared tags or category signals.

General

photographer-testino

No summary provided by upstream source.

Repository SourceNeeds Review
General

photographer-lindbergh

No summary provided by upstream source.

Repository SourceNeeds Review
General

photographer-lachapelle

No summary provided by upstream source.

Repository SourceNeeds Review
General

photographer-vonunwerth

No summary provided by upstream source.

Repository SourceNeeds Review