Back to skills
SkillHub ClubBuild MobileFull StackMobile

swiftui-patterns

Use when implementing iOS 17+ SwiftUI patterns: @Observable/@Bindable, MVVM architecture, NavigationStack, lazy loading, UIKit interop, accessibility (VoiceOver/Dynamic Type), async operations (.task/.refreshable), or migrating from ObservableObject/@StateObject.

Packaged view

This page reorganizes the original catalog entry around fit, installability, and workflow context first. The original raw source lives below.

Stars
168
Hot score
96
Updated
March 20, 2026
Overall rating
C3.8
Composite score
3.8
Best-practice grade
B70.0

Install command

npx @skill-hub/cli install johnrogers-claude-swift-engineering-swiftui-patterns

Repository

johnrogers/claude-swift-engineering

Skill path: plugins/swift-engineering/skills/swiftui-patterns

Use when implementing iOS 17+ SwiftUI patterns: @Observable/@Bindable, MVVM architecture, NavigationStack, lazy loading, UIKit interop, accessibility (VoiceOver/Dynamic Type), async operations (.task/.refreshable), or migrating from ObservableObject/@StateObject.

Open repository

Best for

Primary workflow: Build Mobile.

Technical facets: Full Stack, Mobile.

Target audience: everyone.

License: Unknown.

Original source

Catalog source: SkillHub Club.

Repository owner: johnrogers.

This is still a mirrored public skill entry. Review the repository before installing into production workflows.

What it helps with

  • Install swiftui-patterns into Claude Code, Codex CLI, Gemini CLI, or OpenCode workflows
  • Review https://github.com/johnrogers/claude-swift-engineering before adding swiftui-patterns to shared team environments
  • Use swiftui-patterns for development workflows

Works across

Claude CodeCodex CLIGemini CLIOpenCode

Favorites: 0.

Sub-skills: 0.

Aggregator: No.

Original source / Raw SKILL.md

---
name: swiftui-patterns
description: >-
  Use when implementing iOS 17+ SwiftUI patterns: @Observable/@Bindable, MVVM architecture, NavigationStack, lazy loading, UIKit interop, accessibility (VoiceOver/Dynamic Type), async operations (.task/.refreshable), or migrating from ObservableObject/@StateObject.
---

# SwiftUI Patterns (iOS 17+)

SwiftUI 17+ removes ObservableObject boilerplate with @Observable, simplifies environment injection with @Environment, and introduces task-based async patterns. The core principle: use Apple's modern APIs instead of reactive libraries.

## Overview

## Quick Reference

| Need | Use (iOS 17+) | NOT |
|------|---------------|-----|
| Observable model | `@Observable` | `ObservableObject` |
| Published property | Regular property | `@Published` |
| Own state | `@State` | `@StateObject` |
| Passed model (binding) | `@Bindable` | `@ObservedObject` |
| Environment injection | `environment(_:)` | `environmentObject(_:)` |
| Environment access | `@Environment(Type.self)` | `@EnvironmentObject` |
| Async on appear | `.task { }` | `.onAppear { Task {} }` |
| Value change | `onChange(of:initial:_:)` | `onChange(of:perform:)` |

## Core Workflow

1. Use `@Observable` for model classes (no @Published needed)
2. Use `@State` for view-owned models, `@Bindable` for passed models
3. Use `.task { }` for async work (auto-cancels on disappear)
4. Use `NavigationStack` with `NavigationPath` for programmatic navigation
5. Apply `.accessibilityLabel()` and `.accessibilityHint()` to interactive elements

## Reference Loading Guide

**ALWAYS load reference files if there is even a small chance the content may be required.** It's better to have the context than to miss a pattern or make a mistake.

| Reference | Load When |
|-----------|-----------|
| **[Observable](references/observable.md)** | Creating new `@Observable` model classes |
| **[State Management](references/state-management.md)** | Deciding between `@State`, `@Bindable`, `@Environment` |
| **[Environment](references/environment.md)** | Injecting dependencies into view hierarchy |
| **[View Modifiers](references/view-modifiers.md)** | Using `onChange`, `task`, or iOS 17+ modifiers |
| **[Migration Guide](references/migration-guide.md)** | Updating iOS 16 code to iOS 17+ |
| **[MVVM Observable](references/mvvm-observable.md)** | Setting up view model architecture |
| **[Navigation](references/navigation.md)** | Programmatic or deep-link navigation |
| **[Performance](references/performance.md)** | Lists with 100+ items or excessive re-renders |
| **[UIKit Interop](references/uikit-interop.md)** | Wrapping UIKit components (WKWebView, PHPicker) |
| **[Accessibility](references/accessibility.md)** | VoiceOver, Dynamic Type, accessibility actions |
| **[Async Patterns](references/async-patterns.md)** | Loading states, refresh, background tasks |
| **[Composition](references/composition.md)** | Reusable view modifiers or complex conditional UI |

## Common Mistakes

1. **Over-using `@Bindable` for passed models** — Creating `@Bindable` for every property causes unnecessary view reloads. Use `@Bindable` only for mutable model properties that need two-way binding. Read-only computed properties should use regular properties.

2. **State placement errors** — Putting model state in the view instead of a dedicated `@Observable` model causes view logic to become tangled. Always separate model and view concerns.

3. **NavigationPath state corruption** — Mutating `NavigationPath` incorrectly can leave it in inconsistent state. Use `navigationDestination(for:destination:)` with proper state management to avoid path corruption.

4. **Missing `.task` cancellation** — `.task` handles cancellation on disappear automatically, but nested Tasks don't. Complex async flows need explicit cancellation tracking to avoid zombie tasks.

5. **Ignoring environment invalidation** — Changing environment values at parent doesn't invalidate child views automatically. Use `@Environment` consistently and understand when re-renders happen based on observation.

6. **UIKit interop memory leaks** — `UIViewRepresentable` and `UIViewControllerRepresentable` can leak if delegate cycles aren't broken. Weak references and explicit cleanup are required.


---

## Referenced Files

> The following files are referenced in this skill and included for context.

### references/observable.md

```markdown
# @Observable — NOT ObservableObject

**iOS 17+ Pattern**

## ✅ Modern Pattern
```swift
import Observation

@Observable
class UserProfileModel {
    var name: String = ""
    var email: String = ""
    var isLoading: Bool = false

    func save() async {
        isLoading = true
        // Save logic
        isLoading = false
    }
}

// In SwiftUI view
struct ProfileView: View {
    let model: UserProfileModel

    var body: some View {
        TextField("Name", text: $model.name)
    }
}
```

## ❌ Deprecated Pattern
```swift
// NEVER use ObservableObject for new code
class UserProfileModel: ObservableObject {
    @Published var name: String = ""
    @Published var email: String = ""
}
```

## Why @Observable?

**Benefits:**
- **Less boilerplate** — no `@Published` needed
- **Better performance** — fine-grained observation (only tracks accessed properties)
- **Type-safe environment** — `@Environment(Type.self)` instead of `@EnvironmentObject`
- **Simpler bindings** — `@Bindable` instead of `@ObservedObject`

**Requirement:** iOS 17.0+ / macOS 14.0+

```

### references/state-management.md

```markdown
# State Management (iOS 17+)

## @State — NOT @StateObject

### ✅ Modern Pattern
```swift
struct ProfileView: View {
    @State private var model = UserProfileModel()

    var body: some View {
        TextField("Name", text: $model.name)
    }
}
```

### ❌ Deprecated Pattern
```swift
// NEVER use @StateObject with @Observable
@StateObject private var model = UserProfileModel()
```

## @Bindable — NOT @ObservedObject

### ✅ Modern Pattern
```swift
struct ProfileEditView: View {
    @Bindable var model: UserProfileModel

    var body: some View {
        Form {
            TextField("Name", text: $model.name)
            TextField("Email", text: $model.email)
        }
    }
}

// Usage
struct ProfileView: View {
    @State private var model = UserProfileModel()

    var body: some View {
        ProfileEditView(model: model)
    }
}
```

### ❌ Deprecated Pattern
```swift
// NEVER use @ObservedObject with @Observable
@ObservedObject var model: UserProfileModel
```

## Common Patterns

### Navigation with Observable
```swift
@Observable
class NavigationModel {
    var path = NavigationPath()
    var selectedItem: Item?

    func navigateTo(_ item: Item) {
        selectedItem = item
    }
}

struct ContentView: View {
    @State private var navigation = NavigationModel()

    var body: some View {
        NavigationStack(path: $navigation.path) {
            ItemList()
                .environment(navigation)
        }
    }
}
```

### Form with Validation
```swift
@Observable
class FormModel {
    var email: String = ""
    var isValid: Bool { email.contains("@") }
}

struct FormView: View {
    @State private var model = FormModel()

    var body: some View {
        Form {
            TextField("Email", text: $model.email)
            Button("Submit") { }
                .disabled(!model.isValid)
        }
    }
}
```

### Loading State
```swift
struct DataView: View {
    @State private var data: [Item] = []
    @State private var isLoading = false
    @State private var error: Error?

    var body: some View {
        List(data) { item in
            Text(item.name)
        }
        .overlay {
            if isLoading {
                ProgressView()
            }
        }
        .task {
            isLoading = true
            defer { isLoading = false }

            do {
                data = try await fetchData()
            } catch {
                self.error = error
            }
        }
    }
}
```

```

### references/environment.md

```markdown
# Environment (iOS 17+)

## environment(_:) — NOT environmentObject(_:)

### ✅ Modern Pattern
```swift
@Observable
class AppSettings {
    var isDarkMode: Bool = false
}

// Inject into environment
struct MyApp: App {
    @State private var settings = AppSettings()

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

// Access in child view
struct SettingsView: View {
    @Environment(AppSettings.self) private var settings

    var body: some View {
        Toggle("Dark Mode", isOn: $settings.isDarkMode)
    }
}
```

### ❌ Deprecated Pattern
```swift
// NEVER use .environmentObject(_:) with @Observable
.environmentObject(settings)

// NEVER use @EnvironmentObject with @Observable
@EnvironmentObject var settings: AppSettings
```

```

### references/view-modifiers.md

```markdown
# Modern View Modifiers (iOS 17+)

## onChange(of:initial:_:) — New Signature

### ✅ Modern Pattern
```swift
struct SearchView: View {
    @State private var searchText = ""

    var body: some View {
        TextField("Search", text: $searchText)
            .onChange(of: searchText) { oldValue, newValue in
                performSearch(query: newValue)
            }
            // Run on appear with initial: true
            .onChange(of: searchText, initial: true) { oldValue, newValue in
                validateInput(newValue)
            }
    }
}
```

### ❌ Deprecated Pattern
```swift
// DEPRECATED: onChange(of:perform:)
.onChange(of: searchText) { newValue in
    performSearch(query: newValue)
}
```

## task(priority:_:) — Async Work

### ✅ Modern Pattern
```swift
struct UserListView: View {
    @State private var users: [User] = []
    @State private var isLoading = false

    var body: some View {
        List(users) { user in
            UserRow(user: user)
        }
        .task {
            await loadUsers()
        }
        .task(id: selectedFilter) {
            // Cancelled and restarted when selectedFilter changes
            await loadUsers(filter: selectedFilter)
        }
    }

    func loadUsers() async {
        isLoading = true
        users = try? await fetchUsers()
        isLoading = false
    }
}
```

### ❌ Deprecated Pattern
```swift
// NEVER use .onAppear with Task
.onAppear {
    Task {
        await loadUsers()
    }
}
```

```

### references/migration-guide.md

```markdown
# Migration Checklist

When updating legacy SwiftUI code to iOS 17+:

- [ ] Replace `ObservableObject` with `@Observable`
- [ ] Remove all `@Published` (regular properties auto-publish)
- [ ] Replace `@StateObject` with `@State`
- [ ] Replace `@ObservedObject` with `@Bindable`
- [ ] Replace `environmentObject(_:)` with `environment(_:)`
- [ ] Replace `@EnvironmentObject` with `@Environment(Type.self)`
- [ ] Update `onChange(of:perform:)` to `onChange(of:initial:_:)`
- [ ] Replace `.onAppear { Task {} }` with `.task`

## Before & After Example

### Before (iOS 16)
```swift
class UserProfileModel: ObservableObject {
    @Published var name: String = ""
    @Published var email: String = ""
}

struct ProfileView: View {
    @StateObject private var model = UserProfileModel()

    var body: some View {
        TextField("Name", text: $model.name)
            .onAppear {
                Task {
                    await model.load()
                }
            }
    }
}
```

### After (iOS 17+)
```swift
@Observable
class UserProfileModel {
    var name: String = ""
    var email: String = ""
}

struct ProfileView: View {
    @State private var model = UserProfileModel()

    var body: some View {
        TextField("Name", text: $model.name)
            .task {
                await model.load()
            }
    }
}
```

```

### references/mvvm-observable.md

```markdown
# MVVM with @Observable (iOS 17+)

**Use for:** View models with reactive state

**Problem:** Need reactive view models without @Published boilerplate.

**Solution:**
```swift
import Observation

@Observable
@MainActor
final class ArticleListViewModel {
    var articles: [Article] = []
    var isLoading = false
    var errorMessage: String?

    private let articleService: ArticleService

    init(articleService: ArticleService) {
        self.articleService = articleService
    }

    func loadArticles() async {
        isLoading = true
        errorMessage = nil

        do {
            articles = try await articleService.fetchArticles()
        } catch {
            errorMessage = error.localizedDescription
        }

        isLoading = false
    }
}

struct ArticleListView: View {
    @State private var viewModel: ArticleListViewModel

    init(articleService: ArticleService) {
        _viewModel = State(wrappedValue: ArticleListViewModel(articleService: articleService))
    }

    var body: some View {
        List(viewModel.articles) { article in
            ArticleRow(article: article)
        }
        .overlay {
            if viewModel.isLoading {
                ProgressView()
            }
        }
        .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) {
            Button("OK") { viewModel.errorMessage = nil }
        } message: {
            if let message = viewModel.errorMessage {
                Text(message)
            }
        }
        .task {
            await viewModel.loadArticles()
        }
    }
}
```

**Benefits:**
- No `@Published` needed
- Fine-grained observation (only tracks accessed properties)
- Better performance than ObservableObject
- Less boilerplate

```

### references/navigation.md

```markdown
# NavigationStack Patterns (iOS 16+)

**Use for:** Type-safe programmatic navigation

**Problem:** Need programmatic navigation with deep linking support.

**Solution:**
```swift
// Navigation coordinator
@Observable
@MainActor
final class NavigationCoordinator {
    var path = NavigationPath()

    func navigateTo(_ article: Article) {
        path.append(article)
    }

    func navigateToAuthor(_ author: Author) {
        path.append(author)
    }

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

    func pop() {
        if !path.isEmpty {
            path.removeLast()
        }
    }
}

// App navigation
struct AppNavigationView: View {
    @State private var coordinator = NavigationCoordinator()

    var body: some View {
        NavigationStack(path: $coordinator.path) {
            ArticleListView()
                .navigationDestination(for: Article.self) { article in
                    ArticleDetailView(article: article)
                }
                .navigationDestination(for: Author.self) { author in
                    AuthorProfileView(author: author)
                }
                .environment(coordinator)
        }
    }
}

// Usage in views
struct ArticleListView: View {
    @Environment(NavigationCoordinator.self) private var coordinator

    var body: some View {
        List(articles) { article in
            Button {
                coordinator.navigateTo(article)
            } label: {
                ArticleRow(article: article)
            }
        }
    }
}
```

**Benefits:**
- Type-safe navigation
- Programmatic control
- Deep linking ready
- Centralized navigation logic

```

### references/performance.md

```markdown
# Performance Optimization Patterns

## Lazy Loading

**Use for:** Long scrollable lists

```swift
// Bad: Loads all items immediately
ScrollView {
    VStack {
        ForEach(articles) { article in
            ArticleCard(article: article)
        }
    }
}

// Good: Loads items on-demand
ScrollView {
    LazyVStack(spacing: 16) {
        ForEach(articles) { article in
            ArticleCard(article: article)
                .onAppear {
                    // Pagination trigger
                    if article == articles.last {
                        loadMoreArticles()
                    }
                }
        }
    }
}
```

## View Identity

**Use for:** Efficient list rendering

```swift
// Ensure all items are Identifiable
struct Article: Identifiable {
    let id: String
    let title: String
}

// SwiftUI can efficiently diff changes
ForEach(articles) { article in
    ArticleRow(article: article)
}

// Or provide manual ID
ForEach(articles, id: \.id) { article in
    ArticleRow(article: article)
}
```

## Equatable Views

**Use for:** Skipping unnecessary re-renders

```swift
struct ArticleRow: View, Equatable {
    let article: Article

    static func == (lhs: ArticleRow, rhs: ArticleRow) -> Bool {
        lhs.article.id == rhs.article.id
    }

    var body: some View {
        HStack {
            Text(article.title)
            Spacer()
            Text(article.author)
                .foregroundColor(.secondary)
        }
    }
}

// Usage: SwiftUI skips re-rendering if article ID unchanged
ForEach(articles) { article in
    ArticleRow(article: article)
        .equatable()
}
```

## Debounced Search

**Use for:** Search fields with live filtering

```swift
@Observable
@MainActor
final class SearchViewModel {
    var searchText = ""
    var results: [Article] = []

    private var searchTask: Task<Void, Never>?

    func updateSearch(_ text: String) {
        searchText = text

        searchTask?.cancel()
        searchTask = Task {
            try? await Task.sleep(for: .milliseconds(300))

            guard !Task.isCancelled else { return }

            results = await performSearch(text)
        }
    }

    private func performSearch(_ query: String) async -> [Article] {
        // Search logic
        []
    }
}

struct SearchView: View {
    @State private var viewModel = SearchViewModel()

    var body: some View {
        VStack {
            TextField("Search", text: $viewModel.searchText)
                .onChange(of: viewModel.searchText) { oldValue, newValue in
                    viewModel.updateSearch(newValue)
                }

            List(viewModel.results) { article in
                ArticleRow(article: article)
            }
        }
    }
}
```

```

### references/uikit-interop.md

```markdown
# UIKit Interoperability

## UIViewRepresentable

**Use for:** Wrapping UIKit views in SwiftUI

```swift
struct WebView: UIViewRepresentable {
    let url: URL
    @Binding var isLoading: Bool

    func makeUIView(context: Context) -> WKWebView {
        let webView = WKWebView()
        webView.navigationDelegate = context.coordinator
        return webView
    }

    func updateUIView(_ webView: WKWebView, context: Context) {
        let request = URLRequest(url: url)
        webView.load(request)
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(isLoading: $isLoading)
    }

    class Coordinator: NSObject, WKNavigationDelegate {
        @Binding var isLoading: Bool

        init(isLoading: Binding<Bool>) {
            _isLoading = isLoading
        }

        func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
            isLoading = true
        }

        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            isLoading = false
        }
    }
}

// Usage
struct ArticleWebView: View {
    let url: URL
    @State private var isLoading = false

    var body: some View {
        WebView(url: url, isLoading: $isLoading)
            .overlay {
                if isLoading {
                    ProgressView()
                }
            }
    }
}
```

## UIViewControllerRepresentable

**Use for:** Presenting UIKit view controllers

```swift
struct ImagePicker: UIViewControllerRepresentable {
    @Binding var image: UIImage?
    @Environment(\.dismiss) private var dismiss

    func makeUIViewController(context: Context) -> PHPickerViewController {
        var config = PHPickerConfiguration()
        config.filter = .images
        config.selectionLimit = 1

        let picker = PHPickerViewController(configuration: config)
        picker.delegate = context.coordinator
        return picker
    }

    func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
        // No updates needed
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(image: $image, dismiss: dismiss)
    }

    class Coordinator: NSObject, PHPickerViewControllerDelegate {
        @Binding var image: UIImage?
        let dismiss: DismissAction

        init(image: Binding<UIImage?>, dismiss: DismissAction) {
            _image = image
            self.dismiss = dismiss
        }

        func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
            dismiss()

            guard let provider = results.first?.itemProvider else { return }

            if provider.canLoadObject(ofClass: UIImage.self) {
                provider.loadObject(ofClass: UIImage.self) { image, _ in
                    DispatchQueue.main.async {
                        self.image = image as? UIImage
                    }
                }
            }
        }
    }
}

// Usage
struct ProfileEditView: View {
    @State private var profileImage: UIImage?
    @State private var showImagePicker = false

    var body: some View {
        VStack {
            if let image = profileImage {
                Image(uiImage: image)
                    .resizable()
                    .aspectRatio(contentMode: .fill)
                    .frame(width: 200, height: 200)
                    .clipShape(Circle())
            }

            Button("Choose Photo") {
                showImagePicker = true
            }
        }
        .sheet(isPresented: $showImagePicker) {
            ImagePicker(image: $profileImage)
        }
    }
}
```

```

### references/accessibility.md

```markdown
# Accessibility Patterns

## VoiceOver Support

**Use for:** Screen reader accessibility

```swift
struct ArticleRow: View {
    let article: Article

    var body: some View {
        HStack {
            AsyncImage(url: article.imageURL) { image in
                image.resizable()
            } placeholder: {
                ProgressView()
            }
            .frame(width: 80, height: 80)
            .accessibilityHidden(true) // Decorative image

            VStack(alignment: .leading) {
                Text(article.title)
                    .font(.headline)
                Text(article.author)
                    .font(.subheadline)
                    .foregroundColor(.secondary)
            }
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel("\(article.title), by \(article.author)")
        .accessibilityHint("Double tap to read article")
    }
}
```

## Dynamic Type Support

**Use for:** Text that scales with user preferences

```swift
struct ArticleContent: View {
    let article: Article
    @ScaledMetric private var imageHeight: CGFloat = 200

    var body: some View {
        ScrollView {
            VStack(alignment: .leading, spacing: 16) {
                AsyncImage(url: article.imageURL) { image in
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                } placeholder: {
                    ProgressView()
                }
                .frame(height: imageHeight) // Scales with Dynamic Type
                .clipped()

                Text(article.title)
                    .font(.title)

                Text(article.content)
                    .font(.body)
            }
        }
    }
}
```

## Accessibility Actions

**Use for:** Custom VoiceOver actions

```swift
struct ArticleCard: View {
    let article: Article
    @State private var isSaved = false
    @State private var isShared = false

    var body: some View {
        VStack {
            Text(article.title)
        }
        .accessibilityElement(children: .combine)
        .accessibilityLabel(article.title)
        .accessibilityAction(named: "Save") {
            isSaved.toggle()
        }
        .accessibilityAction(named: "Share") {
            isShared = true
        }
    }
}
```

```

### references/async-patterns.md

```markdown
# Async Operation Patterns

## Task Modifier

**Use for:** Loading data when view appears

```swift
struct ArticleDetailView: View {
    let articleId: String
    @State private var article: Article?
    @State private var isLoading = true

    var body: some View {
        Group {
            if let article {
                ArticleContent(article: article)
            } else if isLoading {
                ProgressView()
            } else {
                ContentUnavailableView("Article Not Found", systemImage: "doc.text")
            }
        }
        .task {
            await loadArticle()
        }
    }

    private func loadArticle() async {
        isLoading = true
        defer { isLoading = false }

        do {
            article = try await articleService.fetchArticle(id: articleId)
        } catch {
            print("Error loading article: \(error)")
        }
    }
}
```

## Refreshable Content

**Use for:** Pull-to-refresh lists

```swift
struct ArticleListView: View {
    @State private var articles: [Article] = []

    var body: some View {
        List(articles) { article in
            ArticleRow(article: article)
        }
        .refreshable {
            await refreshArticles()
        }
    }

    private func refreshArticles() async {
        do {
            articles = try await articleService.fetchArticles()
        } catch {
            print("Error refreshing: \(error)")
        }
    }
}
```

## Background Tasks

**Use for:** Non-blocking async operations

```swift
struct ArticleDetailView: View {
    let article: Article
    @State private var isSaved = false

    var body: some View {
        ArticleContent(article: article)
            .toolbar {
                Button(isSaved ? "Saved" : "Save") {
                    Task {
                        await saveArticle()
                    }
                }
            }
    }

    private func saveArticle() async {
        do {
            try await articleService.saveArticle(article)
            isSaved = true
        } catch {
            print("Error saving: \(error)")
        }
    }
}
```

```

### references/composition.md

```markdown
# View Composition Patterns

## ViewBuilder for Conditional Content

**Use for:** Complex conditional UI

```swift
struct ArticleCard: View {
    let article: Article
    let style: CardStyle

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            headerView
            contentView
            footerView
        }
        .padding()
        .background(backgroundView)
    }

    @ViewBuilder
    private var headerView: some View {
        if let imageURL = article.imageURL {
            AsyncImage(url: imageURL) { image in
                image
                    .resizable()
                    .aspectRatio(contentMode: .fill)
            } placeholder: {
                ProgressView()
            }
            .frame(height: 200)
            .clipped()
        }
    }

    @ViewBuilder
    private var contentView: some View {
        Text(article.title)
            .font(.headline)

        if style == .detailed {
            Text(article.summary)
                .font(.subheadline)
                .foregroundColor(.secondary)
                .lineLimit(3)
        }
    }

    @ViewBuilder
    private var footerView: some View {
        HStack {
            Text(article.author)
                .font(.caption)
            Spacer()
            Text(article.publishedAt, style: .relative)
                .font(.caption)
                .foregroundColor(.secondary)
        }
    }

    @ViewBuilder
    private var backgroundView: some View {
        RoundedRectangle(cornerRadius: 12)
            .fill(.background)
            .shadow(radius: 2)
    }
}
```

## Custom View Modifiers

**Use for:** Reusable styling

```swift
struct CardStyle: ViewModifier {
    let cornerRadius: CGFloat
    let shadowRadius: CGFloat

    func body(content: Content) -> some View {
        content
            .padding()
            .background(
                RoundedRectangle(cornerRadius: cornerRadius)
                    .fill(.background)
                    .shadow(radius: shadowRadius)
            )
    }
}

extension View {
    func cardStyle(cornerRadius: CGFloat = 12, shadowRadius: CGFloat = 2) -> some View {
        modifier(CardStyle(cornerRadius: cornerRadius, shadowRadius: shadowRadius))
    }
}

// Usage
struct ArticleRow: View {
    let article: Article

    var body: some View {
        VStack {
            Text(article.title)
        }
        .cardStyle()
    }
}
```

```

swiftui-patterns | SkillHub