SwiftUI Patterns
Modern SwiftUI patterns targeting iOS 26+ with Swift 6.2. 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
Related skills: For navigation patterns (NavigationStack, sheets, tabs, deep links), see the swiftui-navigation skill. For layout and components (grids, lists, scroll views, forms, controls), see the swiftui-layout-components skill.
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,.task, and.onChangefor orchestration - Inject services and shared models via
@Environment; keep views small and composable - 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 |
|---|---|
@State | View owns the object or value. Creates and manages lifecycle. |
let | View receives an @Observable object. Read-only observation -- no wrapper needed. |
@Bindable | View receives an @Observable object and needs two-way bindings ($property). |
@Environment(Type.self) | Access shared @Observable object from environment. |
@State (value types) | View-local simple state: toggles, counters, text field values. Always private. |
@Binding | Two-way connection to parent's @State or @Bindable 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.
Navigation
For NavigationStack, NavigationSplitView, sheets, tabs, and deep linking patterns, see the dedicated swiftui-navigation skill.
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
.scrollEdgeEffectStyle(.soft, for: .top)-- fading edge effect on scroll edges.backgroundExtensionEffect()-- mirror/blur at safe area edges@Animatablemacro -- synthesizesAnimatableDataconformance automatically (seeswiftui-animationskill)TextEditor-- now acceptsAttributedStringfor rich text
Performance Guidelines
- Lazy stacks/grids: Use
LazyVStack,LazyHStack,LazyVGrid,LazyHGridfor large collections. Regular stacks render all children immediately. - Stable IDs: All items in
List/ForEachmust conform toIdentifiablewith stable IDs. Never use array indices. - 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.
Component Reference
For layout, grids, lists, forms, controls, and scrollview patterns, see the dedicated swiftui-layout-components skill.
HIG Alignment
Follow Apple Human Interface Guidelines for layout, typography, color, and accessibility. Key rules:
- Use semantic colors (
Color.primary,.secondary,Color(uiColor: .systemBackground)) for automatic light/dark mode - Use system font styles (
.title,.headline,.body,.caption) for Dynamic Type support - Use
ContentUnavailableViewfor empty and error states - Support adaptive layouts via
horizontalSizeClass - Provide VoiceOver labels (
.accessibilityLabel) and support Dynamic Type accessibility sizes by switching layout orientation
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 |
|---|---|---|
.complete | Full inline rewriting (proofread, rewrite, transform) | Notes, email, documents |
.limited | Overlay panel only — original text untouched | Code editors, validated forms |
.disabled | Writing Tools hidden entirely | Passwords, search bars |
.automatic | 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
@ObservedObjectto create objects -- use@StateObject(legacy) or@State(modern) - Heavy computation in view
body-- move to model or computed property - Not using
.taskfor async work -- manualTaskinonAppearleaks if not cancelled - Array indices as
ForEachIDs -- causes incorrect diffing and UI bugs - Forgetting
@Bindable--$propertysyntax on@Observablerequires@Bindable - Over-using
@State-- only for view-local state; shared state belongs in@Observable - Not extracting subviews -- long body blocks are hard to read and optimize
- Using
NavigationView-- deprecated; useNavigationStack - Inline closures in body -- extract complex closures to methods
.sheet(isPresented:)when state represents a model -- use.sheet(item:)instead- Using
AnyViewfor type erasure -- causes identity resets and disables diffing. Use@ViewBuilder,Group, or generics instead. Seereferences/deprecated-migration.md
Review Checklist
-
@Observableused for shared state models (notObservableObjecton iOS 17+) -
@Stateowns objects;let/@Bindablereceives them -
NavigationStackused (notNavigationView) -
.taskmodifier for async data loading -
LazyVStack/LazyHStackfor large collections - Stable
IdentifiableIDs (not array indices) - Views decomposed into focused subviews
- No heavy computation in view
body - Environment used for deeply shared state
- Custom
ViewModifierfor repeated styling -
.sheet(item:)preferred over.sheet(isPresented:) - Sheets own their actions and call
dismiss()internally - MV pattern followed -- no unnecessary view models
-
@Observableview model classes are@MainActor-isolated - 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 - Navigation patterns: see
swiftui-navigationskill - Layout & components: see
swiftui-layout-componentsskill