From f5149d84ec241764be53d168718442ace55cc606 Mon Sep 17 00:00:00 2001 From: Maksim Dimitrov Date: Tue, 17 Feb 2026 15:52:15 +0200 Subject: [PATCH] feat: add swiftui-patterns skill Add comprehensive SwiftUI skill covering @Observable state management, view composition, type-safe NavigationStack routing, performance optimization with lazy containers, and modern preview patterns. --- skills/swiftui-patterns/SKILL.md | 257 +++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 skills/swiftui-patterns/SKILL.md diff --git a/skills/swiftui-patterns/SKILL.md b/skills/swiftui-patterns/SKILL.md new file mode 100644 index 00000000..ada05e33 --- /dev/null +++ b/skills/swiftui-patterns/SKILL.md @@ -0,0 +1,257 @@ +--- +name: swiftui-patterns +description: SwiftUI architecture patterns, state management with @Observable, view composition, navigation, performance optimization, and modern iOS/macOS UI best practices. +--- + +# SwiftUI Patterns + +Modern SwiftUI patterns for building declarative, performant user interfaces on Apple platforms. Covers the Observation framework, view composition, type-safe navigation, and performance optimization. + +## When to Activate + +- Building SwiftUI views and managing state (`@State`, `@Observable`, `@Binding`) +- Designing navigation flows with `NavigationStack` +- Structuring view models and data flow +- Optimizing rendering performance for lists and complex layouts +- Working with environment values and dependency injection in SwiftUI + +## State Management + +### Property Wrapper Selection + +Choose the simplest wrapper that fits: + +| Wrapper | Use Case | +|---------|----------| +| `@State` | View-local value types (toggles, form fields, sheet presentation) | +| `@Binding` | Two-way reference to parent's `@State` | +| `@Observable` class + `@State` | Owned model with multiple properties | +| `@Observable` class (no wrapper) | Read-only reference passed from parent | +| `@Bindable` | Two-way binding to an `@Observable` property | +| `@Environment` | Shared dependencies injected via `.environment()` | + +### @Observable ViewModel + +Use `@Observable` (not `ObservableObject`) — it tracks property-level changes so SwiftUI only re-renders views that read the changed property: + +```swift +@Observable +final class ItemListViewModel { + private(set) var items: [Item] = [] + private(set) var isLoading = false + var searchText = "" + + private let repository: any ItemRepository + + init(repository: any ItemRepository = DefaultItemRepository()) { + self.repository = repository + } + + func load() async { + isLoading = true + defer { isLoading = false } + items = (try? await repository.fetchAll()) ?? [] + } +} +``` + +### View Consuming the ViewModel + +```swift +struct ItemListView: View { + @State private var viewModel = ItemListViewModel() + + var body: some View { + List(viewModel.items) { item in + ItemRow(item: item) + } + .searchable(text: $viewModel.searchText) + .overlay { if viewModel.isLoading { ProgressView() } } + .task { await viewModel.load() } + } +} +``` + +### Environment Injection + +Replace `@EnvironmentObject` with `@Environment`: + +```swift +// Inject +ContentView() + .environment(authManager) + +// Consume +struct ProfileView: View { + @Environment(AuthManager.self) private var auth + + var body: some View { + Text(auth.currentUser?.name ?? "Guest") + } +} +``` + +## View Composition + +### Extract Subviews to Limit Invalidation + +Break views into small, focused structs. When state changes, only the subview reading that state re-renders: + +```swift +struct OrderView: View { + @State private var viewModel = OrderViewModel() + + var body: some View { + VStack { + OrderHeader(title: viewModel.title) + OrderItemList(items: viewModel.items) + OrderTotal(total: viewModel.total) + } + } +} +``` + +### ViewModifier for Reusable Styling + +```swift +struct CardModifier: ViewModifier { + func body(content: Content) -> some View { + content + .padding() + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +extension View { + func cardStyle() -> some View { + modifier(CardModifier()) + } +} +``` + +## Navigation + +### Type-Safe NavigationStack + +Use `NavigationStack` with `NavigationPath` for programmatic, type-safe routing: + +```swift +@Observable +final class Router { + var path = NavigationPath() + + func navigate(to destination: Destination) { + path.append(destination) + } + + func popToRoot() { + path = NavigationPath() + } +} + +enum Destination: Hashable { + case detail(Item.ID) + case settings + case profile(User.ID) +} + +struct RootView: View { + @State private var router = Router() + + var body: some View { + NavigationStack(path: $router.path) { + HomeView() + .navigationDestination(for: Destination.self) { dest in + switch dest { + case .detail(let id): ItemDetailView(itemID: id) + case .settings: SettingsView() + case .profile(let id): ProfileView(userID: id) + } + } + } + .environment(router) + } +} +``` + +## Performance + +### Use Lazy Containers for Large Collections + +`LazyVStack` and `LazyHStack` create views only when visible: + +```swift +ScrollView { + LazyVStack(spacing: 8) { + ForEach(items) { item in + ItemRow(item: item) + } + } +} +``` + +### Stable Identifiers + +Always use stable, unique IDs in `ForEach` — avoid using array indices: + +```swift +// Use Identifiable conformance or explicit id +ForEach(items, id: \.stableID) { item in + ItemRow(item: item) +} +``` + +### Avoid Expensive Work in body + +- Never perform I/O, network calls, or heavy computation inside `body` +- Use `.task {}` for async work — it cancels automatically when the view disappears +- Use `.sensoryFeedback()` and `.geometryGroup()` sparingly in scroll views +- Minimize `.shadow()`, `.blur()`, and `.mask()` in lists — they trigger offscreen rendering + +### Equatable Conformance + +For views with expensive bodies, conform to `Equatable` to skip unnecessary re-renders: + +```swift +struct ExpensiveChartView: View, Equatable { + let dataPoints: [DataPoint] + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.dataPoints.count == rhs.dataPoints.count + } + + var body: some View { + // Complex chart rendering + } +} +``` + +## Previews + +Use `#Preview` macro with inline mock data for fast iteration: + +```swift +#Preview("Empty state") { + ItemListView() + .environment(ItemListViewModel(repository: EmptyMockRepository())) +} + +#Preview("Loaded") { + ItemListView() + .environment(ItemListViewModel(repository: PopulatedMockRepository())) +} +``` + +## Anti-Patterns to Avoid + +- Using `ObservableObject` / `@Published` / `@StateObject` / `@EnvironmentObject` in new code — migrate to `@Observable` +- Putting async work directly in `body` or `init` — use `.task {}` or explicit load methods +- Creating view models as `@State` inside child views that don't own the data — pass from parent instead +- Using `AnyView` type erasure — prefer `@ViewBuilder` or `Group` for conditional views +- Ignoring `Sendable` requirements when passing data to/from actors + +## References + +See skill: `swift-actor-persistence` for actor-based persistence patterns. +See skill: `swift-protocol-di-testing` for protocol-based DI and testing with Swift Testing.