Swift-ios-skills swiftui-patterns
Build SwiftUI views with modern MV architecture, state management, and view composition patterns. Covers @Observable ownership rules, @State/@Bindable/@Environment wiring, view decomposition, custom ViewModifiers, environment values, async data loading with .task, iOS 26+ APIs, Writing Tools, and performance guidelines. Use when structuring a SwiftUI app, managing state with @Observable, composing view hierarchies, or applying SwiftUI best practices.
git clone https://github.com/dpearson2699/swift-ios-skills
T=$(mktemp -d) && git clone --depth=1 https://github.com/dpearson2699/swift-ios-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/swiftui-patterns" ~/.claude/skills/dpearson2699-swift-ios-skills-swiftui-patterns && rm -rf "$T"
skills/swiftui-patterns/SKILL.mdSwiftUI Patterns
Modern SwiftUI patterns targeting iOS 26+ with Swift 6.3. Covers architecture, state management, view composition, environment wiring, async loading, design polish, and platform/share integration. Navigation and layout patterns live in dedicated sibling skills. Patterns are backward-compatible to iOS 17 unless noted.
Contents
- Architecture: Model-View (MV) Pattern
- State Management
- View Ordering Convention
- View Composition
- Environment
- Async Data Loading
- iOS 26+ New APIs
- Performance Guidelines
- HIG Alignment
- Writing Tools (iOS 18+)
- Common Mistakes
- Review Checklist
- References
Scope boundary: This skill covers architecture, state ownership, composition, environment wiring, async loading, and related SwiftUI app structure patterns. Detailed navigation patterns are covered in the
swiftui-navigation skill, including NavigationStack, NavigationSplitView, sheets, tabs, and deep-linking patterns. Detailed layout, container, and component patterns are covered in the swiftui-layout-components skill, including stacks, grids, lists, scroll view patterns, forms, controls, search UI with .searchable, overlays, and related layout components.
Architecture: Model-View (MV) Pattern
Default to MV -- views are lightweight state expressions; models and services own business logic. Do not introduce view models unless the existing code already uses them.
Core principles:
- Favor
,@State
,@Environment
,@Query
, and.task
for orchestration.onChange - Inject services and shared models via
; keep views small and composable@Environment - Split large views into smaller subviews rather than introducing a view model
- Test models, services, and business logic; keep views simple and declarative
struct FeedView: View { @Environment(FeedClient.self) private var client enum ViewState { case loading, error(String), loaded([Post]) } @State private var viewState: ViewState = .loading var body: some View { List { switch viewState { case .loading: ProgressView() case .error(let message): ContentUnavailableView("Error", systemImage: "exclamationmark.triangle", description: Text(message)) case .loaded(let posts): ForEach(posts) { post in PostRow(post: post) } } } .task { await loadFeed() } .refreshable { await loadFeed() } } private func loadFeed() async { do { let posts = try await client.getFeed() viewState = .loaded(posts) } catch { viewState = .error(error.localizedDescription) } } }
For MV pattern rationale, app wiring, and lightweight client examples, see references/architecture-patterns.md.
State Management
@Observable Ownership Rules
Important: Always annotate
@Observable view model classes with @MainActor to ensure UI-bound state is updated on the main thread. Required for Swift 6 concurrency safety.
| Wrapper | When to Use |
|---|---|
| View owns the object or value. Creates and manages lifecycle. |
| View receives an object. Read-only observation -- no wrapper needed. |
| View receives an object and needs two-way bindings (). |
| Access shared object from environment. |
(value types) | View-local simple state: toggles, counters, text field values. Always . |
| Two-way connection to parent's or property. |
Ownership Pattern
// @Observable view model -- always @MainActor @MainActor @Observable final class ItemStore { var title = "" var items: [Item] = [] } // View that OWNS the model struct ParentView: View { @State var viewModel = ItemStore() var body: some View { ChildView(store: viewModel) .environment(viewModel) } } // View that READS (no wrapper needed for @Observable) struct ChildView: View { let store: ItemStore var body: some View { Text(store.title) } } // View that BINDS (needs two-way access) struct EditView: View { @Bindable var store: ItemStore var body: some View { TextField("Title", text: $store.title) } } // View that reads from ENVIRONMENT struct DeepView: View { @Environment(ItemStore.self) var store var body: some View { @Bindable var s = store TextField("Title", text: $s.title) } }
Granular tracking: SwiftUI only re-renders views that read properties that changed. If a view reads
items but not isLoading, changing isLoading does not trigger a re-render. This is a major performance advantage over ObservableObject.
Legacy ObservableObject
Only use if supporting iOS 16 or earlier.
@StateObject → @State, @ObservedObject → let, @EnvironmentObject → @Environment(Type.self).
View Ordering Convention
Order members top to bottom: 1)
@Environment 2) let properties 3) @State / stored properties 4) computed var 5) init 6) body 7) view builders / helpers 8) async functions
View Composition
Extract Subviews
Break views into focused subviews. Each should have a single responsibility.
var body: some View { VStack { HeaderSection(title: title, isPinned: isPinned) DetailsSection(details: details) ActionsSection(onSave: onSave, onCancel: onCancel) } }
Computed View Properties
Keep related subviews as computed properties in the same file; extract to a standalone
View struct when reuse is intended or the subview carries its own state.
var body: some View { List { header filters results } } private var header: some View { VStack(alignment: .leading, spacing: 6) { Text(title).font(.title2) Text(subtitle).font(.subheadline) } }
ViewBuilder Functions
For conditional logic that does not warrant a separate struct:
@ViewBuilder private func statusBadge(for status: Status) -> some View { switch status { case .active: Text("Active").foregroundStyle(.green) case .inactive: Text("Inactive").foregroundStyle(.secondary) } }
Custom View Modifiers
Extract repeated styling into
ViewModifier:
struct CardStyle: ViewModifier { func body(content: Content) -> some View { content .padding() .background(.background) .clipShape(RoundedRectangle(cornerRadius: 12)) .shadow(radius: 2) } } extension View { func cardStyle() -> some View { modifier(CardStyle()) } }
Stable View Tree
Avoid top-level conditional view swapping. Prefer a single stable base view with conditions inside sections or modifiers. When a view file exceeds ~300 lines, split with extensions and
// MARK: - comments.
Environment
Custom Environment Values
private struct ThemeKey: EnvironmentKey { static let defaultValue: Theme = .default } extension EnvironmentValues { var theme: Theme { get { self[ThemeKey.self] } set { self[ThemeKey.self] = newValue } } } // Usage .environment(\.theme, customTheme) @Environment(\.theme) var theme
Common Built-in Environment Values
@Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme @Environment(\.dynamicTypeSize) var dynamicTypeSize @Environment(\.horizontalSizeClass) var sizeClass @Environment(\.isSearching) var isSearching @Environment(\.openURL) var openURL @Environment(\.modelContext) var modelContext
Async Data Loading
Always use
.task -- it cancels automatically on view disappear:
struct ItemListView: View { @State var store = ItemStore() var body: some View { List(store.items) { item in ItemRow(item: item) } .task { await store.load() } .refreshable { await store.refresh() } } }
Use
.task(id:) to re-run when a dependency changes:
.task(id: searchText) { guard !searchText.isEmpty else { return } await search(query: searchText) }
Never create manual
Task in onAppear unless you need to store a reference for cancellation. Exception: Task {} is acceptable in synchronous action closures (e.g., Button actions) for immediate state updates before async work.
iOS 26+ New APIs
-- fading edge effect on scroll edges.scrollEdgeEffectStyle(.soft, for: .top)
-- mirror/blur at safe area edges.backgroundExtensionEffect()
macro -- synthesizes@Animatable
conformance automatically (seeAnimatableData
skill)swiftui-animation
-- now acceptsTextEditor
for rich textAttributedString
Performance Guidelines
- Lazy stacks/grids: Use
,LazyVStack
,LazyHStack
,LazyVGrid
for large collections. Regular stacks render all children immediately.LazyHGrid - Stable IDs: All items in
/List
must conform toForEach
with stable IDs. Never use array indices.Identifiable - Avoid body recomputation: Move filtering and sorting to computed properties or the model, not inline in
.body - Equatable views: For complex views that re-render unnecessarily, conform to
.Equatable
HIG Alignment
Follow Apple Human Interface Guidelines for layout, typography, color, and accessibility. Key rules:
- Use semantic colors (
,Color.primary
,.secondary
) for automatic light/dark modeColor(uiColor: .systemBackground) - Use system font styles (
,.title
,.headline
,.body
) for Dynamic Type support.caption - Use
for empty and error statesContentUnavailableView - Support adaptive layouts via
horizontalSizeClass - Provide VoiceOver labels (
) and support Dynamic Type accessibility sizes by switching layout orientation.accessibilityLabel
See references/design-polish.md for HIG, theming, haptics, focus, transitions, and loading patterns.
Writing Tools (iOS 18+)
Control the Apple Intelligence Writing Tools experience on text views with
.writingToolsBehavior(_:).
| Level | Effect | When to use |
|---|---|---|
| Full inline rewriting (proofread, rewrite, transform) | Notes, email, documents |
| Overlay panel only — original text untouched | Code editors, validated forms |
| Writing Tools hidden entirely | Passwords, search bars |
| System chooses based on context (default) | Most views |
TextEditor(text: $body) .writingToolsBehavior(.complete) TextField("Search…", text: $query) .writingToolsBehavior(.disabled)
Detecting active sessions: Read
isWritingToolsActive on UITextView (UIKit) to defer validation or suspend undo grouping until a rewrite finishes.
Common Mistakes
- Using
to create objects -- use@ObservedObject
(legacy) or@StateObject
(modern)@State - Heavy computation in view
-- move to model or computed propertybody - Not using
for async work -- manual.task
inTask
leaks if not cancelledonAppear - Array indices as
IDs -- causes incorrect diffing and UI bugsForEach - Forgetting
--@Bindable
syntax on$property
requires@Observable@Bindable - Over-using
-- only for view-local state; shared state belongs in@State@Observable - Not extracting subviews -- long body blocks are hard to read and optimize
- Using
-- deprecated; useNavigationViewNavigationStack - Inline closures in body -- extract complex closures to methods
when state represents a model -- use.sheet(isPresented:)
instead.sheet(item:)- Using
for type erasure -- causes identity resets and disables diffing. UseAnyView
,@ViewBuilder
, or generics instead. See references/deprecated-migration.mdGroup
Review Checklist
-
used for shared state models (not@Observable
on iOS 17+)ObservableObject -
owns objects;@State
/let
receives them@Bindable -
used (notNavigationStack
)NavigationView -
modifier for async data loading.task -
/LazyVStack
for large collectionsLazyHStack - Stable
IDs (not array indices)Identifiable - Views decomposed into focused subviews
- No heavy computation in view
body - Environment used for deeply shared state
- Custom
for repeated stylingViewModifier -
preferred over.sheet(item:).sheet(isPresented:) - Sheets own their actions and call
internallydismiss() - MV pattern followed -- no unnecessary view models
-
view model classes are@Observable
-isolated@MainActor - Model types passed across concurrency boundaries are
Sendable
References
- Architecture, app wiring, and lightweight clients: references/architecture-patterns.md
- Design polish (HIG, theming, haptics, transitions, loading, focus): references/design-polish.md
- Deprecated API migration: references/deprecated-migration.md
- Platform and sharing patterns (Transferable, media, menus, macOS settings): references/platform-and-sharing.md