CtrlK
BlogDocsLog inGet started
Tessl Logo

dpearson2699/swift-ios-skills

Agent skills for iOS, iPadOS, Swift, SwiftUI, and modern Apple framework development.

71

Quality

89%

Does it follow best practices?

Impact

No eval scenarios have been run

SecuritybySnyk

Advisory

Suggest reviewing before use

Overview
Quality
Evals
Security
Files

architecture-patterns.mdskills/swiftui-patterns/references/

Architecture Patterns

Contents

  • MV Patterns
  • App Wiring and Dependency Graph
  • Lightweight Clients

MV Patterns

Default to Model-View (MV) in SwiftUI. Views are lightweight state expressions; models and services own business logic. Do not introduce view models unless the existing code already requires them.

Contents

Core Principles

  • Views orchestrate UI flow using @State, @Environment, @Query, .task, and .onChange
  • Services and shared models live in the environment, are testable in isolation, and encapsulate complexity
  • Split large views into smaller subviews rather than introducing a view model
  • Test models, services, and business logic; views should stay simple and declarative

Why Not MVVM

SwiftUI views are structs -- lightweight, disposable, and recreated frequently. Adding a ViewModel means fighting the framework's core design. Apple's own WWDC sessions (Data Flow Through SwiftUI, Data Essentials in SwiftUI, Discover Observation in SwiftUI) barely mention ViewModels.

Every ViewModel adds:

  • More complexity and objects to synchronize
  • More indirection and cognitive overhead
  • Manual data fetching that duplicates SwiftUI/SwiftData mechanisms

MV Pattern in Practice

View with Environment-Injected Service

struct FeedView: View {
    @Environment(FeedClient.self) private var client
    @Environment(AppTheme.self) private var theme

    enum ViewState {
        case loading, error(String), loaded([Post])
    }

    @State private var viewState: ViewState = .loading
    @State private var isRefreshing = false

    var body: some View {
        NavigationStack {
            List {
                switch viewState {
                case .loading:
                    ProgressView("Loading feed...")
                        .frame(maxWidth: .infinity)
                        .listRowSeparator(.hidden)
                case .error(let message):
                    ContentUnavailableView("Error", systemImage: "exclamationmark.triangle",
                                           description: Text(message))
                    .listRowSeparator(.hidden)
                case .loaded(let posts):
                    ForEach(posts) { post in
                        PostRowView(post: post)
                    }
                }
            }
            .listStyle(.plain)
            .refreshable { await loadFeed() }
            .task { await loadFeed() }
        }
    }

    private func loadFeed() async {
        do {
            let posts = try await client.getFeed()
            viewState = .loaded(posts)
        } catch {
            viewState = .error(error.localizedDescription)
        }
    }
}

Using .task(id:) and .onChange

SwiftUI modifiers act as small state reducers:

.task(id: searchText) {
    guard !searchText.isEmpty else { return }
    await searchFeed(query: searchText)
}
.onChange(of: isInSearch, initial: false) {
    guard !isInSearch else { return }
    Task { await fetchSuggestedFeed() }
}

App-Level Environment Setup

@main
struct MyApp: App {
    @State var client = APIClient()
    @State var auth = Auth()
    @State var router = AppRouter(initialTab: .feed)

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(client)
                .environment(auth)
                .environment(router)
        }
    }
}

All dependencies are injected once and available everywhere.

SwiftData: The Perfect MV Example

SwiftData was built to work directly in views:

struct BookListView: View {
    @Query private var books: [Book]
    @Environment(\.modelContext) private var modelContext

    var body: some View {
        List {
            ForEach(books) { book in
                BookRowView(book: book)
                    .swipeActions {
                        Button("Delete", role: .destructive) {
                            modelContext.delete(book)
                        }
                    }
            }
        }
    }
}

Forcing a ViewModel here means manual fetching, manual refresh, and boilerplate everywhere.

When a ViewModel Already Exists

If a ViewModel exists in the codebase:

  • Make it non-optional when possible
  • Pass dependencies via init, then forward them into the ViewModel in the view's init
  • Store as @State in the root view that owns it
  • Avoid bootstrapIfNeeded patterns
@State private var viewModel: SomeViewModel

init(dependency: Dependency) {
    _viewModel = State(initialValue: SomeViewModel(dependency: dependency))
}

Modern @Observable ViewModel with child-view binding:

@MainActor @Observable final class ProfileViewModel {
    var name: String = ""
    var isSaving: Bool = false

    private let client: ProfileClient

    init(client: ProfileClient) {
        self.client = client
    }

    func save() async throws {
        isSaving = true
        defer { isSaving = false }
        try await client.update(name: name)
    }
}

// Owner view creates via @State
struct ProfileScreen: View {
    @State private var viewModel: ProfileViewModel

    init(client: ProfileClient) {
        _viewModel = State(initialValue: ProfileViewModel(client: client))
    }

    var body: some View {
        ProfileForm(viewModel: viewModel)
    }
}

// Child view receives and binds
struct ProfileForm: View {
    @Bindable var viewModel: ProfileViewModel

    var body: some View {
        TextField("Name", text: $viewModel.name)
        Button("Save") { Task { try? await viewModel.save() } }
            .disabled(viewModel.isSaving)
    }
}

When a New ViewModel Is Justified

The MV pattern is the default. Introduce a ViewModel only when the view would be hard to read or test without one:

  • Multi-step workflows — onboarding, checkout, or wizard flows where each step mutates shared draft state
  • Non-trivial business logic — validation chains, derived state from multiple sources, or transformation pipelines that don't belong in a lightweight client
  • Coordinated async streams — the view orchestrates multiple publishers or AsyncSequence values with interdependent state transitions
  • Existing test surface — the codebase already tests against a ViewModel interface and rewriting to MV would be high cost, low reward

The bar is "this view would be hard to read and test without a ViewModel," not "I'm used to MVVM."

Environment vs. Initializer Injection

Use @Environment when the dependency is shared across many views at different depths. Threading it through every intermediate initializer adds noise:

  • App-wide services: auth, network client, theme, router
  • SwiftData ModelContext
  • Feature-scoped stores injected at a navigation root

Use initializer parameters when the data is specific to this view instance. Makes the view's requirements explicit and keeps previews simple:

  • The selected item, filter mode, or configuration
  • Parent-to-child data that only one view needs
  • Values known at call site that don't change

Rule of thumb: if three or more intermediate views would need to accept and forward a parameter just to reach a deeply nested consumer, move it to the environment.

Testing Strategy

  • Unit test services and business logic
  • Test models and transformations
  • Use SwiftUI previews for visual regression
  • Use UI automation for end-to-end tests
  • Views should be simple enough that they do not need dedicated unit tests

Source

Based on guidance from "SwiftUI in 2025: Forget MVVM" (Thomas Ricouard) and Apple WWDC sessions on SwiftUI data flow.

App Wiring and Dependency Graph

Contents

Intent

Wire the app shell (TabView + NavigationStack + sheets) and install a global dependency graph (environment objects, services, streaming clients, SwiftData ModelContainer) in one place.

Recommended Structure

  1. Root view sets up tabs, per-tab routers, and sheets.
  2. A dedicated view modifier installs global dependencies and lifecycle tasks (auth state, streaming watchers, push tokens, data containers).
  3. Feature views pull only what they need from the environment; feature-specific state stays local.

Root Shell Example

@MainActor
struct AppView: View {
    @State private var selectedTab: AppTab = .home
    @State private var tabRouter = TabRouter()

    var body: some View {
        TabView(selection: $selectedTab) {
            ForEach(AppTab.allCases) { tab in
                let router = tabRouter.router(for: tab)
                Tab(value: tab) {
                    NavigationStack(path: tabRouter.binding(for: tab)) {
                        tab.makeContentView()
                    }
                    .withSheetDestinations(sheet: Binding(
                        get: { router.presentedSheet },
                        set: { router.presentedSheet = $0 }
                    ))
                    .environment(router)
                } label: {
                    tab.label
                }
            }
        }
        .tabBarMinimizeBehavior(.onScrollDown)
        .withAppDependencyGraph()
    }
}

AppTab Enum

@MainActor
enum AppTab: Identifiable, Hashable, CaseIterable {
    case home, notifications, settings
    var id: String { String(describing: self) }

    @ViewBuilder
    func makeContentView() -> some View {
        switch self {
        case .home: HomeView()
        case .notifications: NotificationsView()
        case .settings: SettingsView()
        }
    }

    @ViewBuilder
    var label: some View {
        switch self {
        case .home: Label("Home", systemImage: "house")
        case .notifications: Label("Notifications", systemImage: "bell")
        case .settings: Label("Settings", systemImage: "gear")
        }
    }
}

Router Skeleton

@MainActor
@Observable
final class RouterPath {
    var path: [Route] = []
    var presentedSheet: SheetDestination?
}

enum Route: Hashable {
    case detail(id: String)
}

Dependency Graph Modifier

Use a single modifier to install environment objects and handle lifecycle hooks. This keeps wiring consistent and avoids forgetting a dependency at call sites.

extension View {
    func withAppDependencyGraph(
        client: APIClient = .shared,
        auth: Auth = .shared,
        theme: Theme = .shared,
        toastCenter: ToastCenter = .shared
    ) -> some View {
        environment(client)
            .environment(auth)
            .environment(theme)
            .environment(toastCenter)
            .task(id: auth.currentAccount?.id) {
                // Re-seed services when account changes
                await client.configure(for: auth.currentAccount)
            }
    }
}

Notes:

  • The .task(id:) hooks respond to account/client changes, re-seeding services and watcher state.
  • Keep the modifier focused on global wiring; feature-specific state stays within features.
  • Adjust types to match your project.

SwiftData / ModelContainer

Install ModelContainer at the root so all feature views share the same store:

extension View {
    func withModelContainer() -> some View {
        modelContainer(for: [Draft.self, LocalTimeline.self, TagGroup.self])
    }
}

A single container avoids duplicated stores per sheet or tab and keeps data consistent.

Sheet Routing (Enum-Driven)

Centralize sheets with a small enum and a helper modifier:

enum SheetDestination: Identifiable {
    case composer
    case settings
    var id: String { String(describing: self) }
}

extension View {
    func withSheetDestinations(sheet: Binding<SheetDestination?>) -> some View {
        sheet(item: sheet) { destination in
            switch destination {
            case .composer:
                ComposerView().withEnvironments()
            case .settings:
                SettingsView().withEnvironments()
            }
        }
    }
}

Enum-driven sheets keep presentation centralized and testable; adding a new sheet means one enum case and one switch branch.

App Entry Point

@main
struct MyApp: App {
    @State var client = APIClient()
    @State var auth = Auth()
    @State var router = AppRouter(initialTab: .home)

    var body: some Scene {
        WindowGroup {
            AppView()
                .environment(client)
                .environment(auth)
                .environment(router)
        }
    }
}

Deep Linking

Store NavigationPath as Codable for state restoration. Handle incoming URLs with .onOpenURL:

.onOpenURL { url in
    guard let route = Route(from: url) else { return }
    router.navigate(to: route)
}

See the swiftui-navigation skill for full URL routing patterns.

When to Use

  • Apps with multiple packages/modules that share environment objects and services
  • Apps that need to react to account/client changes and rewire streaming/push safely
  • Any app that wants consistent TabView + NavigationStack + sheet wiring without repeating environment setup

Caveats

  • Keep the dependency modifier slim; do not put feature state or heavy logic there
  • Ensure .task(id:) work is lightweight or cancelled appropriately; long-running work belongs in services
  • If unauthenticated clients exist, gate streaming/watch calls to avoid reconnect spam

Lightweight Clients

Use this pattern to keep networking or service dependencies simple and testable without introducing a full view model or heavy DI framework. It works well for SwiftUI apps where you want a small, composable API surface that can be swapped in previews/tests.

Intent

  • Provide a tiny "client" type made of async closures.
  • Keep business logic in a store or feature layer, not the view.
  • Enable easy stubbing in previews/tests.

Minimal shape

struct SomeClient {
    var fetchItems: (_ limit: Int) async throws -> [Item]
    var search: (_ query: String, _ limit: Int) async throws -> [Item]
}

extension SomeClient {
    static func live(baseURL: URL = URL(string: "https://example.com")!) -> SomeClient {
        let session = URLSession.shared  // Prototyping only. For production, create a URLSession with timeoutIntervalForRequest: 30, timeoutIntervalForResource: 300, waitsForConnectivity: true, and a URLCache.
        return SomeClient(
            fetchItems: { limit in
                // build URL, call session, decode
            },
            search: { query, limit in
                // build URL, call session, decode
            }
        )
    }
}

Usage pattern

@MainActor
@Observable final class ItemsStore {
    enum LoadState { case idle, loading, loaded, failed(String) }

    var items: [Item] = []
    var state: LoadState = .idle
    private let client: SomeClient

    init(client: SomeClient) {
        self.client = client
    }

    func load(limit: Int = 20) async {
        state = .loading
        do {
            items = try await client.fetchItems(limit)
            state = .loaded
        } catch {
            state = .failed(error.localizedDescription)
        }
    }
}
struct ContentView: View {
    @Environment(ItemsStore.self) private var store

    var body: some View {
        List(store.items) { item in
            Text(item.title)
        }
        .task { await store.load() }
    }
}
@main
struct MyApp: App {
    @State private var store = ItemsStore(client: .live())

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(store)
        }
    }
}

Guidance

  • Keep decoding and URL-building in the client; keep state changes in the store.
  • Make the store accept the client in init and keep it private.
  • Avoid global singletons; use .environment for store injection.
  • If you need multiple variants (mock/stub), add static func mock(...).

Pitfalls

  • Don’t put UI state in the client; keep state in the store.
  • Don’t capture self or view state in the client closures.

skills

CHANGELOG.md

README.md

tile.json