swiftui-patterns-developer

Installation
SKILL.md

SwiftUI Patterns Developer (Smart Router)

Purpose

Apply consistent structure and patterns to SwiftUI views, with focus on ordering, subview extraction, and proper composition.

When Auto-Activated

  • Refactoring SwiftUI view structure
  • Organizing view file layout
  • Splitting large views into subviews
  • Keywords: view structure, view ordering, split view, extract subview, large view, refactor view

Core Guidelines

0) Three Qualities of SwiftUI Views (WWDC24)

Understanding these fundamentals helps you write better SwiftUI code:

1. Declarative - Describe what you want, not how to create it:

// ✅ Declarative - describe the result
List(pets) { pet in
    HStack {
        Text(pet.name)
        Spacer()
        Text(pet.species)
    }
}
// No need to add/remove rows manually - SwiftUI handles it

2. Compositional - Build complex UIs from simple building blocks:

// ViewBuilder closures define children of containers
HStack {           // Container view
    Image(...)     // Child 1
    VStack {       // Child 2 (also a container)
        Text(...)  // Nested child
        Text(...)  // Nested child
    }
    Spacer()       // Child 3
}

3. State-Driven - UI automatically updates when state changes:

// SwiftUI tracks dependencies and updates views automatically
@State private var count = 0

var body: some View {
    Button("Count: \(count)") {  // Dependency on `count`
        count += 1                // State change triggers re-render
    }
}

Key insight: Views are VALUE TYPES (structs), not long-lived objects. They are descriptions of current UI state, not objects that receive commands over time. SwiftUI maintains the actual UI behind the scenes.

1) View Ordering (top -> bottom)

Follow Anytype's property organization from IOS_DEVELOPMENT_GUIDE.md:

struct ExampleView: View {
    // 1. Property wrappers (@State, @Injected, @Environment)
    @State private var model: ExampleViewModel
    @Injected(\.settingsService) private var settingsService
    @Environment(\.dismiss) private var dismiss

    // 2. Public properties (let/var)
    let title: String

    // 3. Private properties
    private var cancellables = Set<AnyCancellable>()

    // 4. Computed properties
    private var hasItems: Bool { !model.items.isEmpty }

    // 5. init (if needed)
    init(title: String) {
        self.title = title
        _model = State(wrappedValue: ExampleViewModel(title: title))
    }

    // 6. body
    var body: some View {
        content
            .task { await model.startSubscriptions() }
    }

    // 7. Computed view builders
    private var content: some View { ... }

    // 8. Helper / async functions
    private func handleTap() { ... }
}

2) ViewModel Pattern (Anytype Standard)

Anytype uses MVVM with ViewModels. Always use ViewModels for business logic:

// View - lightweight, UI only
struct ChatView: View {
    @State private var model: ChatViewModel

    init(spaceId: String, chatId: String) {
        _model = State(wrappedValue: ChatViewModel(spaceId: spaceId, chatId: chatId))
    }

    var body: some View {
        content
            .task { await model.startSubscriptions() }
    }

    private var content: some View {
        List(model.messages) { message in
            MessageRow(message: message)
        }
    }
}

// ViewModel - handles business logic
@MainActor
@Observable
final class ChatViewModel {
    var messages: [Message] = []

    @ObservationIgnored
    @Injected(\.chatService) private var chatService

    func startSubscriptions() async {
        // Heavy work here, not in init
    }

    func sendMessage(_ text: String) async {
        // Business logic
    }
}

Key points:

  • Use @State private var model: ViewModel in views
  • Initialize ViewModel in view's init with _model = State(wrappedValue:)
  • Keep ViewModel init cheap, heavy work in .task
  • Use @Observable macro (not ObservableObject)
  • Mark ViewModels with @MainActor

3) How Observation Works (WWDC23)

Understanding why @Observable works helps you use it correctly.

Property Access Tracking:

  • SwiftUI tracks which properties you access during body evaluation
  • Only those accessed properties trigger view invalidation when changed
  • Properties NOT read in body don't cause re-renders (unlike @Published)
@Observable
final class SettingsViewModel {
    var userName: String = ""      // Accessed in body → triggers update
    var isLoading: Bool = false    // Accessed in body → triggers update
    var analyticsData: Data = Data()  // NOT accessed in body → no update
}

struct SettingsView: View {
    @State private var model: SettingsViewModel

    var body: some View {
        // SwiftUI tracks: "this view reads userName and isLoading"
        VStack {
            Text(model.userName)           // ✓ Tracked
            if model.isLoading {           // ✓ Tracked
                ProgressView()
            }
            // model.analyticsData not read → changes won't invalidate this view
        }
    }
}

Per-Instance Tracking:

  • Arrays of @Observable objects work efficiently
  • Only the specific instance that changed triggers updates
  • No need for identifiable tricks with observation
@Observable
final class MessageViewModel {
    var text: String
    var isRead: Bool = false
}

// Each MessageRow only updates when ITS message changes
List(model.messages) { message in
    MessageRow(message: message)  // Only this row updates when message.isRead changes
}

Computed Properties Just Work:

  • Computed properties composed from stored properties are automatically tracked
  • SwiftUI traces through to the underlying stored properties
@Observable
final class CartViewModel {
    var items: [Item] = []
    var discount: Double = 0

    // Computed → tracks both `items` and `discount`
    var totalPrice: Double {
        items.reduce(0) { $0 + $1.price } - discount
    }
}

Performance Benefit: With @Observable, views only update when properties they actually read change. This is more efficient than ObservableObject where ANY @Published change triggers objectWillChange for ALL subscribers.

4) Property Wrapper Decision Tree

When to use which wrapper with @Observable:

Scenario Wrapper Why
View owns model lifecycle @State View creates and manages the model
Model shared app-wide @Environment Injected at app root, read anywhere
Just need bindings ($syntax) @Bindable Pass to TextField, Toggle, etc.
Just reading the model Nothing Direct property access triggers tracking
// View OWNS the model (creates it)
struct ChatView: View {
    @State private var model: ChatViewModel  // ← @State

    init(chatId: String) {
        _model = State(wrappedValue: ChatViewModel(chatId: chatId))
    }
}

// Model passed from parent, need bindings
struct MessageEditor: View {
    @Bindable var draft: DraftMessage  // ← @Bindable for $draft.text

    var body: some View {
        TextField("Message", text: $draft.text)
    }
}

// Just reading, no bindings needed
struct MessageRow: View {
    let message: MessageViewModel  // ← Nothing! Just read properties

    var body: some View {
        Text(message.text)
        Image(systemName: message.isRead ? "checkmark.circle.fill" : "circle")
    }
}

Migration from ObservableObject:

Old New
@StateObject @State
@ObservedObject @Bindable or nothing
@EnvironmentObject @Environment

5) Migration from ObservableObject (WWDC23)

Step-by-step conversion from legacy ObservableObject:

Before (ObservableObject):

class SettingsViewModel: ObservableObject {
    @Published var userName: String = ""
    @Published var notifications: Bool = true

    private var cancellables = Set<AnyCancellable>()
}

struct SettingsView: View {
    @StateObject private var model = SettingsViewModel()

    var body: some View {
        TextField("Name", text: $model.userName)
        Toggle("Notifications", isOn: $model.notifications)
    }
}

After (@Observable):

@Observable
final class SettingsViewModel {
    var userName: String = ""
    var notifications: Bool = true

    @ObservationIgnored
    private var cancellables = Set<AnyCancellable>()
}

struct SettingsView: View {
    @State private var model = SettingsViewModel()

    var body: some View {
        @Bindable var model = model  // Local binding for $ syntax
        TextField("Name", text: $model.userName)
        Toggle("Notifications", isOn: $model.notifications)
    }
}

Migration Steps:

  1. Remove ObservableObject conformance, add @Observable macro
  2. Remove @Published from all properties (observation is automatic)
  3. Add @ObservationIgnored to properties that shouldn't trigger updates
  4. Change @StateObject@State in views
  5. For $ binding syntax, use @Bindable var model = model in body
  6. Replace @EnvironmentObject with @Environment

Note: Anytype already uses @Observable - this section is for understanding legacy code during migrations.

6) Dependency Injection (Factory)

Anytype uses Factory DI, not SwiftUI Environment for services:

// ✅ CORRECT - Factory DI
@Injected(\.chatService) private var chatService

// ❌ WRONG - Environment for services
@Environment(ChatService.self) private var chatService

Environment is for:

  • System values: @Environment(\.dismiss), @Environment(\.colorScheme)
  • SwiftUI-provided context

@Injected is for:

  • App services: @Injected(\.chatService)
  • Repositories: @Injected(\.userRepository)
  • Any business logic dependencies

7) View Modifiers and Order (WWDC24)

View modifiers create a hierarchical structure. Order matters - modifiers are applied sequentially:

// Each modifier wraps the previous result
Image("whiskers")
    .clipShape(Circle())      // 1. Clip to circle first
    .shadow(radius: 4)        // 2. Add shadow to clipped shape
    .overlay(                 // 3. Overlay on top of shadow
        Circle().stroke(.green, lineWidth: 2)
    )

The hierarchy and order of effect is defined by the exact order of modifiers. Chaining modifiers makes it clear how results are produced and how to customize them.

8) Adaptive Views (WWDC24)

SwiftUI views describe purpose, not exact visual construction. This enables adaptation:

Buttons - Same purpose (labeled action), different contexts:

// Adapts to: borderless, bordered, prominent styles
// Adapts to: swipe actions, menus, forms
Button("Edit", action: handleEdit)

// In swipe actions
.swipeActions {
    Button("Delete", role: .destructive) { delete() }
    Button("Archive") { archive() }
}

Toggles - Switch, checkbox, or toggle button depending on context:

// Automatically shows appropriate style for platform/context
Toggle("Notifications", isOn: $notificationsEnabled)

Searchable - Describes capability, SwiftUI handles idiomatic presentation:

// iOS: overlay list, macOS: dropdown menu
List(filteredItems) { ... }
    .searchable(text: $searchText)
    .searchSuggestions {
        ForEach(suggestions) { Text($0) }
    }

9) Split Large Bodies

If body grows beyond a screen, split into smaller subviews:

// Computed view properties (same file)
var body: some View {
    List {
        header
        filters
        results
    }
}

private var header: some View { ... }
private var filters: some View { ... }
private var results: some View { ... }
// Extracted subview (reusable or complex)
struct HeaderSection: View {
    let title: String
    let subtitle: String?

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            AnytypeText(title, style: .heading)
            if let subtitle {
                AnytypeText(subtitle, style: .bodyRegular)
            }
        }
    }
}

8) ViewState Enum Pattern

For views with loading/error/loaded states:

enum ViewState {
    case loading
    case error(String)
    case loaded
}

@MainActor
@Observable
final class FeedViewModel {
    var viewState: ViewState = .loading
    var posts: [Post] = []

    func loadPosts() async {
        do {
            posts = try await feedService.getFeed()
            viewState = .loaded
        } catch {
            viewState = .error(error.localizedDescription)
        }
    }
}

struct FeedView: View {
    @State private var model: FeedViewModel

    var body: some View {
        content
            .task { await model.loadPosts() }
    }

    @ViewBuilder
    private var content: some View {
        switch model.viewState {
        case .loading:
            ProgressView()
        case .error(let message):
            ErrorView(message: message, retry: { Task { await model.loadPosts() } })
        case .loaded:
            List(model.posts) { post in
                PostRow(post: post)
            }
        }
    }
}

11) State, Binding, and Source of Truth (WWDC24)

@State creates internal source of data for a view:

struct RatingView: View {
    @State private var rating = 0  // View owns this state

    var body: some View {
        HStack {
            Text("\(rating)")
            Button("+") { rating += 1 }
            Button("-") { rating -= 1 }
        }
    }
}

@Binding creates two-way reference to state owned elsewhere:

struct RatingContainerView: View {
    @State private var rating = 0  // Single source of truth

    var body: some View {
        VStack {
            Gauge(value: Double(rating), in: 0...10) {}
            RatingEditor(rating: $rating)  // Pass binding
        }
    }
}

struct RatingEditor: View {
    @Binding var rating: Int  // Two-way reference to container's state

    var body: some View {
        Button("+") { rating += 1 }  // Updates container's state
    }
}

Key principle: One source of truth. When multiple views need the same data, lift state up to common ancestor and pass bindings down.

12) Animation with State Changes (WWDC24)

Wrap state changes with withAnimation to animate resulting view updates:

Button("Rate") {
    withAnimation {
        rating += 1  // State change inside animation block
    }
}

Customize transitions for specific views:

Text("\(rating)")
    .contentTransition(.numericText())  // Smooth number transition

Animations in SwiftUI build on the same data-driven updates - when state changes, views update, and withAnimation makes those updates animate.

13) Task and onChange Usage

// Initial load
.task {
    await model.startSubscriptions()
}

// React to state changes
.task(id: searchText) {
    guard !searchText.isEmpty else { return }
    await model.search(query: searchText)
}

.onChange(of: selectedTab) { oldValue, newValue in
    // Handle tab change
}

14) Large View File Organization

When file exceeds ~300 lines:

struct LargeView: View {
    // Properties and body here
}

// MARK: - Subviews
private extension LargeView {
    var header: some View { ... }
    var content: some View { ... }
}

// MARK: - Actions
private extension LargeView {
    func loadData() async { ... }
    func handleTap() { ... }
}

15) UIKit/AppKit Interoperability (WWDC24)

SwiftUI provides seamless interop with UIKit and AppKit - no expectation that an app needs to be entirely SwiftUI.

Embed UIKit in SwiftUI - Use UIViewRepresentable:

struct MapView: UIViewRepresentable {
    func makeUIView(context: Context) -> MKMapView {
        MKMapView()
    }

    func updateUIView(_ uiView: MKMapView, context: Context) {
        // Update the view with new data
    }
}

// Use like any SwiftUI view
var body: some View {
    VStack {
        MapView()
        Button("Center") { ... }
    }
}

Embed SwiftUI in UIKit - Use UIHostingController:

// In a UIKit view controller
let swiftUIView = ProfileView(user: user)
let hostingController = UIHostingController(rootView: swiftUIView)
addChild(hostingController)
view.addSubview(hostingController.view)

Incremental Adoption Philosophy: Apple's own apps use these tools to adopt SwiftUI incrementally - whether bringing SwiftUI into existing apps or incorporating UIKit views into new SwiftUI apps. All are valid approaches.

Common Mistakes

Using @Environment for Services

// ❌ WRONG - Environment for app services
@Environment(FeedService.self) private var feedService

// ✅ CORRECT - Factory DI
@Injected(\.feedService) private var feedService

Missing ViewModel for Complex Views

// ❌ WRONG - Business logic in view
struct FeedView: View {
    @State private var posts: [Post] = []

    private func loadPosts() async {
        // Business logic directly in view
    }
}

// ✅ CORRECT - ViewModel handles business logic
struct FeedView: View {
    @State private var model: FeedViewModel
    // ViewModel handles loadPosts()
}

Avoid Group with Conditionals + Lifecycle Modifiers

// ❌ WRONG - onAppear can fire multiple times
var body: some View {
    Group {
        if model.isLoading {
            ProgressView()
        } else {
            content
        }
    }
    .onAppear { model.onAppear() }
}

// ✅ CORRECT - Use @ViewBuilder
var body: some View {
    loadingContent
        .onAppear { model.onAppear() }
}

@ViewBuilder
private var loadingContent: some View {
    if model.isLoading {
        ProgressView()
    } else {
        content
    }
}

Related Skills

  • ios-dev-guidelines -> Full MVVM/Coordinator patterns, code style
  • swiftui-performance-developer -> Performance optimization
  • design-system-developer -> Icons, typography, colors

Navigation: This skill provides SwiftUI structure patterns. For full architecture guidance, see IOS_DEVELOPMENT_GUIDE.md.

Attribution: View structure patterns adapted from Dimillian/Skills, aligned with Anytype MVVM architecture. WWDC24 insights from "SwiftUI Essentials" session.

Related skills
Installs
2
GitHub Stars
479
First Seen
Jan 23, 2026