
Swiftui Patterns
- 3.2k installs
- 944 repo stars
- Updated July 15, 2026
- dpearson2699/swift-ios-skills
swiftui-patterns is an agent skill that guides modern SwiftUI architecture with Model-View defaults, @Observable ownership rules, view composition, environment wiring, and async .task loading for iOS 26+ with iOS 17 comp
About
swiftui-patterns is a SwiftUI architecture guide for iOS developers who want maintainable screen structure without unnecessary ViewModels. The skill defaults to Model-View: views express lightweight state while models and services own business logic, and it documents when existing ViewModels must be respected versus when new ones are justified. Coverage includes app wiring, dependency graph setup, environment versus initializer injection, and lightweight HTTP or API clients that avoid bloating views. Developers reach for swiftui-patterns when SwiftUI screens accumulate logic, tests become hard to write, or MVVM layers add indirection without clear benefit. The patterns emphasize pragmatic MV over dogmatic MVVM while preserving test seams and clear ownership boundaries across features.
- Default to Model-View (MV) pattern for SwiftUI instead of MVVM
- Views stay lightweight using @State, @Environment, @Query and .task
- Services and models live in the environment and are tested in isolation
- Split large views into subviews rather than adding ViewModels
- Clear decision rules for when a ViewModel is justified
Swiftui Patterns by the numbers
- 3,206 all-time installs (skills.sh)
- +139 installs in the week ending Jul 29, 2026 (Skillselion tracking)
- Ranked #152 of 2,244 Frontend Development skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Jul 31, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dpearson2699/swift-ios-skills --skill swiftui-patternsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.2k |
|---|---|
| repo stars | ★ 944 |
| Security audit | 3 / 3 scanners passed |
| Last updated | July 15, 2026 |
| Repository | dpearson2699/swift-ios-skills ↗ |
How do you structure SwiftUI views with maintainable state ownership without unnecessary ViewModels bloating every screen?
Apply proven SwiftUI architecture patterns that keep views lightweight and business logic testable.
Who is it for?
iOS developers building SwiftUI apps who want MV architecture, granular @Observable tracking, and clear rules for when ViewModels are justified.
Skip if: Skip for UIKit-only apps, server-side Swift, or navigation or Liquid Glass topics covered by sibling swiftui-navigation and swiftui-liquid-glass skills.
When should I use this skill?
Structuring SwiftUI app state, managing @Observable ownership, composing view hierarchies, or correcting SwiftUI pattern guidance.
What you get
Lightweight composable views using @State, @Bindable, and @Environment with tested models and services, stable view trees, and .task-based async loading.
- MV-structured views
- dependency graph
- lightweight service clients
By the numbers
- Targets iOS 26+ with Swift 6.3 while noting backward compatibility to iOS 17
- Review checklist and common mistakes section for architecture and state anti-patterns
Files
SwiftUI 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, layout, animation, and Liquid Glass 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. Detailed animation choreography is covered in swiftui-animation. Liquid Glass adoption, custom glass controls, scroll edge effects, .scrollEdgeEffectStyle, and .backgroundExtensionEffect are covered in swiftui-liquid-glass.
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: Isolate UI-bound @Observable stores and view models on @MainActor when SwiftUI views own them, mutate them, or bind to their properties. Observation tracks changes; it does not make shared mutable state thread-safe. Domain models that do not touch UI state can use their own isolation strategy.
| 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
// UI-bound @Observable store -- main-actor isolated
@MainActor
@Observable final class ItemStore {
var title = ""
var items: [Item] = []
}
// View that OWNS the model
struct ParentView: View {
@State private 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) private 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) {
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(.rect(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
Use @Entry for custom environment values and actions. It generates the entry boilerplate for EnvironmentValues.
extension EnvironmentValues {
@Entry var theme: Theme = .default
@Entry var refreshFeed: @Sendable () async -> Void = {}
}
// Usage
.environment(\.theme, customTheme)
.environment(\.refreshFeed) { await feedStore.refresh() }
@Environment(\.theme) private var theme
@Environment(\.refreshFeed) private var refreshFeedFor iOS 17-compatible code or older compatibility shims, use manual EnvironmentKey types instead.
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 modelContextAsync 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
- `@Animatable` macro -- synthesizes
AnimatableDataconformance automatically (seeswiftui-animationskill) - `TextEditor(text: Binding<AttributedString>)` -- rich text editing with attributed strings
Keep these as routing reminders in this skill. For Liquid Glass visual treatment, scroll edge effects, glass controls, and availability gating, use swiftui-liquid-glass; for detailed animation APIs, use swiftui-animation.
Clipboard command modifiers are not iOS 26 defaults: .copyable, .cuttable, and command-based .pasteDestination(for:action:validator:) are macOS 13+ and iOS/iPadOS/Mac Catalyst 27 beta in current Apple docs. For iOS 26 targets, use UIPasteboard for custom clipboard commands, or use drag/drop and ShareLink for Transferable flows. See references/platform-and-sharing.md.
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.
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 - Omit
spacing:on stacks unless a specific value is required —nil(the default) uses platform-appropriate adaptive spacing - 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 | Reduced overlay-panel experience | 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.
Docs: WritingToolsBehavior · writingToolsBehavior(_:))
Common Mistakes
1. Using @ObservedObject to create objects -- use @StateObject (legacy) or @State (modern) 2. Heavy computation in view body -- move to model or computed property 3. Not using .task for async work -- manual Task in onAppear leaks if not cancelled 4. Array indices as ForEach IDs -- causes incorrect diffing and UI bugs 5. Forgetting @Bindable -- $property syntax on @Observable requires @Bindable 6. Over-using @State -- only for view-local state; shared state belongs in @Observable 7. Not extracting subviews -- long body blocks are hard to read and optimize 8. Using NavigationView -- deprecated; use NavigationStack 9. Reaching for foregroundColor(_:) when foregroundStyle(_:) better matches semantic styling 10. Inline closures in body -- extract complex closures to methods 11. .sheet(isPresented:) when state represents a model -- use .sheet(item:) instead 12. Using `AnyView` for routine branching -- type erasure hides structure and can hurt performance or identity-sensitive transitions. Use @ViewBuilder, Group, or generics unless an API genuinely needs heterogeneous view storage. See references/deprecated-migration.md 13. Putting `@AppStorage` inside an `@Observable` class -- @AppStorage is a SwiftUI DynamicProperty; it only triggers view updates when used directly in a View. Inside an @Observable class, observation tracking never sees the change. Keep @AppStorage in views, or read/write UserDefaults directly inside the @Observable class:
// Wrong -- @AppStorage is invisible to @Observable tracking
@MainActor @Observable final class Settings {
@AppStorage("theme") var theme: String = "system" // view won't update
}
// Right -- UserDefaults read/write with a normal stored property
@MainActor @Observable final class Settings {
var theme: String {
didSet { UserDefaults.standard.set(theme, forKey: "theme") }
}
init() {
theme = UserDefaults.standard.string(forKey: "theme") ?? "system"
}
}14. Hard-coding spacing: on every stack -- omit it to get adaptive platform spacing; only specify when the value is intentional 15. Treating .copyable, .cuttable, or command-based .pasteDestination(for:action:validator:) as iOS 16/iOS 26 APIs -- they are macOS 13+ and iOS/iPadOS/Mac Catalyst 27 beta in current Apple docs. Use UIPasteboard, drag/drop, or ShareLink for iOS 26 targets. 16. Treating modern defaults as formal deprecations -- #Preview is the modern preview default, but PreviewProvider is legacy rather than compiler-deprecated. EditButton, .onDelete, and .onMove remain valid for edit-mode list workflows; use .swipeActions for contextual row actions.
Review Checklist
- [ ]
@Observableused for shared state models (notObservableObjecton iOS 17+) - [ ]
@Stateowns objects;let/@Bindablereceives them - [ ] Migration and availability claims checked for current platform support, especially clipboard and sharing APIs
- [ ]
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
- [ ]
foregroundStyle(_:)used when semantic styling is preferable to a fixed color - [ ] 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
- [ ] UI-bound
@Observablestores and view models are@MainActor-isolated - [ ] Model types passed across concurrency boundaries are
Sendable - [ ] Stack
spacing:omitted unless a specific value is required (prefer adaptive default)
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, clipboard availability, media, menus, macOS settings): references/platform-and-sharing.md
{
"skill_name": "swiftui-patterns",
"evals": [
{
"id": 0,
"name": "observation-actor-ownership",
"prompt": "Design the SwiftUI state architecture for an iOS 26 profile settings screen. The screen edits a profile name, stores a theme preference in UserDefaults, loads remote suggestions asynchronously, and passes editable state into child fields. Use modern Observation and explain what should be @MainActor, @State, @Bindable, @Environment, and .task.",
"expected_output": "A modern SwiftUI patterns answer that uses a UI-bound @MainActor @Observable store owned by @State, @Bindable only where bindings are needed, environment injection for deeply shared state, .task for cancellable async loading, and either keeps @AppStorage in views or uses normal UserDefaults-backed stored properties inside observable models.",
"files": [],
"assertions": [
"Uses `@MainActor` for the UI-bound observable store or view model without claiming every observable domain model must be main-actor isolated.",
"Uses `@State` in the owning view for the observable object lifecycle.",
"Uses `@Bindable` only where a child view needs `$property` bindings.",
"Uses `@Environment(Type.self)` for shared observable state instead of `@EnvironmentObject` on iOS 17+.",
"Uses `.task` or `.task(id:)` for async loading and mentions automatic cancellation.",
"Avoids putting `@AppStorage` directly inside an `@Observable` class for view reactivity; uses view-level `@AppStorage` or normal UserDefaults-backed stored properties instead."
]
},
{
"id": 1,
"name": "migration-accuracy-review",
"prompt": "Review this SwiftUI iOS 26 migration note: always mark every @Observable type @MainActor; `Color(uiColor:)` is deprecated and replaced by shader libraries; SwiftUI `.copyable`, `.cuttable`, and `.pasteDestination` are iOS 16 APIs; `matchedTransitionSource` and `navigationTransition(.zoom)` are iOS 26 only; `PreviewProvider`, `EditButton`, and `.onDelete` are all deprecated. Correct the note.",
"expected_output": "A source-grounded correction that narrows actor isolation to UI-bound observable state, keeps Color(uiColor:) valid for UIKit bridging, states current Apple docs list copy/cut/command paste modifiers as macOS 13+ and iOS/iPadOS 27 beta rather than iOS 16/iOS 26 defaults, marks matchedTransitionSource/navigation zoom as iOS 18+, and distinguishes modern defaults from APIs that are not formally deprecated.",
"files": [],
"assertions": [
"Narrows `@MainActor` guidance to UI-bound observable stores/view models and avoids a universal rule for every `@Observable` type.",
"States that `Color(uiColor:)` remains valid for bridging existing UIKit colors.",
"Does not recommend `.copyable`, `.cuttable`, or command-based `.pasteDestination(for:action:validator:)` as iOS 16 or iOS 26 defaults; cites current Apple docs as macOS 13+ and iOS/iPadOS 27 beta for those command modifiers.",
"Corrects `matchedTransitionSource` and `navigationTransition(.zoom)` availability to iOS 18+.",
"Treats `#Preview` as the modern preview default without falsely saying all `PreviewProvider` use is compiler-deprecated.",
"Treats `EditButton`, `.onDelete`, and `.onMove` as still valid for edit-mode list workflows while recommending `.swipeActions` for contextual row actions."
]
},
{
"id": 2,
"name": "sibling-boundary-routing",
"prompt": "A SwiftUI dashboard needs a shared store, child edit forms, async refresh, a NavigationStack path with deep links, a large searchable grid, a matched hero transition, and iOS 26 Liquid Glass scroll-edge polish. Which parts should the swiftui-patterns skill own, and what should be routed to sibling skills?",
"expected_output": "A boundary-aware routing answer that keeps swiftui-patterns focused on MV architecture, Observation ownership, binding/environment wiring, view decomposition, and .task refresh, while routing NavigationStack/deep links to swiftui-navigation, grid/search/layout work to swiftui-layout-components, detailed matched-transition work to swiftui-animation, and Liquid Glass polish to swiftui-liquid-glass.",
"files": [],
"assertions": [
"Keeps shared store ownership, `@Observable`, `@State`, `@Bindable`, and environment wiring in `swiftui-patterns` scope.",
"Keeps async refresh with `.task` or `.refreshable` in `swiftui-patterns` scope.",
"Routes NavigationStack path and deep-link implementation details to `swiftui-navigation`.",
"Routes large searchable grid, layout, List/Form/Search UI details to `swiftui-layout-components`.",
"Routes detailed matched hero transition implementation to `swiftui-animation`.",
"Routes iOS 26 Liquid Glass and scroll-edge visual treatment details to `swiftui-liquid-glass`."
]
}
]
}
Architecture Patterns
Contents
MV Patterns
Default to Model-View (MV) in SwiftUI. Views are lightweight state expressions; models and services own business logic. Do not introduce view models unless the existing code already requires them.
Contents
- Core Principles
- Why Not MVVM
- MV Pattern in Practice
- When a ViewModel Already Exists
- When a New ViewModel Is Justified
- Environment vs. Initializer Injection
- Testing Strategy
- Source
Core Principles
- Views orchestrate UI flow using
@State,@Environment,@Query,.task, and.onChange - Services and shared models live in the environment, are testable in isolation, and encapsulate complexity
- Split large views into smaller subviews rather than introducing a view model
- Test models, services, and business logic; views should stay simple and declarative
Why Not MVVM
SwiftUI views are structs -- lightweight, disposable, and recreated frequently. Adding a ViewModel means fighting the framework's core design. Apple's own WWDC sessions (Data Flow Through SwiftUI, Data Essentials in SwiftUI, Discover Observation in SwiftUI) barely mention ViewModels.
Every ViewModel adds:
- More complexity and objects to synchronize
- More indirection and cognitive overhead
- Manual data fetching that duplicates SwiftUI/SwiftData mechanisms
MV Pattern in Practice
View with Environment-Injected Service
struct FeedView: View {
@Environment(FeedClient.self) private var client
@Environment(AppTheme.self) private var theme
enum ViewState {
case loading, error(String), loaded([Post])
}
@State private var viewState: ViewState = .loading
@State private var isRefreshing = false
var body: some View {
NavigationStack {
List {
switch viewState {
case .loading:
ProgressView("Loading feed...")
.frame(maxWidth: .infinity)
.listRowSeparator(.hidden)
case .error(let message):
ContentUnavailableView("Error", systemImage: "exclamationmark.triangle",
description: Text(message))
.listRowSeparator(.hidden)
case .loaded(let posts):
ForEach(posts) { post in
PostRowView(post: post)
}
}
}
.listStyle(.plain)
.refreshable { await loadFeed() }
.task { await loadFeed() }
}
}
private func loadFeed() async {
do {
let posts = try await client.getFeed()
viewState = .loaded(posts)
} catch {
viewState = .error(error.localizedDescription)
}
}
}Using .task(id:) and .onChange
SwiftUI modifiers act as small state reducers:
.task(id: searchText) {
guard !searchText.isEmpty else { return }
await searchFeed(query: searchText)
}
.onChange(of: isInSearch, initial: false) {
guard !isInSearch else { return }
Task { await fetchSuggestedFeed() }
}App-Level Environment Setup
@main
struct MyApp: App {
@State var client = APIClient()
@State var auth = Auth()
@State var router = AppRouter(initialTab: .feed)
var body: some Scene {
WindowGroup {
ContentView()
.environment(client)
.environment(auth)
.environment(router)
}
}
}All dependencies are injected once and available everywhere.
SwiftData: The Perfect MV Example
SwiftData was built to work directly in views:
struct BookListView: View {
@Query private var books: [Book]
@Environment(\.modelContext) private var modelContext
var body: some View {
List {
ForEach(books) { book in
BookRowView(book: book)
.swipeActions {
Button("Delete", role: .destructive) {
modelContext.delete(book)
}
}
}
}
}
}Forcing a ViewModel here means manual fetching, manual refresh, and boilerplate everywhere.
When a ViewModel Already Exists
If a ViewModel exists in the codebase:
- Make it non-optional when possible
- Pass dependencies via
init, then forward them into the ViewModel in the view'sinit - Store as
@Statein the root view that owns it - Avoid
bootstrapIfNeededpatterns
@State private var viewModel: SomeViewModel
init(dependency: Dependency) {
_viewModel = State(initialValue: SomeViewModel(dependency: dependency))
}Modern @Observable ViewModel with child-view binding:
@MainActor @Observable final class ProfileViewModel {
var name: String = ""
var isSaving: Bool = false
private let client: ProfileClient
init(client: ProfileClient) {
self.client = client
}
func save() async throws {
isSaving = true
defer { isSaving = false }
try await client.update(name: name)
}
}
// Owner view creates via @State
struct ProfileScreen: View {
@State private var viewModel: ProfileViewModel
init(client: ProfileClient) {
_viewModel = State(initialValue: ProfileViewModel(client: client))
}
var body: some View {
ProfileForm(viewModel: viewModel)
}
}
// Child view receives and binds
struct ProfileForm: View {
@Bindable var viewModel: ProfileViewModel
var body: some View {
TextField("Name", text: $viewModel.name)
Button("Save") { Task { try? await viewModel.save() } }
.disabled(viewModel.isSaving)
}
}Use @MainActor for observable types that are owned by SwiftUI views and mutate view-facing state. Keep non-UI domain models isolated according to their concurrency boundary instead of applying @MainActor by default.
When a New ViewModel Is Justified
The MV pattern is the default. Introduce a ViewModel only when the view would be hard to read or test without one:
- Multi-step workflows — onboarding, checkout, or wizard flows where each step mutates shared draft state
- Non-trivial business logic — validation chains, derived state from multiple sources, or transformation pipelines that don't belong in a lightweight client
- Coordinated async streams — the view orchestrates multiple publishers or
AsyncSequencevalues with interdependent state transitions - Existing test surface — the codebase already tests against a ViewModel interface and rewriting to MV would be high cost, low reward
The bar is "this view would be hard to read and test without a ViewModel," not "I'm used to MVVM."
Environment vs. Initializer Injection
Use `@Environment` when the dependency is shared across many views at different depths. Threading it through every intermediate initializer adds noise:
- App-wide services: auth, network client, theme, router
- SwiftData
ModelContext - Feature-scoped stores injected at a navigation root
Use initializer parameters when the data is specific to this view instance. Makes the view's requirements explicit and keeps previews simple:
- The selected item, filter mode, or configuration
- Parent-to-child data that only one view needs
- Values known at call site that don't change
Rule of thumb: if three or more intermediate views would need to accept and forward a parameter just to reach a deeply nested consumer, move it to the environment.
Testing Strategy
- Unit test services and business logic
- Test models and transformations
- Use SwiftUI previews for visual regression
- Use UI automation for end-to-end tests
- Views should be simple enough that they do not need dedicated unit tests
Source
Based on guidance from "SwiftUI in 2025: Forget MVVM" (Thomas Ricouard) and Apple WWDC sessions on SwiftUI data flow.
App Wiring and Dependency Graph
Contents
- Intent
- Recommended Structure
- Root Shell Example
- Dependency Graph Modifier
- SwiftData / ModelContainer
- Sheet Routing (Enum-Driven)
- App Entry Point
- Deep Linking
- When to Use
- Caveats
Intent
Wire the app shell (TabView + NavigationStack + sheets) and install a global dependency graph (environment objects, services, streaming clients, SwiftData ModelContainer) in one place.
Recommended Structure
1. Root view sets up tabs, per-tab routers, and sheets. 2. A dedicated view modifier installs global dependencies and lifecycle tasks (auth state, streaming watchers, push tokens, data containers). 3. Feature views pull only what they need from the environment; feature-specific state stays local.
Root Shell Example
@MainActor
struct AppView: View {
@State private var selectedTab: AppTab = .home
@State private var tabRouter = TabRouter()
var body: some View {
TabView(selection: $selectedTab) {
ForEach(AppTab.allCases) { tab in
let router = tabRouter.router(for: tab)
Tab(value: tab) {
NavigationStack(path: tabRouter.binding(for: tab)) {
tab.makeContentView()
}
.withSheetDestinations(sheet: Binding(
get: { router.presentedSheet },
set: { router.presentedSheet = $0 }
))
.environment(router)
} label: {
tab.label
}
}
}
.tabBarMinimizeBehavior(.onScrollDown)
.withAppDependencyGraph()
}
}AppTab Enum
@MainActor
enum AppTab: Identifiable, Hashable, CaseIterable {
case home, notifications, settings
var id: String { String(describing: self) }
@ViewBuilder
func makeContentView() -> some View {
switch self {
case .home: HomeView()
case .notifications: NotificationsView()
case .settings: SettingsView()
}
}
@ViewBuilder
var label: some View {
switch self {
case .home: Label("Home", systemImage: "house")
case .notifications: Label("Notifications", systemImage: "bell")
case .settings: Label("Settings", systemImage: "gear")
}
}
}Router Skeleton
@MainActor
@Observable
final class RouterPath {
var path: [Route] = []
var presentedSheet: SheetDestination?
}
enum Route: Hashable {
case detail(id: String)
}Dependency Graph Modifier
Use a single modifier to install environment objects and handle lifecycle hooks. This keeps wiring consistent and avoids forgetting a dependency at call sites.
extension View {
func withAppDependencyGraph(
client: APIClient = .shared,
auth: Auth = .shared,
theme: Theme = .shared,
toastCenter: ToastCenter = .shared
) -> some View {
environment(client)
.environment(auth)
.environment(theme)
.environment(toastCenter)
.task(id: auth.currentAccount?.id) {
// Re-seed services when account changes
await client.configure(for: auth.currentAccount)
}
}
}Notes:
- The
.task(id:)hooks respond to account/client changes, re-seeding services and watcher state. - Keep the modifier focused on global wiring; feature-specific state stays within features.
- Adjust types to match your project.
SwiftData / ModelContainer
Install ModelContainer at the root so all feature views share the same store:
extension View {
func withModelContainer() -> some View {
modelContainer(for: [Draft.self, LocalTimeline.self, TagGroup.self])
}
}A single container avoids duplicated stores per sheet or tab and keeps data consistent.
Sheet Routing (Enum-Driven)
Centralize sheets with a small enum and a helper modifier:
enum SheetDestination: Identifiable {
case composer
case settings
var id: String { String(describing: self) }
}
extension View {
func withSheetDestinations(sheet: Binding<SheetDestination?>) -> some View {
sheet(item: sheet) { destination in
switch destination {
case .composer:
ComposerView().withEnvironments()
case .settings:
SettingsView().withEnvironments()
}
}
}
}Enum-driven sheets keep presentation centralized and testable; adding a new sheet means one enum case and one switch branch.
App Entry Point
@main
struct MyApp: App {
@State var client = APIClient()
@State var auth = Auth()
@State var router = AppRouter(initialTab: .home)
var body: some Scene {
WindowGroup {
AppView()
.environment(client)
.environment(auth)
.environment(router)
}
}
}Deep Linking
Store NavigationPath as Codable for state restoration. Handle incoming URLs with .onOpenURL:
.onOpenURL { url in
guard let route = Route(from: url) else { return }
router.navigate(to: route)
}See the swiftui-navigation skill for full URL routing patterns.
When to Use
- Apps with multiple packages/modules that share environment objects and services
- Apps that need to react to account/client changes and rewire streaming/push safely
- Any app that wants consistent TabView + NavigationStack + sheet wiring without repeating environment setup
Caveats
- Keep the dependency modifier slim; do not put feature state or heavy logic there
- Ensure
.task(id:)work is lightweight or cancelled appropriately; long-running work belongs in services - If unauthenticated clients exist, gate streaming/watch calls to avoid reconnect spam
Lightweight Clients
Use this pattern to keep networking or service dependencies simple and testable without introducing a full view model or heavy DI framework. It works well for SwiftUI apps where you want a small, composable API surface that can be swapped in previews/tests.
Intent
- Provide a tiny "client" type made of async closures.
- Keep business logic in a store or feature layer, not the view.
- Enable easy stubbing in previews/tests.
Minimal shape
struct SomeClient {
var fetchItems: (_ limit: Int) async throws -> [Item]
var search: (_ query: String, _ limit: Int) async throws -> [Item]
}
extension SomeClient {
static func live(baseURL: URL = URL(string: "https://example.com")!) -> SomeClient {
let session = URLSession.shared // Prototyping only. For production, create a URLSession with timeoutIntervalForRequest: 30, timeoutIntervalForResource: 300, waitsForConnectivity: true, and a URLCache.
return SomeClient(
fetchItems: { limit in
// build URL, call session, decode
},
search: { query, limit in
// build URL, call session, decode
}
)
}
}Usage pattern
@MainActor
@Observable final class ItemsStore {
enum LoadState { case idle, loading, loaded, failed(String) }
var items: [Item] = []
var state: LoadState = .idle
private let client: SomeClient
init(client: SomeClient) {
self.client = client
}
func load(limit: Int = 20) async {
state = .loading
do {
items = try await client.fetchItems(limit)
state = .loaded
} catch {
state = .failed(error.localizedDescription)
}
}
}struct ContentView: View {
@Environment(ItemsStore.self) private var store
var body: some View {
List(store.items) { item in
Text(item.title)
}
.task { await store.load() }
}
}@main
struct MyApp: App {
@State private var store = ItemsStore(client: .live())
var body: some Scene {
WindowGroup {
ContentView()
.environment(store)
}
}
}Guidance
- Keep decoding and URL-building in the client; keep state changes in the store.
- Make the store accept the client in
initand keep it private. - Avoid global singletons; use
.environmentfor store injection. - If you need multiple variants (mock/stub), add
static func mock(...).
Pitfalls
- Don’t put UI state in the client; keep state in the store.
- Don’t capture
selfor view state in the client closures.
Deprecated API Migration Guide
A comprehensive mapping of deprecated, legacy, or fragile SwiftUI and iOS patterns to modern defaults from iOS 15 through iOS 26. Each section shows the old pattern, the modern replacement, and migration notes. Target iOS 26 with Swift 6.3; backward-compatible to iOS 16 unless noted.
Contents
- NavigationView to NavigationStack
- NavigationView Sidebar to NavigationSplitView
- ObservableObject /
@Published/@StateObjectto@Observable/@State @ObservedObjectto let /@Bindable@EnvironmentObjectto@Environment- foregroundColor to foregroundStyle
- .onChange single-value to two-value
- ActionSheet to confirmationDialog
- Alert (Legacy) to modern .alert
- AnyView to
@ViewBuilder - .onAppear + Task to .task
- presentationMode to dismiss
- GeometryReader to Layout / containerRelativeFrame
- PreviewProvider to #Preview
- XCTest to Swift Testing
- List row actions: EditButton/.onDelete/.swipeActions
- UIApplication.shared.open to openURL
@FetchRequestto@Query(SwiftData)- some vs any return types
- .sheet(item:) for sheet presentation
- Color.resolve(in:) usage
- ForEach with Identifiable
- Toolbar placement updates
NavigationView to NavigationStack
NavigationView was deprecated in iOS 16. Use NavigationStack for push-based navigation with a single column, or NavigationSplitView for multi-column layouts.
Before (Deprecated)
struct ContentView: View {
var body: some View {
NavigationView {
List(items) { item in
NavigationLink(destination: DetailView(item: item)) {
Text(item.title)
}
}
.navigationTitle("Items")
}
.navigationViewStyle(.stack)
}
}After (Modern)
struct ContentView: View {
@State private var path: [Item] = []
var body: some View {
NavigationStack(path: $path) {
List(items) { item in
NavigationLink(value: item) {
Text(item.title)
}
}
.navigationTitle("Items")
.navigationDestination(for: Item.self) { item in
DetailView(item: item)
}
}
}
}Migration Notes
NavigationStack gives you programmatic control over the navigation path via a binding. Value-based NavigationLink separates the trigger from the destination, keeping list rows lightweight. The .navigationViewStyle(.stack) modifier is no longer needed.
---
NavigationView Sidebar to NavigationSplitView
Before (Deprecated)
struct SidebarApp: View {
var body: some View {
NavigationView {
SidebarList()
DetailPlaceholder()
}
.navigationViewStyle(.columns)
}
}After (Modern)
struct SidebarApp: View {
@State private var selectedCategory: Category?
@State private var selectedItem: Item?
var body: some View {
NavigationSplitView {
List(categories, selection: $selectedCategory) { category in
Label(category.name, systemImage: category.icon)
}
.navigationTitle("Categories")
} content: {
if let category = selectedCategory {
List(category.items, selection: $selectedItem) { item in
Text(item.title)
}
} else {
ContentUnavailableView("Select a Category",
systemImage: "sidebar.left")
}
} detail: {
if let item = selectedItem {
DetailView(item: item)
} else {
ContentUnavailableView("Select an Item",
systemImage: "doc.text")
}
}
}
}Migration Notes
NavigationSplitView explicitly models two-column and three-column layouts. Column visibility is controlled via NavigationSplitViewVisibility and columnVisibility bindings. On compact size classes the split view collapses into a NavigationStack automatically.
---
ObservableObject / @Published / @StateObject to @Observable / @State
The Observation framework (iOS 17+) replaces Combine-based observation. Classes annotated with @Observable track property access automatically -- no @Published wrappers needed.
Before (Superseded)
class UserSettings: ObservableObject {
@Published var username: String = ""
@Published var notificationsEnabled: Bool = true
@Published var theme: Theme = .system
func resetToDefaults() {
username = ""
notificationsEnabled = true
theme = .system
}
}
struct SettingsView: View {
@StateObject private var settings = UserSettings()
var body: some View {
Form {
TextField("Username", text: $settings.username)
Toggle("Notifications", isOn: $settings.notificationsEnabled)
Picker("Theme", selection: $settings.theme) {
ForEach(Theme.allCases) { theme in
Text(theme.rawValue).tag(theme)
}
}
}
}
}After (Modern)
@Observable
class UserSettings {
var username: String = ""
var notificationsEnabled: Bool = true
var theme: Theme = .system
func resetToDefaults() {
username = ""
notificationsEnabled = true
theme = .system
}
}
struct SettingsView: View {
@State private var settings = UserSettings()
var body: some View {
Form {
TextField("Username", text: $settings.username)
Toggle("Notifications", isOn: $settings.notificationsEnabled)
Picker("Theme", selection: $settings.theme) {
ForEach(Theme.allCases) { theme in
Text(theme.rawValue).tag(theme)
}
}
}
}
}Migration Notes
- Replace
ObservableObjectconformance with the@Observablemacro. - Remove all
@Publishedproperty wrappers -- observation is automatic. - Replace
@StateObjectwith@Statefor owned instances. - Computed properties that depend on stored properties are tracked automatically.
- The view only re-evaluates when properties it actually reads change, so fine-grained observation is free.
- Requires iOS 17+ minimum deployment target.
ObservableObjectis not formally deprecated (no compiler warning) -- it is superseded. Do not rewrite workingObservableObjectcode if the project targets iOS 16 or earlier.
---
@ObservedObject to let / @Bindable
Before (Superseded)
struct ProfileEditor: View {
@ObservedObject var profile: ProfileModel
var body: some View {
TextField("Name", text: $profile.name)
Toggle("Public", isOn: $profile.isPublic)
}
}After (Modern)
When you only need to read properties, use a plain let:
struct ProfileDisplay: View {
let profile: ProfileModel // @Observable class
var body: some View {
Text(profile.name)
Text(profile.isPublic ? "Public" : "Private")
}
}When you need to create bindings, use @Bindable:
struct ProfileEditor: View {
@Bindable var profile: ProfileModel
var body: some View {
TextField("Name", text: $profile.name)
Toggle("Public", isOn: $profile.isPublic)
}
}Migration Notes
With @Observable, you no longer need @ObservedObject to subscribe to changes. A plain let constant already triggers view updates when read properties change. Use @Bindable only when you need two-way bindings via $ syntax.
---
@EnvironmentObject to @Environment
Before (Superseded)
// Injection
ContentView()
.environmentObject(authManager)
// Usage
struct ContentView: View {
@EnvironmentObject var auth: AuthManager
var body: some View {
if auth.isLoggedIn {
HomeView()
} else {
LoginView()
}
}
}After (Modern)
// Injection
ContentView()
.environment(authManager)
// Usage
struct ContentView: View {
@Environment(AuthManager.self) private var auth
var body: some View {
if auth.isLoggedIn {
HomeView()
} else {
LoginView()
}
}
}Migration Notes
With @Observable, use .environment(_:) (the type-keyed overload) instead of .environmentObject(_:). Read with @Environment(Type.self). If you need bindings from an environment-injected object, pull it into a local @Bindable:
struct ContentView: View {
@Environment(AuthManager.self) private var auth
var body: some View {
@Bindable var auth = auth
Toggle("Remember Me", isOn: $auth.rememberMe)
}
}---
foregroundColor(_:) to foregroundStyle(_:)
foregroundColor(_:) was deprecated in iOS 17. Its replacement, foregroundStyle(_:), accepts any ShapeStyle -- not just Color -- enabling gradients, hierarchical styles, and materials directly.
Before (Deprecated)
Text("Hello")
.foregroundColor(.red)
Text("Secondary")
.foregroundColor(.secondary)After (Modern)
Text("Hello")
.foregroundStyle(.red)
Text("Secondary")
.foregroundStyle(.secondary)
// Gradient -- not possible with foregroundColor
Text("Gradient")
.foregroundStyle(
.linearGradient(colors: [.blue, .purple],
startPoint: .leading, endPoint: .trailing)
)Migration Notes
foregroundStyle(_:) is a drop-in replacement when passing a Color. The broader ShapeStyle conformance also accepts gradients, .tint, .selection, and hierarchical styles (.primary, .secondary, .tertiary, .quaternary). Multi-level variants foregroundStyle(_:_:) and foregroundStyle(_:_:_:) set hierarchical styles for child content in one call.
Not to be confused with NSAttributedString.Key.foregroundColor -- that is a UIKit/Foundation attributed-string key used for Core Text, NSAttributedString, and PDF rendering. It is not deprecated and has no SwiftUI equivalent.
---
.onChange(of:perform:) to Modern onChange
The single-value onChange closure was deprecated in iOS 17. Modern overloads can run a zero-argument closure or provide both old and new values.
Before (Deprecated)
.onChange(of: searchText) { newValue in
performSearch(newValue)
}After (Modern: new value only)
.onChange(of: searchText) {
performSearch(searchText)
}After (Modern: compare old and new)
.onChange(of: searchText) { oldValue, newValue in
performSearch(newValue)
}If you only need the new value, use _ for the old value:
.onChange(of: searchText) { _, newValue in
performSearch(newValue)
}Migration Notes
The two-value variant lets you compare old and new values inline without maintaining extra state. The initial parameter is also available if you need the callback to fire on first appearance:
.onChange(of: searchText, initial: true) { _, newValue in
performSearch(newValue)
}---
ActionSheet to confirmationDialog
Before (Deprecated)
.actionSheet(isPresented: $showingOptions) {
ActionSheet(
title: Text("Choose an action"),
message: Text("Select one of the options below"),
buttons: [
.default(Text("Share")) { shareItem() },
.destructive(Text("Delete")) { deleteItem() },
.cancel()
]
)
}After (Modern)
.confirmationDialog("Choose an action",
isPresented: $showingOptions,
titleVisibility: .visible) {
Button("Share") { shareItem() }
Button("Delete", role: .destructive) { deleteItem() }
Button("Cancel", role: .cancel) {}
} message: {
Text("Select one of the options below")
}Migration Notes
.confirmationDialog uses standard SwiftUI Button views with roles instead of an array of ActionSheet.Button. The titleVisibility parameter controls whether the title appears (it is hidden by default on iOS). A cancel-role button is added automatically if you omit one.
---
Alert (Legacy) to Modern .alert with Actions
Before (Deprecated)
.alert(isPresented: $showingAlert) {
Alert(
title: Text("Delete Item?"),
message: Text("This action cannot be undone."),
primaryButton: .destructive(Text("Delete")) { deleteItem() },
secondaryButton: .cancel()
)
}After (Modern)
.alert("Delete Item?", isPresented: $showingAlert) {
Button("Delete", role: .destructive) { deleteItem() }
Button("Cancel", role: .cancel) {}
} message: {
Text("This action cannot be undone.")
}With a data item:
.alert("Delete Item?", isPresented: $showingAlert, presenting: itemToDelete) { item in
Button("Delete", role: .destructive) { delete(item) }
} message: { item in
Text("Delete \"\(item.title)\"? This cannot be undone.")
}Migration Notes
The modern alert API accepts a presenting parameter to pass data directly into the alert closures, eliminating the need for separate optional state tracking.
---
AnyView to @ViewBuilder and Concrete Types
Before (Type-Erased Pattern)
func destination(for route: Route) -> AnyView {
switch route {
case .home: return AnyView(HomeView())
case .profile: return AnyView(ProfileView())
case .settings: return AnyView(SettingsView())
}
}After (Modern)
@ViewBuilder
func destination(for route: Route) -> some View {
switch route {
case .home: HomeView()
case .profile: ProfileView()
case .settings: SettingsView()
}
}Migration Notes
AnyView erases type information and hides useful view structure from SwiftUI. @ViewBuilder preserves concrete types, helping the framework reason about identity, updates, and transitions. Avoid AnyView unless interfacing with APIs that genuinely require heterogeneous view storage.
---
.onAppear + Manual Task to .task
Before (Manual Lifecycle Pattern)
struct FeedView: View {
@State private var posts: [Post] = []
var body: some View {
List(posts) { post in
PostRow(post: post)
}
.onAppear {
Task {
posts = try await fetchPosts()
}
}
}
}After (Modern)
struct FeedView: View {
@State private var posts: [Post] = []
var body: some View {
List(posts) { post in
PostRow(post: post)
}
.task {
do {
posts = try await fetchPosts()
} catch {
// handle error
}
}
}
}Migration Notes
.task automatically cancels the async work when the view disappears, preventing retain cycles and stale updates. Use .task(id:) to re-run the task when a dependency changes:
.task(id: selectedCategory) {
posts = try? await fetchPosts(for: selectedCategory)
}---
@Environment(\.presentationMode) to @Environment(\.dismiss)
Before (Deprecated)
struct DetailView: View {
@Environment(\.presentationMode) var presentationMode
var body: some View {
Button("Done") {
presentationMode.wrappedValue.dismiss()
}
}
}After (Modern)
struct DetailView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
Button("Done") {
dismiss()
}
}
}Migration Notes
dismiss is a callable DismissAction. Call it directly -- no .wrappedValue needed. Works for sheets, full-screen covers, and navigation push destinations.
---
GeometryReader Overuse to Layout Protocol and containerRelativeFrame
GeometryReader has performance costs and complicates layout. iOS 16 introduced the Layout protocol, and iOS 17 added containerRelativeFrame for proportional sizing.
Before (Fragile Layout Pattern)
GeometryReader { proxy in
HStack(spacing: 0) {
SidePanel()
.frame(width: proxy.size.width * 0.3)
MainContent()
.frame(width: proxy.size.width * 0.7)
}
}After (Modern) -- containerRelativeFrame (iOS 17+)
HStack(spacing: 0) {
SidePanel()
.containerRelativeFrame(.horizontal) { length, _ in
length * 0.3
}
MainContent()
.containerRelativeFrame(.horizontal) { length, _ in
length * 0.7
}
}After (Modern) -- Custom Layout (iOS 16+)
struct ProportionalHStack: Layout {
var ratios: [CGFloat]
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
proposal.replacingUnspecifiedDimensions()
}
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
guard subviews.count == ratios.count else { return }
var x = bounds.minX
for (index, subview) in subviews.enumerated() {
let width = bounds.width * ratios[index]
subview.place(at: CGPoint(x: x, y: bounds.minY),
proposal: ProposedViewSize(width: width, height: bounds.height))
x += width
}
}
}
// Usage
ProportionalHStack(ratios: [0.3, 0.7]) {
SidePanel()
MainContent()
}Migration Notes
GeometryReader is still appropriate when you genuinely need to read the proposed size and cannot express the layout declaratively. For proportional sizing, prefer containerRelativeFrame. For custom arrangements, prefer the Layout protocol. Both avoid the bottom-up sizing behavior that makes GeometryReader tricky to compose.
---
PreviewProvider to #Preview Macro
Before (Legacy)
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
.previewDevice("iPhone 15 Pro")
ContentView()
.preferredColorScheme(.dark)
}
}After (Modern)
#Preview("Light Mode") {
ContentView()
}
#Preview("Dark Mode") {
ContentView()
.preferredColorScheme(.dark)
}Widget and UIKit previews:
#Preview("Timeline Entry", as: .systemSmall) {
MyWidget()
} timeline: {
SimpleEntry(date: .now)
}
#Preview("UIKit Controller") {
let vc = MyViewController()
vc.title = "Preview"
return vc
}Migration Notes
The #Preview macro (iOS 17+) is less boilerplate and supports naming each preview directly. It works with SwiftUI views, UIKit view controllers, and WidgetKit timelines. For modern targets, replace PreviewProvider structs with #Preview blocks.
---
XCTest to Swift Testing
Swift Testing (Xcode 16+) provides a modern, expressive test framework that coexists with XCTest.
Before (XCTest)
import XCTest
@testable import MyApp
final class CartTests: XCTestCase {
var cart: Cart!
override func setUp() {
cart = Cart()
}
override func tearDown() {
cart = nil
}
func testAddItem() throws {
cart.add(Item(name: "Widget", price: 9.99))
XCTAssertEqual(cart.items.count, 1)
XCTAssertEqual(cart.total, 9.99, accuracy: 0.01)
}
func testEmptyCartTotal() {
XCTAssertEqual(cart.total, 0)
}
func testDiscountCodes() throws {
let codes = ["SAVE10", "SAVE20", "SAVE50"]
for code in codes {
cart.applyDiscount(code: code)
XCTAssertTrue(cart.hasDiscount)
}
}
}After (Swift Testing)
import Testing
@testable import MyApp
@Suite("Cart Tests")
struct CartTests {
let cart = Cart()
@Test("Adding an item updates count and total")
func addItem() {
cart.add(Item(name: "Widget", price: 9.99))
#expect(cart.items.count == 1)
#expect(cart.total.isApproximatelyEqual(to: 9.99))
}
@Test("Empty cart has zero total")
func emptyCartTotal() {
#expect(cart.total == 0)
}
@Test("Discount codes", arguments: ["SAVE10", "SAVE20", "SAVE50"])
func discountCodes(code: String) {
cart.applyDiscount(code: code)
#expect(cart.hasDiscount)
}
}Migration Notes
- Replace
XCTestCasesubclass with a plain struct annotated with@Suite. - Replace
setUp/tearDownwith an initializer and deinit (or just inline setup). - Replace
XCTAssert*macros with#expect(...)and#require(...). - Use
@Test("description", arguments:)for parameterized tests instead of manual loops. - Swift Testing and XCTest targets can coexist in the same project during migration.
- Use
@Test(.disabled("reason"))instead ofXCTSkip.
---
List Row Actions with EditButton, .onDelete, .onMove, and .swipeActions
Basic Edit Mode Pattern
struct ItemList: View {
@State private var items = ["A", "B", "C"]
var body: some View {
NavigationView {
List {
ForEach(items, id: \.self) { item in
Text(item)
}
.onDelete { items.remove(atOffsets: $0) }
.onMove { items.move(fromOffsets: $0, toOffset: $1) }
}
.navigationTitle("Items")
.toolbar { EditButton() }
}
}
}Contextual Swipe Actions
struct ItemList: View {
@State private var items = ["A", "B", "C"]
var body: some View {
NavigationStack {
List {
ForEach(items, id: \.self) { item in
Text(item)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button("Delete", role: .destructive) {
if let index = items.firstIndex(of: item) {
items.remove(at: index)
}
}
}
.swipeActions(edge: .leading) {
Button("Pin", systemImage: "pin") {
pinItem(item)
}
.tint(.orange)
}
}
.onMove { items.move(fromOffsets: $0, toOffset: $1) }
}
.navigationTitle("Items")
.toolbar { EditButton() }
}
}
}Migration Notes
.swipeActions (iOS 15+) gives you per-row, multi-action swipe menus with custom tints and roles. EditButton, .onDelete, and .onMove remain valid for edit mode and reordering. Prefer .swipeActions when the desired interaction is a contextual row action, and keep edit mode when users need batch delete or reorder workflows.
---
UIApplication.shared.open to @Environment(\.openURL)
Before (App-Coupled Pattern)
Button("Open Website") {
if let url = URL(string: "https://example.com") {
UIApplication.shared.open(url)
}
}After (Modern)
struct LinkButton: View {
@Environment(\.openURL) private var openURL
var body: some View {
Button("Open Website") {
openURL(URL(string: "https://example.com")!)
}
}
}With a completion handler:
openURL(url) { accepted in
if !accepted {
// handle failure to open URL
}
}Migration Notes
@Environment(\.openURL) works on all Apple platforms, not just iOS. It can be overridden in the environment for testing or to intercept URL opens. Avoid reaching for UIApplication.shared in SwiftUI views.
---
@FetchRequest to @Query (SwiftData)
Core Data's @FetchRequest is superseded by SwiftData's @Query macro when you migrate to SwiftData models.
Before (Core Data)
struct ItemListView: View {
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \CDItem.timestamp, ascending: false)],
predicate: NSPredicate(format: "isCompleted == NO")
) private var items: FetchedResults<CDItem>
var body: some View {
List(items) { item in
Text(item.title ?? "")
}
}
}After (SwiftData)
struct ItemListView: View {
@Query(
filter: #Predicate<Item> { !$0.isCompleted },
sort: \.timestamp,
order: .reverse
) private var items: [Item]
var body: some View {
List(items) { item in
Text(item.title)
}
}
}Migration Notes
@Query uses type-safe #Predicate instead of string-based NSPredicate. Sort descriptors use key paths directly. The model container is injected via .modelContainer(for:) on an ancestor view. SwiftData models are plain Swift classes with the @Model macro rather than NSManagedObject subclasses.
---
Opaque return types to some/any clarifications (Swift 5.7+)
Before
func makeView() -> AnyView {
AnyView(Text("Hello"))
}
protocol DataSource {
func fetch() -> AnyPublisher<[Item], Error>
}After (Modern)
func makeView() -> some View {
Text("Hello")
}
protocol DataSource {
func fetch() async throws -> [Item]
}
// When you need a protocol-typed variable:
let source: any DataSource = RemoteDataSource()Migration Notes
Use some for opaque return types when the concrete type is fixed. Use any for existentials when you need to store heterogeneous conformances. Prefer async throws over Combine publishers for new code. Swift 5.7+ allows some in parameter position too:
func display(_ view: some View) { ... }---
.sheet(item:) with Optional Identifiable to Modern Pattern
Before (Fragile Pattern)
@State private var selectedItem: Item?
@State private var showingSheet = false
Button {
selectedItem = item
showingSheet = true
} label: {
ItemRow(item: item)
}
.buttonStyle(.plain)
.sheet(isPresented: $showingSheet) {
if let item = selectedItem {
DetailView(item: item)
}
}After (Modern)
@State private var selectedItem: Item?
Button {
selectedItem = item
} label: {
ItemRow(item: item)
}
.buttonStyle(.plain)
.sheet(item: $selectedItem) { item in
DetailView(item: item)
}Migration Notes
Using .sheet(item:) eliminates the dual-state problem where showingSheet and selectedItem can become out of sync. The sheet presents when the binding becomes non-nil and dismisses when it becomes nil. The unwrapped value is passed directly into the closure.
---
Resolving SwiftUI Color for Interop (iOS 17+)
Before
let color = UIColor.link
let swiftUIColor = Color(uiColor: color)After (Modern)
Color(uiColor:) is still valid when bridging an existing UIKit color. For concrete RGBA values from SwiftUI colors, resolve the color in the current environment:
// Custom colors via asset catalogs (always preferred)
let brand = Color("BrandBlue")
// Resolved colors for interop (iOS 17+)
@Environment(\.self) var environment
let resolved = Color.blue.resolve(in: environment)
// resolved.red, resolved.green, resolved.blue, resolved.opacityMigration Notes
Color.resolve(in:) (iOS 17+) gives you concrete RGBA values in the current environment. Use it for custom runtime color manipulations or lower-level interop. For static brand colors, use asset catalogs; for UIKit colors you already have, keep Color(uiColor:).
---
ForEach with Range to ForEach with Identifiable / indices
Before (Fragile Pattern)
ForEach(0..<items.count) { index in
Text(items[index].name)
}After (Modern)
// Identifiable models
ForEach(items) { item in
Text(item.name)
}
// When you need the index
ForEach(Array(items.enumerated()), id: \.element.id) { index, item in
Text("\(index + 1). \(item.name)")
}
// Subranges with bindable access
ForEach($items) { $item in
TextField("Name", text: $item.name)
}Migration Notes
Constant-range ForEach(0..<n) is only safe when the range never changes. For dynamic data, always use identifiable collections. ForEach($items) provides direct bindings to each element without index arithmetic.
---
Toolbar Placement Names (iOS 16+)
Before
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Back") { dismiss() }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("Edit") { isEditing.toggle() }
}
ToolbarItem(placement: .bottomBar) {
Button("Add") { addItem() }
}
}After (Modern)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Back") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button("Edit") { isEditing.toggle() }
}
ToolbarItem(placement: .bottomBar) {
Button("Add") { addItem() }
}
}Migration Notes
Prefer .topBarLeading and .topBarTrailing (iOS 16+) in modern NavigationStack and NavigationSplitView contexts. The names describe placement without tying the item to a specific navigation-bar implementation.
---
cornerRadius to clipShape(.rect(cornerRadius:))
.cornerRadius(_:) was deprecated in iOS 17.
Before (Deprecated)
RoundedRectangle(cornerRadius: 12)
.cornerRadius(12)
Image("photo")
.cornerRadius(8)After (Modern)
RoundedRectangle(cornerRadius: 12)
.clipShape(.rect(cornerRadius: 12))
Image("photo")
.clipShape(.rect(cornerRadius: 8))Migration Notes
clipShape(.rect(cornerRadius:)) uses RoundedRectangle under the hood and also supports cornerRadii for per-corner control (iOS 16+):
.clipShape(.rect(cornerRadii: .init(topLeading: 12, bottomTrailing: 12)))---
tabItem to Tab (iOS 18+)
The tabItem modifier approach was superseded by the Tab type inside TabView (iOS 18+).
Before (Legacy)
TabView {
HomeView()
.tabItem {
Label("Home", systemImage: "house")
}
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
}After (Modern — iOS 18+)
TabView {
Tab("Home", systemImage: "house") {
HomeView()
}
Tab("Settings", systemImage: "gear") {
SettingsView()
}
}Migration Notes
Tab provides a cleaner API and is required for the new tab sidebar on iPadOS 18+. The tabItem modifier still works but does not support the sidebar presentation. Use Tab with a value parameter and @State selection for programmatic tab switching. TabSection groups tabs in the sidebar.
---
scrollIndicators(.hidden) Replaces showsIndicators Parameter
The showsIndicators parameter on ScrollView is available but the scrollIndicators modifier (iOS 16+) is preferred for consistency.
Before
ScrollView(.vertical, showsIndicators: false) {
content
}After (Modern)
ScrollView {
content
}
.scrollIndicators(.hidden)Migration Notes
.scrollIndicators(_:axes:) accepts .automatic, .visible, .hidden, and .never. It also works on List and TextEditor. The axes parameter lets you control horizontal and vertical indicators independently.
Design Polish
Contents
- HIG Alignment
- Theming and Dynamic Type
- Haptics
- Matched Transitions
- Loading and Placeholders
- Focus Handling
HIG Alignment
iOS Human Interface Guidelines patterns for layout, typography, color, accessibility, and feedback in SwiftUI.
Contents
- Layout and Spacing
- Typography
- Color System
- Navigation Patterns
- Feedback
- Accessibility
- Error and Empty States
Layout and Spacing
Spacing Grid
Omit spacing: on stacks to get SwiftUI's adaptive default. Only specify an explicit value when you need a deliberate departure from the default — and when you do, stick to the 4pt grid below.
This is a common design convention, not an Apple-prescribed system, but it keeps layouts visually coherent. Avoid inventing values between grid stops.
| Points | Token | Typical use |
|---|---|---|
| 4 | .xxSmall | Tight icon-to-label padding, inline badge offsets |
| 8 | .xSmall | Related elements within a group, compact stack gaps |
| 12 | .small | List row internal padding, label-to-secondary-text |
| 16 | .medium | Standard margin, default section gap |
| 20 | .mediumLarge | Comfortable breathing room between distinct controls |
| 24 | .large | Section separators, card internal padding |
| 32 | .xLarge | Major groupings, header-to-content gap |
| 40 | .xxLarge | Large section breaks |
| 48 | .xxxLarge | Hero/splash spacing, onboarding screens |
enum Spacing {
static let xxSmall: CGFloat = 4
static let xSmall: CGFloat = 8
static let small: CGFloat = 12
static let medium: CGFloat = 16
static let mediumLarge: CGFloat = 20
static let large: CGFloat = 24
static let xLarge: CGFloat = 32
static let xxLarge: CGFloat = 40
static let xxxLarge: CGFloat = 48
}Standard Margins
private let standardMargin: CGFloat = 16
private let compactMargin: CGFloat = 8
private let largeMargin: CGFloat = 24
extension EdgeInsets {
static let standard = EdgeInsets(top: 16, leading: 16, bottom: 16, trailing: 16)
static let listRow = EdgeInsets(top: 12, leading: 16, bottom: 12, trailing: 16)
}Safe Area Handling
ScrollView {
LazyVStack {
ForEach(items) { item in
ItemRow(item: item)
}
}
.padding(.horizontal)
}
.safeAreaInset(edge: .bottom) {
HStack {
Button("Cancel") { }
.buttonStyle(.bordered)
Spacer()
Button("Confirm") { }
.buttonStyle(.borderedProminent)
}
.padding()
.background(.regularMaterial)
}Adaptive Layouts
Use horizontalSizeClass to adapt between compact and regular widths:
@Environment(\.horizontalSizeClass) private var sizeClass
private var columns: [GridItem] {
switch sizeClass {
case .compact:
[GridItem(.flexible())]
case .regular:
[GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
default:
[GridItem(.flexible())]
}
}Typography
System Font Styles
Use system font styles for automatic Dynamic Type support:
| Style | Size | Weight | Usage |
|---|---|---|---|
.largeTitle | 34pt | Regular | Screen titles |
.title | 28pt | Regular | Section headers |
.title2 | 22pt | Regular | Sub-section headers |
.title3 | 20pt | Regular | Group headers |
.headline | 17pt | Semibold | Row titles |
.body | 17pt | Regular | Primary content |
.callout | 16pt | Regular | Secondary content |
.subheadline | 15pt | Regular | Supporting text |
.footnote | 13pt | Regular | Tertiary info |
.caption | 12pt | Regular | Labels |
.caption2 | 11pt | Regular | Small labels |
Custom Font with Dynamic Type
extension Font {
static func customBody(_ name: String) -> Font {
.custom(name, size: 17, relativeTo: .body)
}
}Color System
Semantic Colors
Use semantic colors for automatic light/dark mode support:
// Labels
Color.primary // Primary text
Color.secondary // Secondary text
Color(uiColor: .tertiaryLabel)
// Backgrounds
Color(uiColor: .systemBackground)
Color(uiColor: .secondarySystemBackground)
Color(uiColor: .systemGroupedBackground)
// Fills and Separators
Color(uiColor: .systemFill)
Color(uiColor: .separator)Tint Colors
// Apply app-wide tint
ContentView()
.tint(.blue)Use .tint(...) or .foregroundStyle(.tint) for interactive elements and Color.red for destructive actions.
Navigation Patterns
Hierarchical (NavigationSplitView)
Use for iPad/macOS multi-column layouts:
NavigationSplitView {
List(items, selection: $selectedItem) { item in
NavigationLink(value: item) { ItemRow(item: item) }
}
.navigationTitle("Items")
} detail: {
if let item = selectedItem {
ItemDetailView(item: item)
} else {
ContentUnavailableView("Select an Item", systemImage: "sidebar.leading")
}
}Tab-Based
Use TabView with a NavigationStack per tab. See the swiftui-navigation skill for full tab patterns.
Toolbar
.toolbar {
ToolbarItem(placement: .topBarLeading) { EditButton() }
ToolbarItemGroup(placement: .topBarTrailing) {
Button("Filter", systemImage: "line.3.horizontal.decrease.circle") { }
Button("Add", systemImage: "plus") { }
}
ToolbarItemGroup(placement: .bottomBar) {
Button("Archive", systemImage: "archivebox") { }
Spacer()
Text("\(itemCount) items").font(.footnote).foregroundStyle(.secondary)
Spacer()
Button("Share", systemImage: "square.and.arrow.up") { }
}
}Search Integration
.searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always))
.searchScopes($searchScope) {
ForEach(SearchScope.allCases, id: \.self) { scope in
Text(scope.rawValue.capitalized).tag(scope)
}
}Feedback
Haptic Feedback
Prefer SwiftUI's sensoryFeedback(_:trigger:) for state-driven feedback in SwiftUI views.
Button("Save") {
didSave.toggle()
}
.sensoryFeedback(.success, trigger: didSave)
Picker("Sort", selection: $sortOrder) {
Text("Recent").tag(SortOrder.recent)
Text("Popular").tag(SortOrder.popular)
}
.sensoryFeedback(.selection, trigger: sortOrder)Use the UIKit generators only when you need imperative feedback from UIKit or non-SwiftUI integration points.
See the Haptics section below for structured patterns.
Accessibility
VoiceOver Support
VStack(alignment: .leading) {
Text(item.title).font(.headline)
Text(item.subtitle).font(.subheadline).foregroundStyle(.secondary)
HStack {
Image(systemName: "star.fill")
Text("\(item.rating, specifier: "%.1f")")
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel("\(item.title), \(item.subtitle)")
.accessibilityValue("Rating: \(item.rating) stars")
.accessibilityHint("Double tap to view details")
.accessibilityAddTraits(.isButton)Dynamic Type Support
Adapt layout for accessibility sizes:
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
var body: some View {
if dynamicTypeSize.isAccessibilitySize {
VStack(alignment: .leading) {
leadingContent
trailingContent
}
} else {
HStack {
leadingContent
Spacer()
trailingContent
}
}
}Error and Empty States
Use ContentUnavailableView for both:
// Error state
ContentUnavailableView {
Label("Unable to Load", systemImage: "exclamationmark.triangle")
} description: {
Text(error.localizedDescription)
} actions: {
Button("Try Again") { Task { await retry() } }
.buttonStyle(.borderedProminent)
}
// Empty state
ContentUnavailableView {
Label("No Photos", systemImage: "camera")
} description: {
Text("Take your first photo to get started.")
} actions: {
Button("Take Photo") { showCamera = true }
.buttonStyle(.borderedProminent)
}Theming and Dynamic Type
Intent
Provide a clean, scalable theming approach that keeps view code semantic and consistent.
Core patterns
- Use a single
Themeobject as the source of truth (colors, fonts, spacing). - Inject theme at the app root and read it via
@Environment(Theme.self)in views. - Prefer semantic colors (
primaryBackground,secondaryBackground,label,tint) instead of raw colors. - Keep user-facing theme controls in a dedicated settings screen.
- Apply Dynamic Type scaling through text styles,
Font.custom(_:size:relativeTo:), or@ScaledMetricfor numeric layout values.
Example: Theme object
@MainActor
@Observable
final class Theme {
var tintColor: Color = .blue
var primaryBackground: Color = .white
var secondaryBackground: Color = .gray.opacity(0.1)
var labelColor: Color = .primary
var fontSizeScale: Double = 1.0
}Example: inject at app root
@main
struct MyApp: App {
@State private var theme = Theme()
var body: some Scene {
WindowGroup {
AppView()
.environment(theme)
}
}
}Example: view usage
struct ProfileView: View {
@Environment(Theme.self) private var theme
var body: some View {
VStack {
Text("Profile")
.foregroundStyle(theme.labelColor)
}
.background(theme.primaryBackground)
}
}Design choices to keep
- Keep theme values semantic and minimal; avoid duplicating system colors.
- Store user-selected theme values in persistent storage if needed.
- Ensure contrast between text and backgrounds.
Pitfalls
- Avoid sprinkling raw
Colorvalues in views; it breaks consistency. - Do not tie theme to a single view’s local state.
- Avoid using
@Environment(\.colorScheme)as the only theme control; it should complement your theme.
Haptics
Intent
Use haptics sparingly to reinforce user actions (tab selection, refresh, success/error) and respect user preferences.
Core patterns
- Prefer
sensoryFeedback(_:trigger:)in SwiftUI views for state-driven feedback. - Centralize imperative feedback in a
HapticManageronly when UIKit interop or non-view code requires it. - Gate haptics behind user preferences and hardware support.
- Use distinct types for different UX moments (selection vs. notification vs. refresh).
- Escalate to Core Haptics only for custom patterns that exceed SwiftUI's built-in feedback types.
SwiftUI-first pattern
struct SaveButton: View {
@State private var saveToken = 0
var body: some View {
Button("Save") {
persistChanges()
saveToken += 1
}
.sensoryFeedback(.success, trigger: saveToken)
}
}UIKit interop pattern
@MainActor
final class HapticManager {
static let shared = HapticManager()
enum HapticType {
case buttonPress
case tabSelection
case dataRefresh(intensity: CGFloat)
case notification(UINotificationFeedbackGenerator.FeedbackType)
}
private let selectionGenerator = UISelectionFeedbackGenerator()
private let impactGenerator = UIImpactFeedbackGenerator(style: .heavy)
private let notificationGenerator = UINotificationFeedbackGenerator()
private init() { selectionGenerator.prepare() }
func fire(_ type: HapticType, isEnabled: Bool) {
guard isEnabled else { return }
switch type {
case .buttonPress:
impactGenerator.impactOccurred()
case .tabSelection:
selectionGenerator.selectionChanged()
case let .dataRefresh(intensity):
impactGenerator.impactOccurred(intensity: intensity)
case let .notification(style):
notificationGenerator.notificationOccurred(style)
}
}
}Example: usage
Button("Save") {
HapticManager.shared.fire(.notification(.success), isEnabled: preferences.hapticsEnabled)
}
TabView(selection: $selectedTab) { /* tabs */ }
.onChange(of: selectedTab) { _, _ in
HapticManager.shared.fire(.tabSelection, isEnabled: preferences.hapticTabSelectionEnabled)
}Design choices to keep
- Haptics should be subtle and not fire on every tiny interaction.
- Respect user preferences (toggle to disable).
- Keep haptic triggers close to the user action, not deep in data layers.
Pitfalls
- Avoid firing multiple haptics in quick succession.
- Do not assume haptics are available; check support.
Core Haptics (CHHapticEngine)
For advanced haptic patterns beyond the simple feedback generators, use Core Haptics. It provides precise control over haptic intensity, sharpness, and timing with support for audio-haptic synchronization.
Docs: CHHapticEngine · Preparing your app to play haptics
Capabilities check
Always verify hardware support before creating an engine:
import CoreHaptics
let supportsHaptics = CHHapticEngine.capabilitiesForHardware().supportsHaptics
let supportsAudio = CHHapticEngine.capabilitiesForHardware().supportsAudioEngine setup and lifecycle
@MainActor
final class CoreHapticManager {
private var engine: CHHapticEngine?
func prepareEngine() throws {
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
engine = try CHHapticEngine()
// Called when the engine stops due to external cause (audio session interruption, app backgrounding)
engine?.stoppedHandler = { reason in
print("Haptic engine stopped: \(reason)")
}
// Called after the engine is reset (e.g., after audio session interruption ends)
engine?.resetHandler = { [weak self] in
do {
try self?.engine?.start()
} catch {
print("Failed to restart engine: \(error)")
}
}
try engine?.start()
}
func stopEngine() {
engine?.stop()
}
}Key lifecycle rules:
- Call
engine.start()before playing any patterns. - Handle
stoppedHandler— the system can stop the engine when your app moves to the background or during audio interruptions. - Handle
resetHandler— restart the engine when the system resets it. - Call
engine.stop()when haptics are no longer needed to save battery.
CHHapticPattern and CHHapticEvent
Build patterns from individual haptic and audio events:
func playTransientTap() throws {
let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.8)
let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0)
// Transient: short, single-tap feel
let event = CHHapticEvent(
eventType: .hapticTransient,
parameters: [intensity, sharpness],
relativeTime: 0
)
let pattern = try CHHapticPattern(events: [event], parameters: [])
let player = try engine?.makePlayer(with: pattern)
try player?.start(atTime: CHHapticTimeImmediate)
}Event types:
| Type | Description |
|---|---|
.hapticTransient | Brief, tap-like impulse |
.hapticContinuous | Sustained vibration over a duration |
.audioContinuous | Sustained audio tone |
.audioCustom | Play a custom audio resource |
Common parameters: .hapticIntensity (0–1), .hapticSharpness (0–1), .attackTime, .decayTime, .releaseTime.
Playing patterns with CHHapticPatternPlayer
func playContinuousBuzz() throws {
let intensity = CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.6)
let sharpness = CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3)
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [intensity, sharpness],
relativeTime: 0,
duration: 0.5
)
let pattern = try CHHapticPattern(events: [event], parameters: [])
let player = try engine?.makePlayer(with: pattern)
try player?.start(atTime: CHHapticTimeImmediate)
}For looping, seeking, and pausing, use CHHapticAdvancedPatternPlayer via engine.makeAdvancedPlayer(with:).
Haptic parameter curves (CHHapticParameterCurve)
Smoothly vary parameters over time within a pattern:
func playRampingPattern() throws {
let event = CHHapticEvent(
eventType: .hapticContinuous,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.2),
CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.1)
],
relativeTime: 0,
duration: 1.0
)
// Ramp intensity from 0.2 → 1.0 over 1 second
let curve = CHHapticParameterCurve(
parameterID: .hapticIntensityControl,
controlPoints: [
.init(relativeTime: 0, value: 0.2),
.init(relativeTime: 0.5, value: 0.7),
.init(relativeTime: 1.0, value: 1.0)
],
relativeTime: 0
)
let pattern = try CHHapticPattern(events: [event], parameterCurves: [curve])
let player = try engine?.makePlayer(with: pattern)
try player?.start(atTime: CHHapticTimeImmediate)
}Audio-haptic synchronization (AHAP files)
AHAP (Apple Haptic and Audio Pattern) files define haptic patterns in JSON for easy authoring and design iteration. Load them directly:
func playAHAPFile() throws {
guard let url = Bundle.main.url(forResource: "success", withExtension: "ahap") else { return }
try engine?.playPattern(from: url)
}AHAP files support the same events, parameters, and parameter curves as the programmatic API. Use the Core Haptics design tools in Xcode to preview patterns.
Docs: Representing haptic patterns in AHAP files
Matched Transitions
Intent
Use matched transitions to create smooth continuity between a source view (thumbnail, avatar) and a destination view (sheet, detail, viewer).
Core patterns
- Use a shared
Namespaceand a stable ID for the source. - Use
matchedTransitionSource+navigationTransition(.zoom(...))on iOS 18+. - Use
matchedGeometryEffectfor in-place transitions within a view hierarchy. - Keep IDs stable across view updates (avoid random UUIDs).
Example: media preview to full-screen viewer (iOS 18+)
struct MediaPreview: View {
@Namespace private var namespace
let attachments: [MediaAttachment]
var body: some View {
NavigationStack {
List(attachments) { attachment in
NavigationLink(value: attachment) {
ThumbnailView(attachment: attachment)
.matchedTransitionSource(id: attachment.id, in: namespace)
}
}
.navigationDestination(for: MediaAttachment.self) { item in
MediaViewer(item: item)
.navigationTransition(.zoom(sourceID: item.id, in: namespace))
}
}
}
}Example: matched geometry within a view
struct ToggleBadge: View {
@Namespace private var space
@State private var isOn = false
var body: some View {
Button {
withAnimation(.spring) { isOn.toggle() }
} label: {
Image(systemName: isOn ? "eye" : "eye.slash")
.matchedGeometryEffect(id: "icon", in: space)
}
}
}Design choices to keep
- Prefer
matchedTransitionSourcefor supported navigation zoom transitions. - Keep source and destination sizes reasonable to avoid jarring scale changes.
- Use
withAnimationfor state-driven transitions.
Pitfalls
- Don’t use unstable IDs; it breaks the transition.
- Avoid mismatched shapes (e.g., square to circle) unless the design expects it.
Loading and Placeholders
Use this when a view needs a consistent loading state (skeletons, redaction, empty state) without blocking interaction.
Patterns to prefer
- Redacted placeholders for list/detail content to preserve layout while loading.
- ContentUnavailableView for empty or error states after loading completes.
- ProgressView only for short, global operations (use sparingly in content-heavy screens).
Recommended approach
1. Keep the real layout, render placeholder data, then apply .redacted(reason: .placeholder). 2. For lists, show a fixed number of placeholder rows (avoid infinite spinners). 3. Switch to ContentUnavailableView when load finishes but data is empty.
Pitfalls
- Don’t animate layout shifts during redaction; keep frames stable.
- Avoid nesting multiple spinners; use one loading indicator per section.
- Keep placeholder count small (3–6) to reduce jank on low-end devices.
Minimal usage
VStack {
if isLoading {
ForEach(0..<3, id: \.self) { _ in
RowView(model: .placeholder())
}
.redacted(reason: .placeholder)
} else if items.isEmpty {
ContentUnavailableView("No items", systemImage: "tray")
} else {
ForEach(items) { item in RowView(model: item) }
}
}Focus Handling
This file covers basic form-focus patterns only. For directional focus, focus sections, scene-focused values, and UIFocusGuide, see the focus-engine skill.
Intent
Use @FocusState to control keyboard focus, chain fields, and coordinate focus across complex forms.
Core patterns
- Use an enum to represent focusable fields.
- Set initial focus in
onAppear. - Use
.onSubmitto move focus to the next field. - For dynamic lists of fields, use an enum with associated values (e.g.,
.option(Int)).
Example: single field focus
struct AddServerView: View {
@State private var server = ""
@FocusState private var isServerFieldFocused: Bool
var body: some View {
Form {
TextField("Server", text: $server)
.focused($isServerFieldFocused)
}
.onAppear { isServerFieldFocused = true }
}
}Example: chained focus with enum
struct EditTagView: View {
enum FocusField { case title, symbol, newTag }
@FocusState private var focusedField: FocusField?
var body: some View {
Form {
TextField("Title", text: $title)
.focused($focusedField, equals: .title)
.onSubmit { focusedField = .symbol }
TextField("Symbol", text: $symbol)
.focused($focusedField, equals: .symbol)
.onSubmit { focusedField = .newTag }
}
.onAppear { focusedField = .title }
}
}Example: dynamic focus for variable fields
struct PollView: View {
enum FocusField: Hashable { case option(Int) }
@FocusState private var focused: FocusField?
@State private var options: [String] = ["", ""]
@State private var currentIndex = 0
var body: some View {
ForEach(options.indices, id: \.self) { index in
TextField("Option \(index + 1)", text: $options[index])
.focused($focused, equals: .option(index))
.onSubmit { addOption(at: index) }
}
.onAppear { focused = .option(0) }
}
private func addOption(at index: Int) {
options.append("")
currentIndex = index + 1
Task { @MainActor in
try? await Task.sleep(for: .milliseconds(10))
focused = .option(currentIndex)
}
}
}Design choices to keep
- Keep focus state local to the view that owns the fields.
- Use focus changes to drive UX (validation messages, helper UI).
- Pair with
.scrollDismissesKeyboard(...)when using ScrollView/Form.
Pitfalls
- Don’t store focus state in shared objects; it is view-local.
- Avoid aggressive focus changes during animation; delay if needed.
Platform And Sharing
Contents
- Transferable, Drag & Drop, and ShareLink
- Media Patterns
- Top Bar Overlays
- Title Menus
- Input Toolbar
- Menu Bar Commands
- macOS Settings
Transferable, Drag & Drop, and ShareLink
Intent
Adopt the Transferable protocol to enable sharing, drag and drop, copy/paste, and ShareLink with a unified API. Available iOS 16+.
Docs: Transferable · Choosing a transfer representation
Transferable protocol overview
Transferable describes how a type converts to and from transfer representations (clipboard, drag, share sheet). Conform by implementing a static transferRepresentation property.
struct Note: Codable, Identifiable {
let id: UUID
var title: String
var body: String
}
extension Note: Transferable {
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: .note)
ProxyRepresentation(exporting: \.body) // fallback: plain text
}
}
extension UTType {
static let note = UTType(exportedAs: "com.example.note")
}Representation order matters — place the most specific first, with broader fallbacks after.
Common transferable values
Use existing transferable values as proxies or fallbacks when they describe your data accurately:
| Value | Typical use |
|---|---|
String | Plain-text previews, titles, or body fallbacks |
Data | Binary payloads when you control serialization |
URL | Links and file references |
Color (SwiftUI) | Color transfer; semantic colors resolve against a default environment |
Codable models | Custom JSON representations via CodableRepresentation |
TransferRepresentation types
CodableRepresentation
For types conforming to Codable. Serializes to JSON by default:
static var transferRepresentation: some TransferRepresentation {
CodableRepresentation(contentType: .myType)
}ProxyRepresentation
Delegate to another Transferable type. Ideal for quick text or URL fallbacks:
ProxyRepresentation(exporting: \.title) // export only
ProxyRepresentation(\.url) // import + export via URLDataRepresentation
Full control over binary serialization:
DataRepresentation(contentType: .png) { image in
try image.pngData()
} importing: { data in
try MyImage(data: data)
}Use DataRepresentation(exportedContentType:) for export-only representations.
FileRepresentation
For large content best transferred as files:
FileRepresentation(contentType: .movie) { video in
SentTransferredFile(video.fileURL)
} importing: { receivedFile in
let dest = FileManager.default.temporaryDirectory.appendingPathComponent(receivedFile.file.lastPathComponent)
try FileManager.default.copyItem(at: receivedFile.file, to: dest)
return Video(url: dest)
}ShareLink
Present the system share sheet with a Transferable item:
ShareLink(item: note, preview: SharePreview(note.title)) {
Label("Share", systemImage: "square.and.arrow.up")
}
// Multiple items
ShareLink(items: selectedNotes) { note in
SharePreview(note.title)
}
// Simple string sharing
ShareLink(item: "Check out this app!", subject: Text("Cool App"))ShareLink requires the item to conform to Transferable. The preview provides a title, optional image, and optional icon for the share sheet.
Drag and drop
Making views draggable
struct NoteCard: View {
let note: Note
var body: some View {
Text(note.title)
.draggable(note) // Note must be Transferable
}
}Use .draggable(note) { DragPreview(note) } to provide a custom drag preview.
Drop destination
struct NoteBoard: View {
@State private var notes: [Note] = []
var body: some View {
VStack {
ForEach(notes) { NoteCard(note: $0) }
}
.dropDestination(for: Note.self) { droppedNotes, location in
notes.append(contentsOf: droppedNotes)
return true
} isTargeted: { isOver in
// Highlight drop zone
}
}
}For reordering within a list, combine .draggable with .dropDestination or use onMove on ForEach inside List.
Handling multiple types
Accept multiple content types with separate .dropDestination modifiers or use DropDelegate for advanced logic:
.dropDestination(for: String.self) { strings, _ in
notes.append(contentsOf: strings.map { Note(id: UUID(), title: $0, body: "") })
return true
}Pasteboard integration
For direct clipboard access outside SwiftUI's drag/drop system, use UIPasteboard:
// Copy
UIPasteboard.general.string = note.title
// Paste
if let text = UIPasteboard.general.string {
// use text
}For Transferable types with custom content types, export to Data first:
let data = try await note.exported(as: .note)
UIPasteboard.general.setData(data, forPasteboardType: UTType.note.identifier)On macOS 13+, prefer SwiftUI's .copyable, .cuttable, and command-based .pasteDestination(for:action:validator:) modifiers over direct pasteboard usage when you are wiring Edit menu commands and keyboard shortcuts. Current Apple docs list those command modifiers as iOS/iPadOS/Mac Catalyst 27 beta, not iOS 26 defaults. On iOS 26 targets, use UIPasteboard directly for custom clipboard commands, or use SwiftUI drag/drop and ShareLink for Transferable-driven sharing flows.
Docs: copyable(_:)) · cuttable(for:action:)) · pasteDestination(for:action:validator:))
Common patterns
Transferable enum with multiple representations
enum SharedContent: Transferable {
case text(String)
case url(URL)
static var transferRepresentation: some TransferRepresentation {
ProxyRepresentation { content in
switch content {
case .text(let s): return s
case .url(let u): return u.absoluteString
}
}
}
}Export-only conformance
When your type should be sharable but not importable:
extension Report: Transferable {
static var transferRepresentation: some TransferRepresentation {
DataRepresentation(exportedContentType: .pdf) { report in
try report.renderPDF()
}
}
}Pitfalls
- Always declare custom
UTTypeidentifiers in Info.plist under Exported/Imported Type Identifiers. - Representation order matters — the first matching representation wins. Put the richest format first.
FileRepresentationfiles are temporary; copy them if you need to persist.- Keep custom
UTTypedeclarations and transfer code in modules that are visible to every app target or extension that imports, exports, drags, or shares the type. - Test drag and drop on device — Simulator haptics and drop targeting differ from hardware.
Media Patterns
Intent
Use consistent patterns for loading images, previewing media, and presenting a full-screen viewer.
Core patterns
- Use
AsyncImagefor simple remote images.LazyImageis from the third-party Nuke library if you need advanced caching and prefetching. - Prefer a lightweight preview component for inline media.
- Use a shared viewer state (e.g.,
QuickLook) to present a full-screen media viewer. - Use
openWindowfor desktop/visionOS and a sheet for iOS.
Example: inline media preview
struct MediaPreviewRow: View {
@Environment(QuickLook.self) private var quickLook
let attachments: [MediaAttachment]
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(attachments) { attachment in
Button {
quickLook.prepareFor(
selectedMediaAttachment: attachment,
mediaAttachments: attachments
)
} label: {
LazyImage(url: attachment.previewURL) { state in
if let image = state.image {
image.resizable().aspectRatio(contentMode: .fill)
} else {
ProgressView()
}
}
.frame(width: 120, height: 120)
.clipped()
}
.buttonStyle(.plain)
}
}
}
}
}Example: global media viewer sheet
struct AppRoot: View {
@State private var quickLook = QuickLook.shared
var body: some View {
content
.environment(quickLook)
.sheet(item: $quickLook.selectedMediaAttachment) { selected in
MediaUIView(selectedAttachment: selected, attachments: quickLook.mediaAttachments)
}
}
}Design choices to keep
- Keep previews lightweight; load full media in the viewer.
- Use shared viewer state so any view can open media without prop-drilling.
- Use a single entry point for the viewer (sheet/window) to avoid duplicates.
Pitfalls
- Avoid loading full-size images in list rows; use resized previews.
- Don’t present multiple viewer sheets at once; keep a single source of truth.
Top Bar Overlays
Intent
Provide a custom top selector or pill row that sits above scroll content, using safeAreaBar(edge: .top) on iOS 26 and a compatible fallback on earlier OS versions.
iOS 26+ approach
Use safeAreaBar(edge: .top) to attach the view to the safe area bar. It insets the modified view, adjusts the safe area, and extends affected scroll edge effects.
if #available(iOS 26.0, *) {
content
.safeAreaBar(edge: .top) {
TopSelectorView()
.padding(.horizontal, .layoutPadding)
}
}Fallback for earlier iOS
Use .safeAreaInset(edge: .top) and hide the toolbar background to avoid double layers.
content
.toolbarBackground(.hidden, for: .navigationBar)
.safeAreaInset(edge: .top, spacing: 0) {
VStack(spacing: 0) {
TopSelectorView()
.padding(.vertical)
.padding(.horizontal, .layoutPadding)
.background(Color.primary.opacity(0.06))
.background(Material.ultraThin)
Divider()
}
}Design choices to keep
- Use
safeAreaBarwhen available; it integrates better with the navigation bar. - Use a subtle background + divider in the fallback to keep separation from content.
- Keep the selector height compact to avoid pushing content too far down.
Pitfalls
- Don’t stack multiple top insets; it can create extra padding.
- Avoid heavy, opaque backgrounds that fight the navigation bar.
Title Menus
Intent
Use a title menu in the navigation bar to provide context‑specific filtering or quick actions without adding extra chrome.
Core patterns
- Use
ToolbarTitleMenuto attach a menu to the navigation title. - Keep the menu content compact and grouped with dividers.
Example: title menu for filters
@ToolbarContentBuilder
private var toolbarView: some ToolbarContent {
ToolbarTitleMenu {
Button("Latest") { timeline = .latest }
Button("Resume") { timeline = .resume }
Divider()
Button("Local") { timeline = .local }
Button("Federated") { timeline = .federated }
}
}Example: attach to a view
NavigationStack {
TimelineView()
.toolbar {
toolbarView
}
}Example: title + menu together
struct TimelineScreen: View {
@State private var timeline: TimelineFilter = .home
var body: some View {
NavigationStack {
TimelineView()
.toolbar {
ToolbarItem(placement: .principal) {
VStack(spacing: 2) {
Text(timeline.title)
.font(.headline)
Text(timeline.subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}
ToolbarTitleMenu {
Button("Home") { timeline = .home }
Button("Local") { timeline = .local }
Button("Federated") { timeline = .federated }
}
}
.navigationBarTitleDisplayMode(.inline)
}
}
}Example: title + subtitle with menu
ToolbarItem(placement: .principal) {
VStack(spacing: 2) {
Text(title)
.font(.headline)
Text(subtitle)
.font(.caption)
.foregroundStyle(.secondary)
}
}Design choices to keep
- Only show the title menu when filtering or context switching is available.
- Keep the title readable; avoid long labels that truncate.
- Use secondary text below the title if extra context is needed.
Pitfalls
- Don’t overload the menu with too many options.
- Avoid using title menus for destructive actions.
Input Toolbar
Intent
Use a bottom-anchored input bar for chat, composer, or quick actions without fighting the keyboard.
Core patterns
- Use
.safeAreaInset(edge: .bottom)to anchor the toolbar above the keyboard. - Keep the main content in a
ScrollVieworList. - Drive focus with
@FocusStateand set initial focus when needed. - Avoid embedding the input bar inside the scroll content; keep it separate.
Example: scroll view + bottom input
@MainActor
struct ConversationView: View {
@FocusState private var isInputFocused: Bool
@State private var scrollPosition = ScrollPosition(edge: .bottom)
@State private var draft = ""
var body: some View {
ScrollView {
LazyVStack {
ForEach(messages) { message in
MessageRow(message: message)
}
}
.scrollTargetLayout()
.padding(.horizontal, .layoutPadding)
}
.scrollPosition($scrollPosition)
.safeAreaInset(edge: .bottom) {
InputBar(text: $draft)
.focused($isInputFocused)
}
.scrollDismissesKeyboard(.interactively)
.onAppear { isInputFocused = true }
}
}Design choices to keep
- Keep the input bar visually separated from the scrollable content.
- Use
.scrollDismissesKeyboard(.interactively)for chat-like screens. - Ensure send actions are reachable via keyboard return or a clear button.
Pitfalls
- Avoid placing the input view inside the scroll stack; it will jump with content.
- Avoid nested scroll views that fight for drag gestures.
Menu Bar Commands
Contents
- Intent
- Core patterns
- Example: basic command menu
- Example: insert and replace groups
- Example: focused menu state
- Menu bar and Settings
- Pitfalls
Intent
Use this when adding or customizing the macOS/iPadOS menu bar with SwiftUI commands.
Core patterns
- Add commands at the
Scenelevel with.commands { ... }. - Use
SidebarCommands()when your UI includes a navigation sidebar. - Use
CommandMenufor app-specific menus and group related actions. - Use
CommandGroupto insert items before/after system groups or replace them. - Use
FocusedValuefor context-sensitive menu items that depend on the active scene.
Example: basic command menu
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
.commands {
CommandMenu("Actions") {
Button("Run", action: run)
.keyboardShortcut("R")
Button("Stop", action: stop)
.keyboardShortcut(".")
}
}
}
private func run() {}
private func stop() {}
}Example: insert and replace groups
WindowGroup {
ContentView()
}
.commands {
CommandGroup(before: .systemServices) {
Button("Check for Updates") { /* open updater */ }
}
CommandGroup(after: .newItem) {
Button("New from Clipboard") { /* create item */ }
}
CommandGroup(replacing: .help) {
Button("User Manual") { /* open docs */ }
}
}Example: focused menu state
@MainActor @Observable
final class DataModel {
var items: [String] = []
}
struct ContentView: View {
@State private var model = DataModel()
var body: some View {
List(model.items, id: \.self) { item in
Text(item)
}
.focusedSceneValue(model)
}
}
struct ItemCommands: Commands {
@FocusedValue(DataModel.self) private var model: DataModel?
var body: some Commands {
CommandGroup(after: .newItem) {
Button("New Item") {
model?.items.append("Untitled")
}
.disabled(model == nil)
}
}
}Menu bar and Settings
- Defining a
Settingsscene adds the Settings menu item on macOS automatically. - If you need a custom entry point inside the app, use
OpenSettingsActionorSettingsLink.
Pitfalls
- Avoid registering the same keyboard shortcut in multiple command groups.
- Don’t use menu items as the only discoverable entry point for critical features.
macOS Settings
Intent
Use this when building a macOS Settings window backed by SwiftUI's Settings scene.
Core patterns
- Declare the Settings scene in the
Appand compile it only for macOS. - Keep settings content in a dedicated root view (
SettingsView) and drive values with@AppStorage. - Use
TabViewto group settings sections when you have more than one category. - Use
Forminside each tab to keep controls aligned and accessible. - Use
OpenSettingsActionorSettingsLinkfor in-app entry points to the Settings window.
Example: settings scene
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
#if os(macOS)
Settings {
SettingsView()
}
#endif
}
}Example: tabbed settings view
@MainActor
struct SettingsView: View {
@AppStorage("showPreviews") private var showPreviews = true
@AppStorage("fontSize") private var fontSize = 12.0
var body: some View {
TabView {
Tab("General", systemImage: "gear") {
Form {
Toggle("Show Previews", isOn: $showPreviews)
Slider(value: $fontSize, in: 9...96) {
Text("Font Size (\(fontSize, specifier: "%.0f") pts)")
}
}
}
Tab("Advanced", systemImage: "star") {
Form {
Toggle("Enable Advanced Mode", isOn: .constant(false))
}
}
}
.scenePadding()
.frame(maxWidth: 420, minHeight: 240)
}
}Skip navigation
- Avoid wrapping
SettingsViewin aNavigationStackunless you truly need deep push navigation. - Prefer tabs or sections; Settings is already presented as a separate window and should feel flat.
- If you must show hierarchical settings, use a single
NavigationSplitViewwith a sidebar list of categories.
Pitfalls
- Don’t reuse iOS-only settings layouts (full-screen stacks, toolbar-heavy flows).
- Avoid large custom view hierarchies inside
Form; keep rows focused and accessible.
Related skills
How it compares
Use swiftui-patterns when simplifying MV-heavy SwiftUI code; reach for platform API docs when the task is single-component styling only.
FAQ
Does swiftui-patterns require MVVM?
No. It defaults to Model-View where views are lightweight state expressions and models or services own business logic, adding ViewModels only when existing code already uses them or rules justify them.
How should @Observable objects be wrapped in views?
@State when the view owns the object, let for read-only observation, @Bindable for two-way bindings, and @Environment(Type.self) for shared stores.
What async loading pattern does it recommend?
Always use .task which cancels on disappear, with .refreshable for pull-to-refresh and .task(id:) when dependencies like searchText change.
Is Swiftui Patterns safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.