
Axiom Swiftui
- 783 installs
- 1.1k repo stars
- Updated August 3, 2026
- charleswiltgen/axiom
axiom-swiftui is a SwiftUI router skill for Claude Code and Codex that maps symptoms to indexed sub-skill references for developers building or fixing iOS SwiftUI views, navigation, layout, and performance.
About
axiom-swiftui is an Axiom router skill that forces coding agents to route any SwiftUI task through indexed sub-skill references instead of guessing Apple APIs. The SKILL.md quick-reference table maps 18 symptom areas—view updates, previews, hot reload, navigation, layout, performance, gestures, toolbars, and iOS 26 features—to dedicated markdown files such as skills/debugging.md, skills/nav.md, and skills/26-ref.md. A decision-tree diagram and conflict-resolution rules tell agents to try SwiftUI-specific performance fixes before general profiling. Automated scanning hooks launch swiftui-architecture-auditor, swiftui-performance-analyzer, and liquid-glass-auditor agents. Axiom overall ships 259 skills and 41 agents for Apple OS development. Developers reach for axiom-swiftui on any SwiftUI view, NavigationStack, animation, preview, or Liquid Glass task in an Xcode project.
- Mandatory router for ANY SwiftUI work per skill HARD requirement
- Symptom-to-reference table: debugging, nav, layout, performance, architecture, animations, containers
- Dedicated preview guidance including @Previewable, PreviewModifier, and variant matrices
- Escalation paths to diagnostic refs when first-line debugging or nav fixes fail
- Covers iOS 26-oriented features called out in the skill description
Axiom Swiftui by the numbers
- 783 all-time installs (skills.sh)
- Ranked #248 of 1,039 Mobile Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/charleswiltgen/axiom --skill axiom-swiftuiAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 783 |
|---|---|
| repo stars | ★ 1.1k |
| Security audit | 2 / 3 scanners passed |
| Last updated | August 3, 2026 |
| Repository | charleswiltgen/axiom ↗ |
How do you fix SwiftUI views not updating?
Route any SwiftUI view, navigation, layout, animation, performance, or debugging task through Axiom’s indexed sub-skill references instead of guessing APIs.
Who is it for?
iOS developers using Claude Code or Codex who want symptom-based SwiftUI routing across debugging, navigation, layout, and iOS 26 APIs.
Skip if: Developers working only in UIKit or server-side Swift should use axiom-uikit or backend skills instead of axiom-swiftui.
When should I use this skill?
Any SwiftUI view, state, navigation, layout, animation, preview, gesture, or iOS 26 feature task arises in an Xcode project.
What you get
Targeted sub-skill guidance, decision-tree routing to debugging or nav references, and optional SwiftUI audit agent reports.
- Sub-skill routing decisions
- SwiftUI audit agent reports
- Referenced fix patterns
By the numbers
- Indexes 18 SwiftUI sub-skill reference routes in the quick-reference table
- Part of the Axiom catalog with 259 skills and 41 agents
Files
SwiftUI
You MUST use this skill for ANY SwiftUI work including views, state, navigation, layout, animations, architecture, gestures, and debugging.
Quick Reference
| Symptom / Task | Reference |
|---|---|
| View not updating | See skills/debugging.md |
| View update still broken after debugging | See skills/debugging-diag.md |
Slow previews / building good previews / @Previewable / PreviewModifier / variant matrix | See skills/previews.md |
Preview API reference (#Preview, traits, modes, Development Assets) | See skills/previews-ref.md |
| Preview crashes / won't load | See skills/debugging.md (Preview Crashes section) |
| Navigation issues | See skills/nav.md |
| Navigation still broken after debugging | See skills/nav-diag.md |
| Navigation API reference | See skills/nav-ref.md |
| Layout breaks on iPad/rotation | See skills/layout.md |
| Layout API reference | See skills/layout-ref.md |
| Performance/lag/slow scroll | See skills/swiftui-performance.md |
| Architecture/testability | See skills/architecture.md |
| Animation issues | See skills/animation-ref.md |
| Stacks/grids/outlines | See skills/containers-ref.md |
| Custom containers / List replacement (iOS 18+) | See skills/containers-ref.md Part 7 |
| Search implementation | See skills/search-ref.md |
| Toolbars, ToolbarItem, sheet button placement, customization | See skills/toolbars.md |
| Gesture conflicts | See skills/gestures.md |
| iOS 26 features | See skills/26-ref.md |
Non-SwiftUI UI Routes
These topics are part of the broader iOS UI domain but live in separate suites:
UIKit issues
- Auto Layout conflicts → See axiom-uikit (skills/auto-layout-debugging.md)
- Animation timing → See axiom-uikit (skills/uikit-animation-debugging.md)
- SwiftUI ↔ UIKit bridging → See axiom-uikit (skills/uikit-bridging.md)
Design & guidelines
- Liquid Glass adoption → See axiom-design (skills/liquid-glass.md)
- SF Symbols → See axiom-design (skills/sf-symbols.md)
- HIG compliance → See axiom-design (skills/hig.md)
- Typography → See axiom-design (skills/typography-ref.md)
- TextKit/rich text → See axiom-uikit (skills/textkit-ref.md)
Other
- tvOS (focus, remote, text input) → See axiom-swift (skills/tvos.md)
- App-level composition (root, auth, scenes) → See axiom-design (skills/app-composition.md)
- Drag/drop, sharing, copy/paste → See axiom-swift (skills/transferable-ref.md)
- VoiceOver, Dynamic Type →
/skill axiom-accessibility - UI test flakiness →
/skill axiom-testing - UX dead ends, dismiss traps → Launch
ux-flow-auditoragent
watchOS-specific patterns
- Glanceable UI, watch navigation, Smart Stack widgets → See axiom-watchos
Conflict Resolution
axiom-swiftui vs axiom-performance: When UI is slow (e.g., "SwiftUI List slow"): 1. Try axiom-swiftui FIRST — Domain-specific fixes (LazyVStack, view identity, @State optimization) often solve UI performance in 5 minutes 2. Only use axiom-performance if domain fixes don't help — Profiling takes longer and may confirm what domain knowledge already knows
Decision Tree
digraph swiftui {
start [label="SwiftUI issue" shape=ellipse];
what [label="What's wrong?" shape=diamond];
start -> what;
what -> "skills/debugging.md" [label="view not updating"];
what -> "skills/nav.md" [label="navigation"];
what -> "skills/swiftui-performance.md" [label="slow/lag"];
what -> "skills/layout.md" [label="adaptive layout"];
what -> "skills/containers-ref.md" [label="stacks/grids/outlines"];
what -> "skills/architecture.md" [label="feature architecture"];
what -> "skills/animation-ref.md" [label="animations"];
what -> "skills/gestures.md" [label="gestures"];
what -> "skills/search-ref.md" [label="search"];
what -> "skills/toolbars.md" [label="toolbars / sheet buttons"];
what -> "skills/26-ref.md" [label="iOS 26 features"];
what -> "skills/previews.md" [label="slow previews / building good previews"];
what -> "skills/previews-ref.md" [label="preview API reference"];
what -> "skills/debugging.md" [label="preview crashes / won't load"];
what -> "axiom-uikit (skills/uikit-bridging.md)" [label="UIKit interop"];
what -> "axiom-design (skills/app-composition.md)" [label="app-level (root, auth)"];
what -> "axiom-swift (skills/transferable-ref.md)" [label="drag/drop, sharing"];
}Automated Scanning
- Architecture audit → Launch
swiftui-architecture-auditoragent - Performance scan → Launch
swiftui-performance-analyzeragent or/axiom:audit swiftui-performance - Navigation audit → Launch
swiftui-nav-auditoragent or/axiom:audit swiftui-nav - Layout audit → Launch
swiftui-layout-auditoragent or/axiom:audit swiftui-layout - UX flow audit → Launch
ux-flow-auditoragent or/axiom:audit ux-flow - Liquid Glass scan → Launch
liquid-glass-auditoragent or/axiom:audit liquid-glass(detects migration opportunities AND adoption-completeness gaps: variant discipline for media surfaces, glass-on-glass nesting, missingif #availablegates, primary-action tinting,.tabRole(.search); scores ADOPTED / PARTIAL / NOT ADOPTED) - TextKit scan → Launch
textkit-auditoragent or/axiom:audit textkit(detects fallback triggers, glyph APIs that corrupt complex scripts, missing Writing Tools wiring, AND architectural gaps like missing fallback observation, SwiftUI wrappers dropping TextKit 2 properties, missingisWritingToolsActiveguards; scores MODERN / MIXED / LEGACY)
Anti-Rationalization
| Thought | Reality |
|---|---|
| "Simple SwiftUI layout, no need" | SwiftUI layout has 12 gotchas. skills/layout.md covers all of them. |
| "I know how NavigationStack works" | Navigation has state restoration, deep linking, and identity traps. skills/nav.md prevents 2-hour debugging. |
| "It's just a view not updating" | View update failures have 4 root causes. skills/debugging.md diagnoses in 5 min. |
| "I'll just add .animation()" | Animation issues compound. skills/animation-ref.md has the correct patterns. |
| "No architecture needed" | Even small features benefit from separation. skills/architecture.md prevents refactoring debt. |
| "I know .searchable" | Search has 6 gotchas. skills/search-ref.md covers all of them. |
| "I'll just add a Done button" | Sheets without Cancel break the HIG (updated 2026-03-24). .cancellationAction / .confirmationAction produce HIG-correct placement automatically — skills/toolbars.md Pattern 2 has the rules. |
| "Previews are slow forever, I'll just use the simulator" | Five concrete fixes in skills/previews.md. Rule 4 (auto-refresh off) is 30 seconds and often halves perceived slowness. |
"I'll just write a wrapper view for @State in this preview" | @Previewable @State (Xcode 16+) eliminates that boilerplate. skills/previews-ref.md has the macro signature. |
SwiftUI 26 Features
Overview
Comprehensive guide to new SwiftUI features in iOS 26, iPadOS 26, macOS Tahoe, watchOS 26, and visionOS 26. From the Liquid Glass design system to rich text editing, these enhancements make SwiftUI more powerful across all Apple platforms.
Core principle From low level performance improvements all the way up through the buttons in your user interface, there are some major improvements across the system.
When to Use This Skill
- Adopting the Liquid Glass design system
- Implementing rich text editing with AttributedString
- Embedding web content with WebView
- Optimizing list and scrolling performance
- Using the @Animatable macro for custom animations
- Building 3D spatial layouts on visionOS
- Bridging SwiftUI scenes to UIKit/AppKit apps
- Implementing drag and drop with multiple items
- Creating 3D charts with Chart3D
- Adding widgets to visionOS or CarPlay
- Adding custom tick marks to sliders (chapter markers, value indicators)
- Constraining slider selection ranges with
enabledBounds - Customizing slider appearance (thumb visibility, current value labels)
- Creating sticky safe area bars with blur effects
- Opening URLs in in-app browser
- Using system-styled close and confirm buttons
- Applying glass button styles (iOS 26.1+)
- Controlling button sizing behavior
- Implementing compact search toolbars
- Adjusting line height or baseline spacing for text
System Requirements
iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, visionOS 26+
---
Liquid Glass Design System
For comprehensive coverage, see axiom-design (skills/liquid-glass.md) (design principles, variants, review pressure) and axiom-design (skills/liquid-glass-ref.md) (app-wide adoption guide). This section covers WWDC 256-specific APIs only.
Automatic Adoption
Recompile with iOS 26 SDK — navigation containers, tab bars, toolbars, toggles, segmented pickers, and sliders automatically adopt the new design. Bordered buttons default to capsule shape. Sheets get Liquid Glass background (remove any presentationBackground customizations).
Toolbar APIs (iOS 26)
ToolbarSpacer
.toolbar {
ToolbarItem(placement: .bottomBar) { Button("Archive", systemImage: "archivebox") { } }
ToolbarSpacer(.flexible, placement: .bottomBar) // Push items apart
ToolbarItem(placement: .bottomBar) { Button("Compose", systemImage: "square.and.pencil") { } }
}
// .fixed separates groups visually; .flexible pushes apart (like Spacer in HStack)ToolbarItemGroup (Visual Grouping)
Items in a ToolbarItemGroup share a single glass background "pill". ToolbarItemPlacement controls visual appearance: confirmationAction → glassProminent styling, cancellationAction → standard glass. Use .sharedBackgroundVisibility(.hidden) to exclude items (e.g., avatars) from group background.
Toolbar Morphing
Attach .toolbar {} to individual views inside NavigationStack (not to NavigationStack itself). iOS 26 morphs between per-view toolbars during push/pop. Use toolbar(id:) with matching ToolbarItem(id:) across screens for items that should stay stable (no bounce):
// MailboxList
.toolbar(id: "main") {
ToolbarItem(id: "filter", placement: .bottomBar) { Button("Filter") { } }
ToolbarSpacer(.flexible, placement: .bottomBar)
ToolbarItem(id: "compose", placement: .bottomBar) { Button("New Message") { } }
}
// MessageList — "filter" absent (animates out), "compose" stays stable
.toolbar(id: "main") {
ToolbarSpacer(.flexible, placement: .bottomBar)
ToolbarItem(id: "compose", placement: .bottomBar) { Button("New Message") { } }
}#1 gotcha: Toolbar on NavigationStack = nothing to morph between.
DefaultToolbarItem
Reposition system-provided items (like search) within your toolbar layout:
DefaultToolbarItem(kind: .search, placement: .bottomBar)
// Replaces system's default placement of matching kindUse in collapsed NavigationSplitView sidebar to specify which column shows search on iPhone. Wrap in if #available(iOS 26.0, *) for backward compatibility.
User-Customizable Toolbars
toolbar(id:) enables user customization (rearrange, show/hide). Only .secondaryAction items support customization on iPadOS. Use showsByDefault: false for optional items. Add ToolbarCommands() for macOS menu item.
Other Toolbar Features
.navigationSubtitle("3 unread")— Secondary line below title.badge(3)on toolbar items — Notification counts- Monochrome icon rendering — Reduces visual noise; tint for meaning, not decoration
- Scroll edge blur — Automatic, no code required
Bottom-Aligned Search
Foundational search APIs: See skills/search-ref.md. This section covers iOS 26 refinements only.
NavigationSplitView {
List { }.searchable(text: $searchText)
}
// Bottom-aligned on iPhone, top trailing on iPad (automatic)
// Use placement: .sidebar to restore sidebar-embedded search on iPadsearchToolbarBehavior(.minimize)— Compact search that expands on tapTab(role: .search)— Dedicated search tab; search field replaces tab bar. See swiftui-nav-ref Section 5.7
Known Issue: .onGeometryChange breaks Tab(role: .search) morph on first activation
Symptom On first activation of the search-role tab, the search field renders as a separate top bar (legacy .navigationBarDrawer placement) instead of morphing from the tab icon. The circular search-role icon still appears in the tab bar, so the visible failure is "two search affordances" rather than "no search." Subsequent activations render correctly.
Cause .onGeometryChange(...) action: { state = ... } anywhere in the TabView's subtree — on the body containing the TabView, on the TabView itself, or on any individual tab's content. The geometry-driven state write triggers a TabView re-render during initial layout that the search-tab morph integration doesn't recover from. Cached state on subsequent activations bypasses the issue.
Fix Don't write observable state from .onGeometryChange for any value the TabView's structure depends on. For one-time window-size reads at launch (e.g., to gate tab inclusion), seed @State once via UIApplication.shared.connectedScenes:
@State private var windowWidth: CGFloat = {
UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.first?.screen.bounds.width ?? 0
}()For continuous size tracking, push the observer outside the TabView's coordinate space (sibling Color.clear) or read from a GeometryReader above the TabView.
Status Reproduced on iOS 26.0. Retest on iOS 26.1+ — adjacent tabViewBottomAccessory AttributeGraph cycle bug (Apple Forums 801431) was fixed in Xcode 26.1; this may be too. See skills/nav-diag.md Pattern 4e for full diagnosis, all 4 fix options, and verification steps.
Glass Effect for Custom Views
Button("To Top", systemImage: "chevron.up") { scrollToTop() }
.padding()
.glassEffect() // Add .interactive for custom controls on iOSGlassEffectContainer— Required when multiple glass elements are nearby (glass can't sample glass)glassEffectID(_:in:)— Fluid morphing transitions between glass elements using a namespace- Sheet morphing — Use
.matchedTransitionSource+.navigationTransition(.zoom(...))to morph sheets from buttons
Button & Control Changes
- Capsule shape default for bordered buttons (override with
.buttonBorderShape(.roundedRectangle)) .controlSize(.extraLarge)— extra-large control size (available since iOS 17).controlSize(.small)on containers — Preserve pre-iOS 26 density.buttonStyle(.glass)/.glassProminent/.glass(.regular.tint(.blue))— Glass button styles (theGlassButtonStyle(_:)initializer is iOS 26.1+).buttonSizing(.automatic/.flexible/.fitted)— Control button layout behaviorButton(role: .close)/Button(role: .confirm)— System-styled close/confirmConcentricRectangle()(or.rect(cornerRadius:style:)with.circular/.continuous) — Corner concentricity (there is no.containerConcentriccorner style)- Menus: icons on leading edge, consistent iOS/macOS
---
Slider Enhancements
iOS 26 adds custom tick marks, constrained selection ranges, current value labels, and thumb visibility control.
Slider Ticks
Core types: SliderTick<V>, SliderTickContentForEach, SliderTickBuilder
// Static ticks with labels
Slider(value: $value, in: 0...10) {
Text("Rating")
} ticks: {
SliderTick(0) { Text("Min") }
SliderTick(5) { Text("Mid") }
SliderTick(10) { Text("Max") }
}
// Dynamic ticks from collection
SliderTickContentForEach(stops, id: \.self) { value in
SliderTick(value) { Text("\(Int(value))°").font(.caption2) }
}
// Step-based ticks (called for each step value)
Slider(value: $volume, in: 0...10, step: 2, label: { Text("Volume") }, tick: { value in
SliderTick(value) { Text("\(Int(value))") }
})API constraint: SliderTickContentForEach requires Data.Element to match SliderTick<V> value type. For custom structs, extract numeric values: chapters.map(\.time) then look up labels via chapters.first(where: { $0.time == time }).
Full-Featured Slider
Slider(
value: $rating, in: 0...100,
neutralValue: 50, // Starting point / center value
enabledBounds: 20...80, // Restrict selectable range
label: { Text("Rating") },
currentValueLabel: { Text("\(Int(rating))") },
minimumValueLabel: { Text("0") },
maximumValueLabel: { Text("100") },
ticks: { SliderTick(50) { Text("Mid") } },
onEditingChanged: { editing in print(editing ? "Started" : "Ended") }
)sliderThumbVisibility
.sliderThumbVisibility(.hidden) — Hide thumb for media progress indicators and minimal UI. Options: .automatic, .visible, .hidden. Always visible on watchOS.
---
New View Modifiers
safeAreaBar
Sticky bars with integrated progressive blur:
List { ForEach(1...20, id: \.self) { Text("\($0). Item") } }
.safeAreaBar(edge: .bottom) {
Text("Bottom Action Bar").padding(.vertical, 15)
}
.scrollEdgeEffectStyle(.soft, for: .bottom) // or .hardWorks like safeAreaInset but with blur. Bar remains fixed while content scrolls beneath.
onOpenURL Enhancement
@Environment(\.openURL) var openURL
// openURL(url, prefersInApp: true) — Opens in SFSafariViewController-style in-app browser
// Default Link opens in Safari; prefersInApp keeps users in your appsearchToolbarBehavior
See skills/search-ref.md for foundational .searchable APIs. iOS 26 adds:
.searchable(text: $searchText)
.searchToolbarBehavior(.minimize) // Compact button, expands on tapAlso: .searchPresentationToolbarBehavior(.avoidHidingContent) (iOS 17.1+) keeps title visible during search.
Backward-compatible wrapper for apps targeting iOS 18+26:
extension View {
@ViewBuilder func minimizedSearch() -> some View {
if #available(iOS 26.0, *) {
self.searchToolbarBehavior(.minimize)
} else { self }
}
}
// Usage
.searchable(text: $searchText)
.minimizedSearch()Availability pattern for toolbar items:
.toolbar {
if #available(iOS 26.0, *) {
DefaultToolbarItem(kind: .search, placement: .bottomBar)
ToolbarSpacer(.flexible, placement: .bottomBar)
}
ToolbarItem(placement: .bottomBar) {
NewNoteButton()
}
}
.searchable(text: $searchText)Button roles, GlassButtonStyle, buttonSizing — See Liquid Glass Design System section above.
lineHeight (iOS 26)
Sets the baseline-to-baseline distance between text lines. More intuitive than .lineSpacing() which measures bottom-of-line to top-of-next-line.
Presets
Text("Lorem ipsum...")
.lineHeight(.loose) // Increased spacing for open layouts
.lineHeight(.tight) // Reduced spacing for compact layouts
.lineHeight(.normal) // Constant height based on point size multiple
.lineHeight(.variable) // Uses font metrics for height calculationPrecise Control
// Scale proportionally to font size
Text("Scales with text size")
.lineHeight(.multiple(factor: 2))
// Relative to point size with fixed increase
Text("Point-size relative")
.lineHeight(.leading(increase: 30))
// Absolute fixed value — does NOT scale with Dynamic Type
Text("Fixed height")
.lineHeight(.exact(points: 30))AttributedString Support
var s = AttributedString("Paragraph\nwith multiple\nlines.")
s.lineHeight = .exact(points: 32)
s.lineHeight = .multiple(factor: 2.5)
s.lineHeight = .looseComparison with Existing APIs
| API | Measures | Available |
|---|---|---|
.lineHeight() | Baseline to baseline | iOS 26+ |
.lineSpacing() | Bottom of line to top of next | iOS 13+ |
.font(.body.leading(.tight)) | Font-level leading preset | iOS 14+ |
Cross-reference axiom-design (skills/typography-ref.md) — Full typography system including Dynamic Type, tracking, and internationalization
---
iPad Enhancements
Menu Bar
Access common actions via swipe-down menu
.commands {
TextEditingCommands() // Same API as macOS menu bar
CommandGroup(after: .newItem) {
Button("Add Note") {
addNote()
}
.keyboardShortcut("n", modifiers: [.command, .shift])
}
}
// Creates menu bar on iPad when people swipe downResizable Windows
Fluid resizing on iPad
// MIGRATION REQUIRED:
// Remove deprecated property list key in iPadOS 26:
// UIRequiresFullScreen (entire key deprecated, all values)
// For split view navigation, system automatically shows/hides columns
// based on available space during resize
NavigationSplitView {
Sidebar()
} detail: {
Detail()
}
// Adapts to resizing automatically---
macOS Window Enhancements
Synchronized Window Resize Animations
.windowResizeAnchor(.topLeading) // Tailor where animation originates
// SwiftUI now synchronizes animation between content view size changes
// and window resizing - great for preserving continuity when switching tabs---
Performance Improvements
List Performance (macOS Focus)
Massive gains for large lists
- 6x faster loading for lists of 100,000+ items on macOS
- 16x faster updates for large lists
- Even bigger gains for larger lists
- Improvements benefit all platforms (iOS, iPadOS, watchOS)
List(trips) { trip in // 100k+ items
TripRow(trip: trip)
}
// Loads 6x faster, updates 16x faster on macOS (iOS 26+)Scrolling Performance
Reduced dropped frames
SwiftUI has improved scheduling of user interface updates on iOS and macOS. This improves responsiveness and lets SwiftUI do even more work to prepare for upcoming frames. All in all, it reduces the chance of your app dropping a frame while scrolling quickly at high frame rates.
Nested ScrollViews with Lazy Stacks
Photo carousels and multi-axis scrolling
ScrollView(.horizontal) {
LazyHStack {
ForEach(photoSets) { photoSet in
ScrollView(.vertical) {
LazyVStack {
ForEach(photoSet.photos) { photo in
PhotoView(photo: photo)
}
}
}
}
}
}
// Nested scrollviews now properly delay loading with lazy stacks
// Great for building photo carouselsSwiftUI Performance Instrument
New profiling tool in Xcode
Available lanes:
- Long view body updates — Identify expensive body computations
- Platform view updates — Track UIKit/AppKit bridging performance
- Other performance problem areas
Cross-reference skills/swiftui-performance.md — Master the SwiftUI Instrument
---
Swift Concurrency Integration
Compile-Time Data Race Safety
@Observable
class TripStore {
var trips: [Trip] = []
func loadTrips() async {
trips = await TripService.fetchTrips()
// Swift 6 verifies data race safety at compile time
}
}Benefits Find bugs in concurrent code before they affect your app
Cross-reference axiom-concurrency — Swift 6 strict concurrency patterns
---
@Animatable Macro
Overview
Simplifies custom animations by automatically synthesizing animatableData property.
Before (@Animatable macro)
struct HikingRouteShape: Shape {
var startPoint: CGPoint
var endPoint: CGPoint
var elevation: Double
var drawingDirection: Bool // Don't want to animate this
// Tedious manual animatableData declaration
var animatableData: AnimatablePair<CGPoint.AnimatableData,
AnimatablePair<Double, CGPoint.AnimatableData>> {
get {
AnimatablePair(startPoint.animatableData,
AnimatablePair(elevation, endPoint.animatableData))
}
set {
startPoint.animatableData = newValue.first
elevation = newValue.second.first
endPoint.animatableData = newValue.second.second
}
}
}After (@Animatable macro)
@Animatable
struct HikingRouteShape: Shape {
var startPoint: CGPoint
var endPoint: CGPoint
var elevation: Double
@AnimatableIgnored
var drawingDirection: Bool // Excluded from animation
// animatableData automatically synthesized!
}Key benefits
- Delete manual
animatableDataproperty - Use
@AnimatableIgnoredfor properties to exclude - SwiftUI automatically synthesizes animation data
Cross-reference SwiftUI Animation (swiftui-animation-ref skill) — Comprehensive animation guide covering VectorArithmetic, Animatable protocol, @Animatable macro, animation types, Transaction system, and performance optimization
---
3D Spatial Layout (visionOS)
Alignment3D
Depth-based layout
struct SunPositionView: View {
@State private var timeOfDay: Double = 12.0
var body: some View {
HikingRouteView()
.overlay(alignment: sunAlignment) {
SunView()
.spatialOverlay(alignment: sunAlignment)
}
}
var sunAlignment: Alignment3D {
// Align sun in 3D space based on time of day
Alignment3D(
horizontal: .center,
vertical: .top,
depth: .back
)
}
}Manipulable Modifier
Interactive 3D objects
Model3D(named: "WaterBottle")
.manipulable() // People can pick up and move the objectSurface Snapping APIs
@Environment(\.surfaceSnappingInfo) var snappingInfo: SurfaceSnappingInfo
var body: some View {
VStackLayout().depthAlignment(.center) {
Model3D(named: "waterBottle")
.manipulable()
Pedestal()
.opacity(snappingInfo.classification == .table ? 1.0 : 0.0)
}
}---
Scene Bridging
Overview
Scene bridging allows your UIKit and AppKit lifecycle apps to interoperate with SwiftUI scenes. Apps can use it to open SwiftUI-only scene types or use SwiftUI-exclusive features right from UIKit or AppKit code.
Supported Scene Types
From UIKit/AppKit apps, you can now use
MenuBarExtra(macOS)ImmersiveSpace(visionOS)RemoteImmersiveSpace(macOS → Vision Pro)AssistiveAccess(iOS 26)
Scene Modifiers
Works with scene modifiers like:
.windowStyle().immersiveEnvironmentBehavior()
RemoteImmersiveSpace
Mac app renders stereo content on Vision Pro
// In your macOS app
@main
struct MyMacApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
RemoteImmersiveSpace(id: "stereoView") {
// Render stereo content on Apple Vision Pro
// Uses CompositorServices
}
}
}Features
- Mac app renders stereo content on Vision Pro
- Hover effects and input events supported
- Uses CompositorServices and Metal
AssistiveAccess Scene
Special mode for users with cognitive disabilities
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
AssistiveAccess {
SimplifiedUI() // UI shown when iPhone is in Assistive Access mode
}
}
}---
AppKit Integration Enhancements
SwiftUI Sheets in AppKit
// Show SwiftUI view in AppKit sheet
let hostingController = NSHostingController(rootView: SwiftUISettingsView())
presentAsSheet(hostingController)
// Great for incremental SwiftUI adoptionNSGestureRecognizerRepresentable
// Bridge AppKit gestures to SwiftUI
struct AppKitPanGesture: NSGestureRecognizerRepresentable {
func makeNSGestureRecognizer(context: Context) -> NSPanGestureRecognizer {
NSPanGestureRecognizer()
}
func updateNSGestureRecognizer(_ recognizer: NSPanGestureRecognizer, context: Context) {
// Update configuration
}
}NSHostingView in Interface Builder
NSHostingView can now be used directly in Interface Builder for gradual SwiftUI adoption.
---
RealityKit Integration
Observable Entities
@Observable
class RealityEntity {
var position: SIMD3<Float>
var rotation: simd_quatf
}
struct MyView: View {
@State private var entity = RealityEntity()
var body: some View {
// SwiftUI views automatically observe changes
Text("Position: \(entity.position.x)")
}
}PresentationComponent
Present SwiftUI popovers, alerts, and sheets directly from RealityKit entities.
// Present SwiftUI popovers from RealityKit entities
let popover = Entity()
mapEntity.addChild(popover)
popover.components[PresentationComponent.self] = PresentationComponent(
isPresented: $popoverPresented,
configuration: .popover(arrowEdge: .bottom),
content: DetailsView()
)Additional Improvements
ViewAttachmentComponent— add SwiftUI views to entitiesGestureComponent— entity touch and gesture responsiveness- Enhanced coordinate conversion API
- Synchronizing animations, binding to components
- New sizing behaviors for RealityView
---
WebView & WebPage
Overview
WebKit now provides full SwiftUI APIs for embedding web content, eliminating the need to drop down to UIKit.
WebView
Display web content
import WebKit
struct ArticleView: View {
let articleURL: URL
var body: some View {
WebView(url: articleURL)
}
}WebPage (Observable Model)
Rich interaction with web content
import WebKit
struct InAppBrowser: View {
@State private var page = WebPage()
var body: some View {
VStack {
Text(page.title.isEmpty ? "Loading…" : page.title)
WebView(page)
.ignoresSafeArea()
.onAppear {
page.load(URLRequest(url: articleURL))
}
HStack {
Button("Back") {
if let item = page.backForwardList.backList.last { page.load(item) }
}
.disabled(page.backForwardList.backList.isEmpty)
Button("Forward") {
if let item = page.backForwardList.forwardList.first { page.load(item) }
}
.disabled(page.backForwardList.forwardList.isEmpty)
}
}
}
}WebPage features
- History navigation via
backForwardList(backList/forwardList/currentItem) +load(_ item:)— there are nogoBack()/goForward()/canGoBack/canGoForwardmembers - Access page properties (
titleis a non-optionalString,urlisURL?,estimatedProgress) - Observable — SwiftUI views update automatically
Form-submission hook iOS27
WebPage.NavigationDeciding gains willSubmit(formInfo:) async (default no-op), observing form submissions: WebPage.FormInfo carries targetFrame / sourceFrame (FrameInfo), submissionURL, httpMethod, and formValues: [String: String]. WebPage.NavigationPreferences adds alternateRequest: URLRequest? and overrideReferrer: String?. In the 27 beta 1 SDK these are iOS-only — the macOS/visionOS annotations are placeholder (9999) — re-check later betas before claiming them cross-platform.
tvOS: WebView and WebPage are not available on tvOS. tvOS has no WKWebView at all. For web content parsing on tvOS, use JavaScriptCore. See axiom-swift (skills/tvos.md) for alternatives.
Advanced WebKit Features
- Custom user agents
- JavaScript execution
- Custom URL schemes
- And more
---
TextEditor with AttributedString
Overview
SwiftUI's new support for rich text editing is great for experiences like commenting on photos. TextView now supports AttributedString!
Note The WWDC transcript uses "TextView" as editorial language. The actual SwiftUI API is TextEditor which now supports AttributedString binding for rich text editing.
Plain Text vs Rich Text
- For plain text: Prefer
TextField("Label", text: $text, axis: .vertical)overTextEditor— supports placeholder text, consistent styling, and automatic vertical expansion (iOS 16+) - For rich text: Use
TextEditorwithAttributedStringbinding (iOS 26+) —TextFielddoes not supportAttributedString
Rich Text Editing
struct CommentView: View {
@State private var comment = AttributedString("Enter your comment")
var body: some View {
TextEditor(text: $comment)
// Built-in text formatting controls included
// Users can apply bold, italic, underline, etc.
}
}Features
- Built-in text formatting controls (bold, italic, underline, colors, etc.)
- Binding to
AttributedStringpreserves formatting - Automatic toolbar with formatting options
Advanced AttributedString Features
Customization options
- Paragraph styles
- Attribute transformations
- Constrain which attributes users can apply
Cross-reference axiom-integration — AttributedString for Apple Intelligence Use Model action
---
Drag and Drop Enhancements
Multiple Item Dragging
Drag multiple items based on selection
struct PhotoGrid: View {
@State private var selectedPhotos: [Photo.ID] = []
var body: some View {
ScrollView {
LazyVGrid(columns: gridColumns) {
ForEach(model.photos) { photo in
view(photo: photo)
.draggable(containerItemID: photo.id)
}
}
}
.dragContainer(for: Photo.self) { draggedIDs in
photos(ids: draggedIDs)
}
.dragContainerSelection(selectedPhotos)
}
}Key APIs:
.draggable(containerItemID:containerNamespace:)marks each item as part of a drag container (namespace defaults tonil).dragContainer(for:in:)provides the typed items lazily when a drop occurs; the payload closure receives the dragged item IDs.dragContainerSelection(_:containerNamespace:)supplies the current selection — it is a separate modifier, not adragContainerargument
DragConfiguration
Customize supported operations
.dragConfiguration(DragConfiguration(allowMove: false, allowDelete: true))Observing Drag Events
.onDragSessionUpdated { session in
let ids = session.draggedItemIDs(for: Photo.ID.self)
if session.phase == .ended(.delete) {
trash(ids)
deletePhotos(ids)
}
}Drag Preview Formations
.dragPreviewsFormation(.stack) // Items stack nicely on top of one another
// Other formations: .default, .pile, .list, .none
// (there is no .grid formation)Combine all modifiers (.dragContainer, .dragConfiguration, .dragPreviewsFormation, .onDragSessionUpdated) on the same scroll view for a complete multi-item drag experience.
---
3D Charts
Overview
Swift Charts supports three-dimensional plotting with Chart3D. Key components: Chart3D (container), SurfacePlot (continuous surfaces), Chart3DPose (camera control), Chart3DSurfaceStyle (surface appearance).
Gotcha: conditional ChartContent crashes below a 27.0 deployment target
This applies to all Swift Charts, not just Chart3D. With a minimum deployment target below 27.0, an if/else inside a Chart { … } closure triggers the warning "Conformance of _ConditionalContent<TrueContent, FalseContent> to ChartContent is only available in 27.0 or newer," and the app can crash at runtime when that content loads. Extract the conditional into a function or computed property annotated with @ChartContentBuilder:
@ChartContentBuilder
func marks(for dp: DataPoint) -> some ChartContent {
if selectedMetric == "Rate" {
LineMark(x: .value("X", dp.index), y: .value("Y", dp.rate)).foregroundStyle(.blue)
} else {
LineMark(x: .value("X", dp.index), y: .value("Y", dp.signal))
}
}
// Chart(dataPoints, id: \.index) { marks(for: $0) }Chart3D Container
import Charts
Chart3D {
SurfacePlot(x: "x", y: "y", z: "z") { x, y in
sin(x) * cos(y)
}
.foregroundStyle(Gradient(colors: [.orange, .pink]))
}
.chartXScale(domain: -3...3)
.chartYScale(domain: -3...3)
.chartZScale(domain: -3...3)Chart3D also accepts data collections:
Chart3D(dataPoints) { point in
// 3D mark for each data point
}SurfacePlot
Renders continuous surfaces from a mathematical function mapping (x, y) to z values.
SurfacePlot(x: "X Axis", y: "Y Axis", z: "Z Axis") { x, y in
sin(sqrt(x * x + y * y))
}Surface Styling
SurfacePlot(x: "X", y: "Y", z: "Z") { x, y in sin(x) * cos(y) }
.foregroundStyle(.blue) // Solid color
.roughness(0.3) // 0 = smooth, 1 = rough
// Height-based coloring (color maps to z-value)
.foregroundStyle(Chart3DSurfaceStyle.heightBased(yRange: -1.0...1.0))
// Custom gradient mapped to height
.foregroundStyle(Chart3DSurfaceStyle.heightBased(
Gradient(colors: [.blue, .green, .yellow, .red]),
yRange: -1.0...1.0
))Available surface styles: .heightBased (color by z-value), .normalBased (color by surface normal direction).
Multiple Surfaces
Chart3D {
SurfacePlot(x: "X", y: "Y", z: "Z") { x, y in sin(x) * cos(y) }
SurfacePlot(x: "X", y: "Y", z: "Z") { x, y in cos(x) * sin(y) + 2 }
}Chart3DPose (Camera Control)
Controls the viewing angle. Pass as value for static positioning, or bind for interactive rotation.
@State private var chartPose: Chart3DPose = .default
Chart3D { /* ... */ }
.chart3DPose(chartPose) // Static — read-only
.chart3DPose($chartPose) // Binding — enables drag-to-rotatePredefined poses: .default, .front, .back, .top, .bottom, .left, .right
Custom pose with specific angles:
Chart3DPose(azimuth: .degrees(45), inclination: .degrees(30))Animate between poses:
Button("Top View") { withAnimation { chartPose = .top } }Chart3DCameraProjection
Controls how 3D depth is projected to 2D.
Chart3D { /* ... */ }
.chart3DCameraProjection(.perspective) // Objects shrink with distance
.chart3DCameraProjection(.orthographic) // Objects maintain size regardless of depth
.chart3DCameraProjection(.automatic) // System decidesZ-Axis Modifiers
All existing chart axis modifiers have z-axis equivalents:
.chartZScale(domain:)— Set z-axis range.chartZAxis()— Configure z-axis labels and grid lines
---
Widgets & Controls
Controls on watchOS and macOS
watchOS 26
struct FavoriteLocationControl: ControlWidget {
var body: some ControlWidgetConfiguration {
StaticControlConfiguration(kind: "FavoriteLocation") {
ControlWidgetButton(action: MarkFavoriteIntent()) {
Label("Mark Favorite", systemImage: "star")
}
}
}
}
// Access from watch face or ShortcutsmacOS
Controls now appear in Control Center on Mac.
Widgets on visionOS
Level of detail customization
struct CountdownWidget: Widget {
var body: some WidgetConfiguration {
StaticConfiguration(kind: "Countdown") { entry in
CountdownView(entry: entry)
}
}
}
struct PhotoCountdownView: View {
@Environment(\.levelOfDetail) var levelOfDetail: LevelOfDetail
var body: some View {
switch levelOfDetail {
case .default:
RecentPhotosView() // Full detail when close
case .simplified:
CountdownView() // Simplified when further away
default:
CountdownView()
}
}
}Widgets on CarPlay
Live Activities on CarPlay
Live Activities now appear on CarPlay displays for glanceable information while driving.
Additional Widget Features
- Push-based updating API
- New relevance APIs for watchOS
---
Migration Checklist
Deprecated APIs
❌ Remove in iPadOS 26
<key>UIRequiresFullScreen</key>
<!-- Entire property list key is deprecated (all values) -->Apps must support resizable windows on iPad.
Automatic Adoptions (Recompile Only)
✅ Liquid Glass design for navigation, tab bars, toolbars ✅ Bottom-aligned search on iPhone ✅ List performance improvements (6x loading, 16x updating) ✅ Scrolling performance improvements ✅ System controls (toggles, pickers, sliders) new appearance ✅ Bordered buttons default to capsule shape ✅ Updated control heights (slightly taller on macOS) ✅ Monochrome icon rendering in toolbars ✅ Menus: icons on leading edge, consistent across iOS and macOS ✅ Sheets morph out of dialogs automatically ✅ Scroll edge blur/fade under system toolbars
Audit Items (Remove Old Customizations)
⚠️ Remove presentationBackground from sheets (let Liquid Glass material shine) ⚠️ Remove extra backgrounds/darkening effects behind toolbar areas ⚠️ Remove hard-coded control heights (use automatic sizing) ⚠️ Update section headers to title-style capitalization (no longer auto-uppercased)
Manual Adoptions (Code Changes)
🔧 Toolbar spacers (.fixed) 🔧 Tinted prominent buttons in toolbars 🔧 Glass effect for custom views (.glassEffect()) 🔧 glassEffectID for morphing transitions between glass elements 🔧 GlassEffectContainer for multiple nearby glass elements 🔧 sharedBackgroundVisibility(.hidden) to remove toolbar item from group background 🔧 Sheet morphing from buttons (.navigationTransition(.zoom(sourceID:in:))) 🔧 Search tab role (Tab(role: .search)) 🔧 Compact search toolbar (.searchToolbarBehavior(.minimize)) 🔧 Extra large control size (.controlSize(.extraLarge), available since iOS 17) 🔧 Concentric rectangle shape (ConcentricRectangle) 🔧 iPad menu bar (.commands) 🔧 Window resize anchor (.windowResizeAnchor()) 🔧 @Animatable macro for custom shapes/modifiers 🔧 WebView for web content 🔧 TextEditor with AttributedString binding 🔧 Enhanced drag and drop with .dragContainer 🔧 Slider ticks (SliderTick, SliderTickContentForEach) 🔧 Slider thumb visibility (.sliderThumbVisibility()) 🔧 Safe area bars with blur (.safeAreaBar() + .scrollEdgeEffectStyle()) 🔧 In-app URL opening (openURL(url, prefersInApp: true)) 🔧 Close and confirm button roles (Button(role: .close)) 🔧 Glass button styles (.glass/.glassProminent; the GlassButtonStyle(_:) init is iOS 26.1+) 🔧 Button sizing control (.buttonSizing()) 🔧 Toolbar morphing transitions (per-view .toolbar {} inside NavigationStack) 🔧 DefaultToolbarItem for system components in toolbars 🔧 Stable toolbar items (toolbar(id:) with matched IDs across screens) 🔧 User-customizable toolbars (toolbar(id:) with CustomizableToolbarContent) 🔧 Line height control (.lineHeight() — baseline-to-baseline distance) 🔧 Tab bar minimization (.tabBarMinimizeBehavior(.onScrollDown)) 🔧 Tab view bottom accessory (.tabViewBottomAccessory(isEnabled:content:) — iOS 26.1+)
---
Best Practices
- Performance: Profile with new SwiftUI Instrument; use lazy stacks in nested ScrollViews; trust automatic list performance improvements
- Liquid Glass: Recompile and test first; use toolbar spacers; attach
.toolbar {}to individual views (not NavigationStack); removepresentationBackgroundfrom sheets; useGlassEffectContainerfor nearby glass elements - Layout: Use
.safeAreaPadding()for edge-to-edge (not.padding()). Seeskills/layout-ref.mdfor full guide - Rich Text: Bind
AttributedStringtoTextEditor; constrain attributes for your UX - Spatial (visionOS): Use
Alignment3Dfor depth;.manipulable()only where it makes sense
---
Troubleshooting
| Symptom | Fix |
|---|---|
| Old design after updating to iOS 26 SDK | Clean build (Shift-Cmd-K), rebuild targeting iOS 26 SDK, check deployment target |
| Search remains at top on iPhone | Place .searchable on NavigationSplitView, not on List directly |
| @Animatable "does not conform" | All properties must be VectorArithmetic or marked @AnimatableIgnored |
| Rich text formatting lost in TextEditor | Bind AttributedString, not String |
| Drag delete not working | Enable .dragConfiguration(allowDelete: true) AND observe .onDragSessionUpdated |
| SliderTickContentForEach won't compile | Iterate over numeric values (chapters.map(\.time)), not custom structs — see Slider section |
| Toolbar not morphing during navigation | Move .toolbar {} from NavigationStack to each view inside it — see Liquid Glass section |
.toolbarBackground on TabView ignored | Known buggy/no-op at the TabView level on iOS 26. Apply toolbarBackground/toolbarBackgroundVisibility/toolbarColorScheme for .tabBar on each Tab's content instead. (Separate from the wrong-glass-variant cold-start bug, which no modifier fixes — see axiom-design (skills/liquid-glass.md) Known iOS 26 Limitations.) |
---
Resources
WWDC: 2025-256, 2025-278 (What's new in widgets), 2025-287 (Meet WebKit for SwiftUI), 2025-310 (Optimize SwiftUI performance with instruments), 2025-323 (Build a SwiftUI app with the new design), 2025-325 (Bring Swift Charts to the third dimension), 2025-341 (Cook up a rich text experience in SwiftUI with AttributedString)
Docs: /swiftui, /swiftui/defaulttoolbaritem, /swiftui/toolbarspacer, /swiftui/searchtoolbarbehavior, /swiftui/view/toolbar(id:content:), /swiftui/view/tabbarminimizebehavior(_:), /swiftui/view/tabviewbottomaccessory(isenabled:content:), /swiftui/slider, /swiftui/slidertick, /swiftui/slidertickcontentforeach, /webkit, /foundation/attributedstring, /charts, /charts/chart3d, /charts/surfaceplot, /charts/chart3dpose, /charts/chart3dcameraprojection, /charts/chart3dsurfacestyle, /realitykit/presentationcomponent
Skills: skills/swiftui-performance.md, axiom-design (skills/liquid-glass.md), axiom-concurrency, axiom-integration, skills/search-ref.md
---
Primary source WWDC 2025-256 "What's new in SwiftUI". Additional content from 2025-323 (Build a SwiftUI app with the new design), 2025-287 (Meet WebKit for SwiftUI), and Apple documentation. Version iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, visionOS 26+
SwiftUI Animation
Overview
Comprehensive guide to SwiftUI's animation system, from foundational concepts to advanced techniques. This skill covers the Animatable protocol, the iOS 26 @Animatable macro, animation types, and the Transaction system.
Core principle Animation in SwiftUI is mathematical interpolation over time, powered by the VectorArithmetic protocol. Understanding this foundation unlocks the full power of SwiftUI's declarative animation system.
System Requirements
- iOS 13+: Animatable protocol, timing/spring animations
- iOS 17+: Default spring animations, scoped animations, PhaseAnimator, KeyframeAnimator
- iOS 18+: Zoom transitions, UIKit/AppKit animation bridging
- iOS 26+: @Animatable macro
---
Part 1: Understanding Animation
What Is Interpolation
Animation is the process of generating intermediate values between a start and end state.
Example: Opacity animation
.opacity(0) → .opacity(1)While this animation runs, SwiftUI computes intermediate values:
0.0 → 0.02 → 0.05 → 0.1 → 0.25 → 0.4 → 0.6 → 0.8 → 1.0How values are distributed
- Determined by the animation's timing curve or velocity function
- Spring animations use physics simulation
- Timing curves use bezier curves
- Each animation type calculates values differently
VectorArithmetic Protocol
SwiftUI requires animated data to conform to VectorArithmetic — providing subtraction, scaling, addition, and a zero value. This enables SwiftUI to interpolate between any two values.
Built-in conforming types: CGFloat, Double, Float, Angle (1D), CGPoint, CGSize (2D), CGRect (4D).
Key insight Vector arithmetic abstracts over dimensionality. SwiftUI animates all these types with a single generic implementation.
Why Int Can't Be Animated
Int doesn't conform to VectorArithmetic — no fractional intermediates exist between 3 and 4. SwiftUI simply snaps the value.
Solution: Use Float/Double and display as Int:
@State private var count: Float = 0
// ...
Text("\(Int(count))")
.animation(.spring, value: count)Model vs Presentation Values
Animatable attributes conceptually have two values:
Model Value
- The target value set by your code
- Updated immediately when state changes
- What you write in your view's body
Presentation Value
- The current interpolated value being rendered
- Updates frame-by-frame during animation
- What the user actually sees
Example
.scaleEffect(selected ? 1.5 : 1.0)When selected becomes true:
- Model value: Immediately becomes
1.5 - Presentation value: Interpolates
1.0 → 1.1 → 1.2 → 1.3 → 1.4 → 1.5over time
---
Part 2: Animatable Protocol
Overview
The Animatable protocol allows views to animate their properties by defining which data should be interpolated.
protocol Animatable {
associatedtype AnimatableData: VectorArithmetic
var animatableData: AnimatableData { get set }
}SwiftUI builds an animatable attribute for any view conforming to this protocol.
Built-in Animatable Views
Many SwiftUI modifiers conform to Animatable:
Visual Effects
.scaleEffect()— Animates scale transform.rotationEffect()— Animates rotation.offset()— Animates position offset.opacity()— Animates transparency.blur()— Animates blur radius.shadow()— Animates shadow properties
All Shape types
Circle,Rectangle,RoundedRectangleCapsule,Ellipse,Path- Custom
Shapeimplementations
AnimatablePair for Multi-Dimensional Data
When animating multiple properties, use AnimatablePair to combine vectors. For example, scaleEffect combines CGSize (2D) and UnitPoint (2D) into a 4D vector via AnimatablePair<CGSize.AnimatableData, UnitPoint.AnimatableData>. Access components via .first and .second. The @Animatable macro (iOS 26+) eliminates this boilerplate entirely.
Custom Animatable Conformance
When to use
- Animating custom layout (like RadialLayout)
- Animating custom drawing code
- Animating properties that affect shape paths
Example: Animated number view
struct AnimatableNumberView: View, Animatable {
var number: Double
var animatableData: Double {
get { number }
set { number = newValue }
}
var body: some View {
Text("\(Int(number))")
.font(.largeTitle)
}
}
// Usage
AnimatableNumberView(number: value)
.animation(.spring, value: value)How it works
1. number changes from 0 to 100 2. SwiftUI calls body for every frame of the animation 3. Each frame gets a new number value: 0 → 5 → 15 → 30 → 55 → 80 → 100 4. Text updates to show the interpolated integer
Performance Warning
Custom Animatable conformance is expensive — SwiftUI calls body for every frame on the main thread. Built-in effects (.scaleEffect(), .opacity()) run off-main-thread and don't call body. Use custom conformance only when built-in modifiers can't achieve the effect (e.g., animating a custom Layout that repositions subviews per-frame).
---
Part 3: @Animatable Macro (iOS 26+)
Overview
The @Animatable macro eliminates the boilerplate of manually conforming to the Animatable protocol.
Before iOS 26, you had to: 1. Manually conform to Animatable 2. Write animatableData getter and setter 3. Use AnimatablePair for multiple properties 4. Exclude non-animatable properties manually
iOS 26+, you just add @Animatable:
@MainActor
@Animatable
struct MyView: View {
var scale: CGFloat
var opacity: Double
var body: some View {
// ...
}
}The macro automatically:
- Generates
Animatableconformance - Inspects all stored properties
- Creates
animatableDatafrom VectorArithmetic-conforming properties - Handles multi-dimensional data with
AnimatablePair
Before/After Comparison
Before @Animatable macro
struct HikingRouteShape: Shape {
var startPoint: CGPoint
var endPoint: CGPoint
var elevation: Double
var drawingDirection: Bool // Don't want to animate this
// Tedious manual animatableData declaration
var animatableData: AnimatablePair<AnimatablePair<CGFloat, CGFloat>,
AnimatablePair<Double, AnimatablePair<CGFloat, CGFloat>>> {
get {
AnimatablePair(
AnimatablePair(startPoint.x, startPoint.y),
AnimatablePair(elevation, AnimatablePair(endPoint.x, endPoint.y))
)
}
set {
startPoint = CGPoint(x: newValue.first.first, y: newValue.first.second)
elevation = newValue.second.first
endPoint = CGPoint(x: newValue.second.second.first, y: newValue.second.second.second)
}
}
func path(in rect: CGRect) -> Path {
// Drawing code
}
}After @Animatable macro
@Animatable
struct HikingRouteShape: Shape {
var startPoint: CGPoint
var endPoint: CGPoint
var elevation: Double
@AnimatableIgnored
var drawingDirection: Bool // Excluded from animation
func path(in rect: CGRect) -> Path {
// Drawing code
}
}Lines of code: 20 → 12 (40% reduction)
@AnimatableIgnored
Use @AnimatableIgnored to exclude properties from animation.
When to use
- Debug values — Flags for development only
- IDs — Identifiers that shouldn't animate
- Timestamps — When the view was created/updated
- Internal state — Non-visual bookkeeping
- Non-VectorArithmetic types — Colors, strings, booleans
Example
@MainActor
@Animatable
struct ProgressView: View {
var progress: Double // Animated
var totalItems: Int // Animated (if Float, not if Int)
@AnimatableIgnored
var title: String // Not animated
@AnimatableIgnored
var startTime: Date // Not animated
@AnimatableIgnored
var debugEnabled: Bool // Not animated
var body: some View {
VStack {
Text(title)
ProgressBar(value: progress)
if debugEnabled {
Text("Started: \(startTime.formatted())")
}
}
}
}Real-World Use Case
@Animatable works for any numeric display — stock prices, heart rate, scores, timers, progress bars:
@MainActor
@Animatable
struct AnimatedValueView: View {
var value: Double
var changePercent: Double
@AnimatableIgnored
var label: String
var body: some View {
VStack(alignment: .trailing) {
Text("\(value, format: .number.precision(.fractionLength(2)))")
.font(.title)
Text("\(changePercent > 0 ? "+" : "")\(changePercent, format: .percent)")
.foregroundStyle(changePercent > 0 ? .green : .red)
}
}
}
// Usage
AnimatedValueView(value: currentPrice, changePercent: 0.025, label: "Price")
.animation(.spring(duration: 0.8), value: currentPrice)---
Part 4: Animation Types
Timing Curve Animations
Timing curve animations use bezier curves to control the speed of animation over time.
Built-in presets
.animation(.linear) // Constant speed
.animation(.easeIn) // Starts slow, ends fast
.animation(.easeOut) // Starts fast, ends slow
.animation(.easeInOut) // Slow start and end, fast middleCustom timing curves
let customCurve = UnitCurve(
startControlPoint: CGPoint(x: 0.2, y: 0),
endControlPoint: CGPoint(x: 0.8, y: 1)
)
.animation(.timingCurve(customCurve, duration: 0.5))Duration
All timing curve animations accept an optional duration:
.animation(.easeInOut(duration: 0.3))
.animation(.linear(duration: 1.0))Default: 0.35 seconds
Spring Animations
Spring animations use physics simulation to create natural, organic motion.
Built-in presets
.animation(.smooth) // No bounce (default since iOS 17)
.animation(.snappy) // Small amount of bounce
.animation(.bouncy) // Larger amount of bounceCustom springs
.animation(.spring(duration: 0.6, bounce: 0.3))Parameters
duration— Perceived animation durationbounce— Amount of bounce (0 = no bounce, 1 = very bouncy)
Much more intuitive than traditional spring parameters (mass, stiffness, damping).
Higher-Order Animations
Modify base animations to create complex effects.
Delay
.animation(.spring.delay(0.5))Waits 0.5 seconds before starting the animation.
Repeat
.animation(.easeInOut.repeatCount(3, autoreverses: true))
.animation(.linear.repeatForever(autoreverses: false))Repeats the animation multiple times or infinitely.
Speed
.animation(.spring.speed(2.0)) // 2x faster
.animation(.spring.speed(0.5)) // 2x slowerMultiplies the animation speed.
Default Animation Changes (iOS 17+)
Before iOS 17
withAnimation {
// Used timing curve by default
}iOS 17+
withAnimation {
// Uses .smooth spring by default
}Why the change: Spring animations feel more natural and preserve velocity when interrupted.
Recommendation: Embrace springs. They make your UI feel more responsive and polished.
---
Part 5: Transaction System
withAnimation
The most common way to trigger an animation.
Button("Scale Up") {
withAnimation(.spring) {
scale = 1.5
}
}How it works
1. withAnimation opens a transaction 2. Sets the animation in the transaction dictionary 3. Executes the closure (state changes) 4. Transaction propagates down the view hierarchy 5. Animatable attributes check for animation and interpolate
Explicit animation
withAnimation(.spring(duration: 0.6, bounce: 0.4)) {
isExpanded.toggle()
}No animation
withAnimation(nil) {
// Changes happen immediately, no animation
resetState()
}animation() View Modifier
Apply animations to specific values within a view.
Basic usage
Circle()
.fill(isActive ? .blue : .gray)
.animation(.spring, value: isActive)How it works: Animation only applies when isActive changes. Other state changes won't trigger this animation.
Multiple animations on same view
Circle()
.scaleEffect(scale)
.animation(.bouncy, value: scale)
.opacity(opacity)
.animation(.easeInOut, value: opacity)Different animations for different properties.
Scoped Animations (iOS 17+)
Narrowly scope animations to specific animatable attributes.
Problem with old approach
struct AvatarView: View {
var selected: Bool
var body: some View {
Image("avatar")
.scaleEffect(selected ? 1.5 : 1.0)
.animation(.spring, value: selected)
// ⚠️ If image also changes when selected changes,
// image transition gets animated too (accidental)
}
}Solution: Scoped animation
struct AvatarView: View {
var selected: Bool
var body: some View {
Image("avatar")
.animation(.spring, value: selected) {
$0.scaleEffect(selected ? 1.5 : 1.0)
}
// ✅ Only scaleEffect animates, image transition doesn't
}
}How it works
- Animation only applies to attributes in the closure
- Other attributes are unaffected
- Prevents accidental animations
Custom Transaction Keys
Define custom TransactionKey types to propagate context through the transaction system. Use withTransaction to set values and .transaction modifier to read them. This enables applying different animations based on how a state change was triggered (tap vs programmatic).
---
Part 6: Advanced Topics
CustomAnimation Protocol
Implement your own animation algorithms.
protocol CustomAnimation {
// Calculate current value
func animate<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: inout AnimationContext<V>
) -> V?
// Optional: Should this animation merge with previous?
func shouldMerge<V>(previous: Animation, value: V, time: TimeInterval, context: inout AnimationContext<V>) -> Bool
// Optional: Current velocity
func velocity<V: VectorArithmetic>(
value: V,
time: TimeInterval,
context: AnimationContext<V>
) -> V?
}Example: Linear timing curve
struct LinearAnimation: CustomAnimation {
let duration: TimeInterval
func animate<V: VectorArithmetic>(
value: V, // Delta vector: target - current
time: TimeInterval,
context: inout AnimationContext<V>
) -> V? {
if time >= duration { return nil }
return value.scaled(by: time / duration)
}
}Critical understanding: value is the delta vector (target - current), not the target. Return nil when done. SwiftUI adds the scaled delta to the current value automatically.
Animation Merging Behavior
What happens when a new animation starts before the previous one finishes?
Timing curve animations (default: don't merge)
func shouldMerge(...) -> Bool {
return false // Default implementation
}Behavior: Both animations run together, results are combined additively.
Example
- First tap: animate 1.0 → 1.5 (running)
- Second tap (before finish): animate 1.5 → 1.0
- Result: Both animations run, values combine
Spring animations (merge and retarget)
func shouldMerge(...) -> Bool {
return true // Springs override this
}Behavior: New animation incorporates state of previous animation, preserving velocity.
Example
- First tap: animate 1.0 → 1.5 with velocity V
- Second tap (before finish): retarget to 1.0, preserving current velocity V
- Result: Smooth transition, no sudden velocity change
Why springs feel more natural: They preserve momentum when interrupted.
---
Part 7: Multi-Step Animations (iOS 17+)
PhaseAnimator
Cycles through a sequence of phases, applying different modifiers at each phase. Each phase transition is independently animated.
PhaseAnimator([false, true]) { phase in
Image(systemName: "star.fill")
.scaleEffect(phase ? 1.5 : 1.0)
.opacity(phase ? 1.0 : 0.5)
.rotationEffect(.degrees(phase ? 360 : 0))
} animation: { phase in
phase ? .spring(duration: 0.8, bounce: 0.3) : .easeInOut(duration: 0.4)
}How it works: Begins at first phase, animates to second, then loops. The animation closure returns the animation used to transition INTO that phase. Phases can be any Equatable type — use an enum for complex multi-step sequences:
enum PulsePhase: CaseIterable { case idle, expand, contract }
PhaseAnimator(PulsePhase.allCases) { phase in
Circle()
.scaleEffect(phase == .expand ? 1.3 : phase == .contract ? 0.9 : 1.0)
}Trigger: Add a trigger parameter to run the animation only when a value changes (instead of looping continuously).
KeyframeAnimator
Provides per-property keyframe tracks for precise, timeline-based animations. More control than PhaseAnimator.
struct AnimationValues {
var scale: Double = 1.0
var rotation: Angle = .zero
var yOffset: Double = 0
}
KeyframeAnimator(initialValue: AnimationValues()) { values in
Image(systemName: "heart.fill")
.scaleEffect(values.scale)
.rotationEffect(values.rotation)
.offset(y: values.yOffset)
} keyframes: { _ in
KeyframeTrack(\.scale) {
SpringKeyframe(1.5, duration: 0.3)
SpringKeyframe(1.0, duration: 0.3)
}
KeyframeTrack(\.rotation) {
LinearKeyframe(.degrees(15), duration: 0.15)
LinearKeyframe(.degrees(-15), duration: 0.3)
LinearKeyframe(.zero, duration: 0.15)
}
KeyframeTrack(\.yOffset) {
CubicKeyframe(-20, duration: 0.3)
CubicKeyframe(0, duration: 0.3)
}
}Keyframe types: LinearKeyframe (constant velocity), SpringKeyframe (spring physics), CubicKeyframe (bezier curves), MoveKeyframe (instant jump, no interpolation).
vs PhaseAnimator: Use PhaseAnimator for simple state cycling. Use KeyframeAnimator when different properties need independent timing.
.transition()
Defines how a view animates when inserted/removed from the view hierarchy.
if showDetail {
DetailView()
.transition(.slide) // Slide in/out
.transition(.scale.combined(with: .opacity)) // Combine transitions
.transition(.move(edge: .bottom)) // Move from edge
.transition(.asymmetric( // Different in/out
insertion: .scale.combined(with: .opacity),
removal: .opacity
))
}Requires animation context — wrap the state change in withAnimation or use .animation() modifier. Without animation, the view appears/disappears instantly.
matchedGeometryEffect
Smoothly animate a view's frame between two positions in the hierarchy. Commonly used for hero transitions and shared element animations.
@Namespace private var animation
// Source
if !isExpanded {
RoundedRectangle(cornerRadius: 10)
.matchedGeometryEffect(id: "card", in: animation)
.frame(width: 100, height: 100)
}
// Destination
if isExpanded {
RoundedRectangle(cornerRadius: 20)
.matchedGeometryEffect(id: "card", in: animation)
.frame(width: 300, height: 400)
}Key rules: Same id + same Namespace = matched pair. Only one view with a given ID should be isSource: true (default) at a time. Wrap state change in withAnimation for smooth interpolation.
contentTransition
Animates changes to text and symbol content within a view (iOS 16+).
Text(value, format: .number)
.contentTransition(.numericText(countsDown: value < previous))
Image(systemName: isFavorite ? "heart.fill" : "heart")
.contentTransition(.symbolEffect(.replace))---
Part 8: Zoom Transitions (iOS 18+)
Overview
iOS 18 introduces the zoom transition, where a tapped cell morphs into the incoming view. This transition is continuously interactive—users can grab and drag the view during or after the transition begins.
Key benefit In parts of your app where you transition from a large cell, zoom transitions increase visual continuity by keeping the same UI elements on screen across the transition.
SwiftUI Implementation
Two steps to adopt zoom transitions:
Step 1: Declare the transition style on the destination
NavigationLink {
BraceletEditor(bracelet)
.navigationTransition(.zoom(sourceID: bracelet.id, in: namespace))
} label: {
BraceletPreview(bracelet)
}Step 2: Mark the source view
BraceletPreview(bracelet)
.matchedTransitionSource(id: bracelet.id, in: namespace)Complete example
struct BraceletListView: View {
@Namespace private var braceletList
let bracelets: [Bracelet]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150))]) {
ForEach(bracelets) { bracelet in
NavigationLink {
BraceletEditor(bracelet: bracelet)
.navigationTransition(
.zoom(sourceID: bracelet.id, in: braceletList)
)
} label: {
BraceletPreview(bracelet: bracelet)
}
.matchedTransitionSource(id: bracelet.id, in: braceletList)
}
}
}
}
}
}UIKit Implementation
Set preferredTransition = .zoom { context in ... } on the pushed view controller. The closure returns the source view and is called on both zoom in and zoom out — capture a stable identifier (model object), not a view directly.
Presentations
Zoom transitions also work with fullScreenCover and sheet:
.fullScreenCover(item: $selectedBracelet) { bracelet in
BraceletEditor(bracelet: bracelet)
.navigationTransition(.zoom(sourceID: bracelet.id, in: namespace))
}Styling the Source View
.matchedTransitionSource(id: bracelet.id, in: namespace) { source in
source.cornerRadius(8.0).shadow(radius: 4)
}Fluid Transition Lifecycle
Push transitions cannot be cancelled — when interrupted, they convert to pop transitions. The view controller always reaches the Appeared state. Don't guard against overlapping transitions; let the system handle them.
---
Part 9: UIKit/AppKit Animation Bridging (iOS 18+)
Overview
iOS 18 enables using SwiftUI Animation types to animate UIKit and AppKit views. This provides access to the full suite of SwiftUI animations, including custom animations.
Basic Usage
// Old way
UIView.animate(withDuration: 0.5, delay: 0,
usingSpringWithDamping: 0.7, initialSpringVelocity: 0.5) {
bead.center = endOfBracelet
}
// New way: Use SwiftUI Animation type
UIView.animate(.spring(duration: 0.5)) {
bead.center = endOfBracelet
}All SwiftUI animations work: .linear, .easeIn/Out, .spring, .smooth, .snappy, .bouncy, .repeatForever(), and custom animations.
Architecture note: Unlike old UIKit APIs, no CAAnimation is generated — presentation values are animated directly.
---
Part 10: UIViewRepresentable Animation Bridging (iOS 18+)
The Problem
When wrapping UIKit views in SwiftUI, animations don't automatically bridge:
struct BeadBoxWrapper: UIViewRepresentable {
@Binding var isOpen: Bool
func updateUIView(_ box: BeadBox, context: Context) {
// ❌ Animation on binding doesn't affect UIKit
box.lid.center.y = isOpen ? -100 : 100
}
}
// Usage
BeadBoxWrapper(isOpen: $isOpen)
.animation(.spring, value: isOpen) // No effect on UIKit viewThe Solution: context.animate()
Use context.animate() to bridge SwiftUI animations:
struct BeadBoxWrapper: UIViewRepresentable {
@Binding var isOpen: Bool
func makeUIView(context: Context) -> BeadBox {
BeadBox()
}
func updateUIView(_ box: BeadBox, context: Context) {
// ✅ Bridges animation from Transaction to UIKit
context.animate {
box.lid.center.y = isOpen ? -100 : 100
}
}
}How It Works
1. SwiftUI stores animation info in the current Transaction 2. context.animate() reads the Transaction's animation 3. Applies that animation to UIView changes in the closure 4. If no animation in Transaction, changes happen immediately (no animation)
Key Behavior
context.animate {
// Changes here
} completion: {
// Called when animation completes
// If not animated, called immediately inline
}Works whether animated or not — safe to always use this pattern.
Perfect Synchronization
A single animation running across SwiftUI Views and UIViews runs perfectly in sync. This enables seamless mixed hierarchies.
---
Part 11: Gesture-Driven Animations (iOS 18+)
Automatic Velocity Preservation
SwiftUI animations automatically preserve velocity through animation merging — no manual velocity calculation needed:
// UIKit with SwiftUI animations
func handlePan(_ gesture: UIPanGestureRecognizer) {
switch gesture.state {
case .changed:
UIView.animate(.interactiveSpring) {
bead.center = gesture.location(in: view)
}
case .ended:
UIView.animate(.spring) { // Inherits velocity automatically
bead.center = endOfBracelet
}
default: break
}
}
// Pure SwiftUI equivalent
DragGesture()
.onChanged { value in
withAnimation(.interactiveSpring) { position = value.location }
}
.onEnded { _ in
withAnimation(.spring) { position = targetPosition }
}Each .interactiveSpring retargets the previous animation, and the final .spring inherits the accumulated velocity for smooth deceleration.
---
Troubleshooting
Property Not Animating
Check in order: 1. Type conforms to VectorArithmetic? — Int can't animate; use Double/Float 2. Animation modifier present? — Need .animation(.spring, value: x) or withAnimation 3. Correct value tracked? — .animation(.spring, value: progress) not .animation(.spring, value: title) 4. View conforms to Animatable? — Custom views need @Animatable (iOS 26+) or manual animatableData
Animation Stuttering
Custom Animatable conformance calls body every frame on main thread. Use built-in effects (.opacity(), .scaleEffect()) when possible — they run off-main-thread. Profile with Instruments for complex cases.
Unexpected Animation Merging
Spring animations merge by default, preserving velocity. Use timing curve animations (.easeInOut) if you don't want merging behavior. See Animation Merging Behavior section above.
---
Resources
WWDC: 2023-10156, 2023-10157, 2023-10158, 2024-10145, 2025-256
Docs: /swiftui/animatable, /swiftui/animation, /swiftui/vectorarithmetic, /swiftui/transaction, /swiftui/view/navigationtransition(_:), /swiftui/view/matchedtransitionsource(id:in:configuration:), /uikit/uiview/animate(_:changes:completion:)
Skills: skills/26-ref.md, skills/nav-ref.md, skills/swiftui-performance.md, skills/debugging.md, axiom-design (skills/sf-symbols-ref.md)
SwiftUI Architecture
When to Use This Skill
Use this skill when:
- You have logic in your SwiftUI view files and want to extract it
- Choosing between MVVM, TCA, vanilla SwiftUI patterns, or Coordinator
- Refactoring views to separate concerns
- Making SwiftUI code testable
- Asking "where should this code go?"
- Deciding which property wrapper to use (@State, @Environment, @Bindable)
- Organizing a SwiftUI codebase for team development
Example Prompts
| What You Might Ask | Why This Skill Helps |
|---|---|
| "There's quite a bit of code in my model view files about logic things. How do I extract it?" | Provides refactoring workflow with decision trees for where logic belongs |
| "Should I use MVVM, TCA, or Apple's vanilla patterns?" | Decision criteria based on app complexity, team size, testability needs |
| "How do I make my SwiftUI code testable?" | Shows separation patterns that enable testing without SwiftUI imports |
| "Where should formatters and calculations go?" | Anti-patterns section prevents logic in view bodies |
| "Which property wrapper do I use?" | Decision tree for @State, @Environment, @Bindable, or plain properties |
Quick Architecture Decision Tree
What's driving your architecture choice?
│
├─ Starting fresh, small/medium app, want Apple's patterns?
│ └─ Use Apple's Native Patterns (Part 1)
│ - @Observable models for business logic
│ - State-as-Bridge for async boundaries
│ - Property wrapper decision tree
│
├─ Familiar with MVVM from UIKit?
│ └─ Use MVVM Pattern (Part 2)
│ - ViewModels as presentation adapters
│ - Clear View/ViewModel/Model separation
│ - Works well with @Observable
│
├─ Complex app, need rigorous testability, team consistency?
│ └─ Consider TCA (Part 3)
│ - State/Action/Reducer/Store architecture
│ - Excellent testing story
│ - Learning curve + boilerplate trade-off
│
└─ Complex navigation, deep linking, multiple entry points?
└─ Add Coordinator Pattern (Part 4)
- Can combine with any of the above
- Extracts navigation logic from views
- NavigationPath + Coordinator objects---
Part 1: Apple's Native Patterns (iOS 26+)
Core Principle
"A data model provides separation between the data and the views that interact with the data. This separation promotes modularity, improves testability, and helps make it easier to reason about how the app works."
— Apple Developer Documentation
Apple's modern SwiftUI patterns (WWDC 2023-2025) center on: 1. @Observable for data models (replaces ObservableObject) 2. State-as-Bridge for async boundaries (WWDC 2025) 3. Three property wrappers: @State, @Environment, @Bindable 4. Synchronous UI updates for animations
The State-as-Bridge Pattern
Problem
Async functions create suspension points that can break animations:
// ❌ Problematic: Animation might miss frame deadline
struct ColorExtractorView: View {
@State private var isLoading = false
var body: some View {
Button("Extract Colors") {
Task {
isLoading = true // Synchronous ✅
await extractColors() // ⚠️ Suspension point!
isLoading = false // ❌ Might happen too late
}
}
.scaleEffect(isLoading ? 1.5 : 1.0) // ⚠️ Animation timing uncertain
}
}Solution: Use State as a Bridge
"Find the boundaries between UI code that requires time-sensitive changes, and long-running async logic."
// ✅ Correct: State bridges UI and async code
@Observable
class ColorExtractor {
var isLoading = false
var colors: [Color] = []
func extract(from image: UIImage) async {
// This method is async and can live in the model
let extracted = await heavyComputation(image)
// Synchronous mutation for UI update
self.colors = extracted
}
}
struct ColorExtractorView: View {
let extractor: ColorExtractor
var body: some View {
Button("Extract Colors") {
// Synchronous state change for animation
withAnimation {
extractor.isLoading = true
}
// Launch async work
Task {
await extractor.extract(from: currentImage)
// Synchronous state change for animation
withAnimation {
extractor.isLoading = false
}
}
}
.scaleEffect(extractor.isLoading ? 1.5 : 1.0)
}
}Benefits:
- UI logic stays synchronous (animations work correctly)
- Async code lives in the model (testable without SwiftUI)
- Clear boundary between time-sensitive UI and long-running work
Property Wrapper Decision Tree
There are only 3 questions to answer:
Which property wrapper should I use?
│
├─ Does this model need to be STATE OF THE VIEW ITSELF?
│ └─ YES → Use @State
│ Examples: Form inputs, local toggles, sheet presentations
│ Lifetime: Managed by the view's lifetime
│
├─ Does this model need to be part of the GLOBAL ENVIRONMENT?
│ └─ YES → Use @Environment
│ Examples: User account, app settings, dependency injection
│ Lifetime: Lives at app/scene level
│
├─ Does this model JUST NEED BINDINGS?
│ └─ YES → Use @Bindable
│ Examples: Editing a model passed from parent
│ Lightweight: Only enables $ syntax for bindings
│
└─ NONE OF THE ABOVE?
└─ Use as plain property
Examples: Immutable data, parent-owned models
No wrapper needed: @Observable handles observationExamples
// ✅ @State — View owns the model
struct DonutEditor: View {
@State private var donutToAdd = Donut() // View's own state
var body: some View {
TextField("Name", text: $donutToAdd.name)
}
}
// ✅ @Environment — App-wide model
struct MenuView: View {
@Environment(Account.self) private var account // Global
var body: some View {
Text("Welcome, \(account.userName)")
}
}
// ✅ @Bindable — Need bindings to parent-owned model
struct DonutRow: View {
@Bindable var donut: Donut // Parent owns it
var body: some View {
TextField("Name", text: $donut.name) // Need binding
}
}
// ✅ Plain property — Just reading
struct DonutRow: View {
let donut: Donut // Parent owns, no binding needed
var body: some View {
Text(donut.name) // Just reading
}
}@State is a macro now (Xcode 27) — three source-compat breaks
Xcode 27 reimplements @State as a Swift macro so an initial-value expression (@State private var model = Model()) is evaluated once instead of on every view re-instantiation. The new behavior back-deploys to the iOS 17-aligned OSes and is mostly source-compatible — but three patterns that compiled under the property-wrapper @State no longer do:
1. *Initial value at the declaration and an assignment in `init`.* The init assignment was always silently discarded; now it also fails to compile. Fix: drop the declaration's initial value when you assign in init.
@State private var page: StickerPage // no initial-value expression
init(title: String) { self.page = StickerPage(title: title); self.title = title } // compiles2. The synthesized memberwise initializer is disabled by the macro, so an extension calling self.init(page:title:) breaks. Fix: assign the members explicitly (self.title = title; self.page = page). 3. Composing `@State` with another property wrapper or macro is unsupported.
Also: generic-argument inference is slightly less flexible — write the @State type explicitly if inference fails. There's no availability gate (the change back-deploys); it's a build-time behavior of the Xcode 27 toolchain, so it bites the moment you build with the 27 SDK regardless of deployment target.
@Observable Model Pattern
Use @Observable for business logic that needs to trigger UI updates:
// ✅ Domain model with business logic
@Observable
class FoodTruckModel {
var orders: [Order] = []
var donuts = Donut.all
var orderCount: Int {
orders.count // Computed properties work automatically
}
func addDonut() {
donuts.append(Donut())
}
}
// ✅ View automatically tracks accessed properties
struct DonutMenu: View {
let model: FoodTruckModel // No wrapper needed!
var body: some View {
List {
Section("Donuts") {
ForEach(model.donuts) { donut in
Text(donut.name) // Tracks model.donuts
}
Button("Add") {
model.addDonut()
}
}
Section("Orders") {
Text("Count: \(model.orderCount)") // Tracks model.orders
}
}
}
}How it works:
- SwiftUI tracks which properties are accessed during
bodyexecution - Only those properties trigger view updates when changed
- Granular dependency tracking = better performance
ViewModel Adapter Pattern
Use ViewModels as presentation adapters when you need filtering, sorting, or view-specific logic:
// ✅ ViewModel as presentation adapter
@Observable
class PetStoreViewModel {
let petStore: PetStore // Domain model
var searchText: String = ""
// View-specific computed property
var filteredPets: [Pet] {
guard !searchText.isEmpty else { return petStore.myPets }
return petStore.myPets.filter { $0.name.contains(searchText) }
}
}
struct PetListView: View {
@Bindable var viewModel: PetStoreViewModel
var body: some View {
List {
ForEach(viewModel.filteredPets) { pet in
PetRowView(pet: pet)
}
}
.searchable(text: $viewModel.searchText)
}
}When to use a ViewModel adapter:
- Filtering, sorting, grouping for display
- Formatting for presentation (but NOT heavy computation)
- View-specific state that doesn't belong in domain model
- Bridging between domain model and SwiftUI conventions
When NOT to use a ViewModel:
- Simple views that just display model data
- Logic that belongs in the domain model
- Over-extraction just for "pattern purity"
---
Bridging Actor State to SwiftUI
SwiftUI's @Observable and ObservableObject types must be @MainActor — view bodies render on MainActor and need synchronous access to observed state. Custom actors live in their own isolation domain. The two don't connect directly.
A proxy layer is unavoidable when you want SwiftUI to observe state owned by a custom actor. Some boilerplate is the cost of safe non-UI concurrency.
The standard pattern: actor owns the source of truth; a @MainActor @Observable model holds a snapshot; the model subscribes to actor updates and publishes changes to views.
// Source of truth — lives off-main
actor InventoryStore {
private var items: [Item] = []
private var listeners: [AsyncStream<[Item]>.Continuation] = []
func current() -> [Item] { items }
func updates() -> AsyncStream<[Item]> {
AsyncStream { continuation in
listeners.append(continuation)
continuation.yield(items)
}
}
func setItems(_ newItems: [Item]) {
items = newItems
listeners.forEach { $0.yield(newItems) }
}
}
// Proxy for SwiftUI
@MainActor
@Observable
final class InventoryModel {
private(set) var items: [Item] = []
private let store: InventoryStore
private var observationTask: Task<Void, Never>?
init(store: InventoryStore) {
self.store = store
}
func start() {
observationTask = Task { [weak self] in
guard let stream = await self?.store.updates() else { return }
for await snapshot in stream {
self?.items = snapshot
}
}
}
deinit { observationTask?.cancel() }
}
struct InventoryView: View {
@State private var model: InventoryModel
var body: some View {
List(model.items) { item in Text(item.name) }
.onAppear { model.start() }
}
}The proxy buys you:
- Safe off-main state ownership in the actor
- SwiftUI-compatible observable surface on MainActor
- Decoupling — the actor doesn't know SwiftUI exists; the model doesn't know about non-UI consumers
The cost is the proxy code itself, which scales linearly with the number of actor-owned subsystems you need to surface. There's no way to eliminate this without giving up either actor isolation or SwiftUI's observability model.
---
.task Modifier Lifecycle
The .task modifier is the canonical way to attach async work to a SwiftUI view. Its cancellation timing is the most common source of confusion because it does NOT match what developers usually assume.
When .task cancels
.task cancels when the view is destroyed, which has the same timeline as onDisappear. Specifically:
| Event | Does .task cancel? |
|---|---|
| State change re-evaluates view body | No — body re-evaluation does NOT destroy the view |
Conditional branch flips (if condition { ... } else { ... }) | Yes — the un-rendered branch's views are destroyed |
View is popped from NavigationStack | Yes — destruction |
| Parent removes the view from its hierarchy | Yes — destruction |
| Sheet/popover is dismissed | Yes — destruction (when the sheet view goes away) |
| App backgrounds | No — destruction is a view-tree event, not app lifecycle |
struct ContentView: View {
@State private var counter = 0
var body: some View {
VStack {
Button("Increment") { counter += 1 }
DataView()
.task {
// ✅ Runs once when DataView first appears
// ❌ Does NOT restart when `counter` changes — DataView is reused
await loadData()
}
}
}
}State changes that re-evaluate the body keep the same view identity. .task is tied to view identity, not body evaluation. If you want the task to restart on a value change, use .task(id:):
DataView(id: selectedID)
.task(id: selectedID) {
// ✅ Cancels and restarts whenever selectedID changes
await loadData(id: selectedID)
}When you need fine-grained cancellation
SwiftUI does not expose the Task handle that .task creates. If you need to cancel based on a signal that isn't view destruction (e.g., user taps a "stop" button, network condition changes, a model state requires aborting), own the Task yourself:
struct DownloadView: View {
@State private var downloadTask: Task<Void, Never>?
var body: some View {
VStack {
Button("Cancel") { downloadTask?.cancel() }
}
.onAppear {
downloadTask = Task {
await performDownload()
}
}
.onDisappear {
downloadTask?.cancel() // Required — view destruction won't cancel manual tasks
}
}
}This pattern recreates .task's "cancel on view destruction" behavior via onAppear + onDisappear, while also exposing the handle for explicit cancellation.
.task(id:) Pitfalls
The .task(id:) variant restarts when the id value changes by Equatable comparison. Three specific failure modes:
Equality-stuck repeated assignment. Assigning the same value to your id doesn't trigger a restart, because oldValue == newValue returns true. This bites refresh buttons that use a sentinel:
@State private var refreshFlag = false
// ❌ Second tap is a no-op — refreshFlag is already true
Button("Refresh") { refreshFlag = true }
.task(id: refreshFlag) { await load() }
// ✅ Use a monotonically increasing token so every action produces a new value
@State private var refreshToken = UUID()
Button("Refresh") { refreshToken = UUID() }
.task(id: refreshToken) { await load() }A common workaround is .toggle() to force a value change, but that couples the Bool's semantics to a flag-flipping protocol and breaks if any other code path also writes to the flag. UUID() or an incrementing Int is unambiguous.
Identity collision in Equatable structs. .task(id:) requires only Equatable. If your id is a struct with custom Equatable (or one that derives equality from a subset of fields), changing a non-included field won't restart the task:
struct Filter: Equatable {
var category: String
var sortOrder: SortOrder
var debugLabel: String // Used only for diagnostics
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.category == rhs.category && lhs.sortOrder == rhs.sortOrder
// debugLabel intentionally excluded
}
}
// ❌ Changing debugLabel won't restart the task — Equatable says "no change"
.task(id: filter) { await load(filter) }If you need every user-perceived change to restart, either ensure the Equatable conformance covers every meaningful field, or use a separate UUID bump alongside the value change.
Spurious restart from continuously-changing state. Don't use a value as id if it changes for reasons unrelated to the work the task does. A timestamp, a frequently-updated model field, or a derived value that ticks on every render will restart the task far more often than intended — usually every body re-evaluation. Pick an id whose changes correspond exactly to "the task's input changed."
When to skip .task(id:) entirely
For pure refresh-button patterns where the task body doesn't depend on the id value, .task(id:) is often the wrong tool. The cleaner alternative is to run the async work directly from the button action and use plain .task { } for the initial load:
struct ProductListView: View {
@State private var products: [Product] = []
var body: some View {
VStack {
Button("Refresh") {
Task { products = await ProductService.fetchAll() }
}
List(products) { Text($0.name) }
}
.task { // Initial load on appear
products = await ProductService.fetchAll()
}
}
}This separates two concerns that .task(id:) conflates: "load once when the view appears" and "reload on user demand." The button-spawned Task has the same view-destruction-cancels-it lifetime if you store its handle, or it's fire-and-forget if you don't. You avoid the entire family of id pitfalls (equality-stuck, identity collision, spurious restart) by not using id at all.
Reach for .task(id:) only when the task body genuinely depends on the id value — for example, fetching details for whichever item the user selected, where the selection drives both the cancellation and the query parameter.
NavigationStack and .task Lifetime
A child view pushed onto a NavigationStack has its .task cancelled when the user pops back. But the child view doesn't carry state across re-entry — pushing the same destination again creates a fresh view (new @State, new .task invocation). If you need the task's result to persist across navigations, store it on a model that lives outside the view (an @Observable on the parent or in an @Environment value), not on the view's local @State.
// ❌ Results lost when user pops and pushes again
struct DetailView: View {
@State private var data: [Item] = []
var body: some View {
List(data) { Text($0.name) }
.task { data = await fetch() } // Re-runs on every push
}
}
// ✅ Results survive navigation
@Observable @MainActor
final class DetailModel { var data: [Item] = [] }
struct DetailView: View {
@Bindable var model: DetailModel
var body: some View {
List(model.data) { Text($0.name) }
.task {
if model.data.isEmpty { model.data = await fetch() }
}
}
}---
Part 2: MVVM Pattern
When to Use MVVM
MVVM (Model-View-ViewModel) is appropriate when:
✅ You're familiar with it from UIKit — Easier onboarding for team ✅ You want explicit View/ViewModel separation — Clear contracts ✅ You have complex presentation logic — Multiple filtering/sorting operations ✅ You're migrating from UIKit — Familiar mental model
❌ Avoid MVVM when:
- Views are simple (just displaying data)
- You're starting fresh with SwiftUI (Apple's patterns are simpler)
- You're creating unnecessary abstraction layers
MVVM Structure for SwiftUI
// Model — Domain data and business logic
struct Pet: Identifiable {
let id: UUID
var name: String
var kind: Kind
var trick: String
var hasAward: Bool = false
mutating func giveAward() {
hasAward = true
}
}
// ViewModel — Presentation logic
@Observable
class PetListViewModel {
private let petStore: PetStore
var pets: [Pet] { petStore.myPets }
var searchText: String = ""
var selectedSort: SortOption = .name
var filteredSortedPets: [Pet] {
let filtered = pets.filter { pet in
searchText.isEmpty || pet.name.contains(searchText)
}
return filtered.sorted { lhs, rhs in
switch selectedSort {
case .name: lhs.name < rhs.name
case .kind: lhs.kind.rawValue < rhs.kind.rawValue
}
}
}
init(petStore: PetStore) {
self.petStore = petStore
}
func awardPet(_ pet: Pet) {
petStore.awardPet(pet.id)
}
}
// View — UI only
struct PetListView: View {
@Bindable var viewModel: PetListViewModel
var body: some View {
List {
ForEach(viewModel.filteredSortedPets) { pet in
PetRow(pet: pet) {
viewModel.awardPet(pet)
}
}
}
.searchable(text: $viewModel.searchText)
}
}Common MVVM Mistakes in SwiftUI
❌ Mistake 1: Duplicating @Observable in View and ViewModel
// ❌ @State + @Observable is redundant — @State creates its own storage
struct MyView: View {
@State private var viewModel = MyViewModel() // ❌ Redundant wrapper
}
// ✅ Pass @Observable directly — use @State only if the view OWNS the lifecycle
struct MyView: View {
let viewModel: MyViewModel // ✅ Or @State if view creates it
}❌ Mistake 2: God ViewModel
// ❌ Don't do this
@Observable
class AppViewModel {
// Settings
var isDarkMode = false
var notificationsEnabled = true
// User
var userName = ""
var userEmail = ""
// Content
var posts: [Post] = []
var comments: [Comment] = []
// ... 50 more properties
}// ✅ Correct: Separate concerns
@Observable
class SettingsViewModel {
var isDarkMode = false
var notificationsEnabled = true
}
@Observable
class UserProfileViewModel {
var user: User
}
@Observable
class FeedViewModel {
var posts: [Post] = []
}❌ Mistake 3: Business Logic in ViewModel
// ❌ Business rules belong in the Model, not the ViewModel
@Observable class OrderViewModel {
func calculateDiscount(for order: Order) -> Double { /* ... */ } // ❌ Business logic
}
// ✅ Model owns business logic; ViewModel only formats for display
struct Order {
func calculateDiscount() -> Double { /* business rules */ }
}
@Observable class OrderViewModel {
let order: Order
var displayDiscount: String {
"$\(order.calculateDiscount(), specifier: "%.2f")" // ✅ Just formatting
}
}---
Part 3: TCA (Composable Architecture)
When to Consider TCA
TCA is a third-party architecture from Point-Free. Consider it when:
✅ Rigorous testability is critical — TestStore makes testing deterministic ✅ Large team needs consistency — Strict patterns reduce variation ✅ Complex state management — Side effects, dependencies, composition ✅ You value Redux-like patterns — Unidirectional data flow
❌ Avoid TCA when:
- Small app or prototype (too much overhead)
- Team unfamiliar with functional programming
- You need rapid iteration (boilerplate slows development)
- You want minimal dependencies
TCA Core Concepts
TCA has 4 building blocks — State (data), Action (events), Reducer (state evolution), and Store (runtime engine). Here they are in a single feature:
@Reducer
struct CounterFeature {
// STATE — Data your feature needs
@ObservableState
struct State {
var count = 0
var fact: String?
var isLoading = false
}
// ACTION — All possible events
enum Action {
case incrementButtonTapped
case decrementButtonTapped
case factButtonTapped
case factResponse(String)
}
// REDUCER — How state evolves in response to actions
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .incrementButtonTapped:
state.count += 1
return .none
case .decrementButtonTapped:
state.count -= 1
return .none
case .factButtonTapped:
state.isLoading = true
return .run { [count = state.count] send in
let fact = try await numberFact(count)
await send(.factResponse(fact))
}
case let .factResponse(fact):
state.isLoading = false
state.fact = fact
return .none
}
}
}
}
// STORE — Runtime that receives actions and executes reducer
struct CounterView: View {
let store: StoreOf<CounterFeature>
var body: some View {
VStack {
Text("\(store.count)")
Button("Increment") { store.send(.incrementButtonTapped) }
}
}
}TCA Trade-offs
✅ Benefits
| Benefit | Description |
|---|---|
| Testability | TestStore makes testing deterministic and exhaustive |
| Consistency | One pattern for all features reduces cognitive load |
| Composition | Small reducers combine into larger features |
| Side effects | Structured effect management (networking, timers, etc.) |
❌ Costs
| Cost | Description |
|---|---|
| Boilerplate | State/Action/Reducer for every feature |
| Learning curve | Concepts from functional programming (effects, dependencies) |
| Dependency | Third-party library, not Apple-supported |
| Iteration speed | More code to write for simple features |
When to Choose TCA Over Apple Patterns
| Scenario | Recommendation |
|---|---|
| Small app (< 10 screens) | Apple patterns (simpler) |
| Medium app, experienced team | TCA if testability is priority |
| Large app, multiple teams | TCA for consistency |
| Rapid prototyping | Apple patterns (faster) |
| Mission-critical (banking, health) | TCA for rigorous testing |
---
Part 4: Coordinator Pattern
When to Use Coordinators
Coordinators extract navigation logic from views. Use when:
✅ Complex navigation — Multiple paths, conditional flows ✅ Deep linking — URL-driven navigation to any screen ✅ Multiple entry points — Same screen from different contexts ✅ Testable navigation — Isolate navigation from UI
SwiftUI Coordinator Implementation
// Minimal coordinator — Route enum + @Observable coordinator + NavigationStack binding
enum Route: Hashable {
case detail(Pet)
case settings
}
@Observable
class AppCoordinator {
var path: [Route] = []
func showDetail(for pet: Pet) { path.append(.detail(pet)) }
func popToRoot() { path.removeAll() }
}
// Root view binds NavigationStack to coordinator's path
struct AppView: View {
@State private var coordinator = AppCoordinator()
var body: some View {
NavigationStack(path: $coordinator.path) {
PetListView(coordinator: coordinator)
.navigationDestination(for: Route.self) { route in
switch route {
case .detail(let pet): PetDetailView(pet: pet, coordinator: coordinator)
case .settings: SettingsView(coordinator: coordinator)
}
}
}
}
}Add deep linking with .onOpenURL and URL-to-route parsing:
// Add to AppCoordinator
func handleDeepLink(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return }
// Parse URL path into routes
if components.path.hasPrefix("/pets/"), let id = components.path.split(separator: "/").last {
path = [.detail(loadPet(id: String(id)))]
}
}
// Add to AppView's body
.onOpenURL { url in coordinator.handleDeepLink(url) }Coordinators are testable without SwiftUI — assert path state directly:
func testDeepLink() {
let coordinator = AppCoordinator()
coordinator.handleDeepLink(URL(string: "myapp://pets/123")!)
XCTAssertEqual(coordinator.path.count, 1) // Navigated to detail
}For state restoration, advanced URL routing, and tab-based coordination, see skills/nav.md — Pattern 7 (Coordinator) for structure, Pattern 1b for URL-based deep linking.
Coordinator + Architecture Combinations
You can combine Coordinators with any architecture:
| Pattern | Coordinator Role |
|---|---|
| Apple Native | Coordinator manages path, @Observable models for data |
| MVVM | Coordinator manages path, ViewModels for presentation |
| TCA | Coordinator manages path, Reducers for features |
---
Part 5: Refactoring Workflow
Step 1: Identify Logic in Views
Run this checklist on your views:
View body contains
- DateFormatter, NumberFormatter creation
- Calculations or data transformations
- API calls or async operations
- Business rules (discounts, validation, etc.)
- Data filtering or sorting
- Heavy string manipulation
- Task { } with complex logic inside
If ANY of these are present, that logic should likely move out.
Step 2: Extract to Appropriate Layer
Use this decision tree:
Where does this logic belong?
│
├─ Pure domain logic (discounts, validation, business rules)?
│ └─ Extract to Model
│ Example: Order.calculateDiscount()
│
├─ Presentation logic (filtering, sorting, formatting)?
│ └─ Extract to ViewModel or computed property
│ Example: filteredItems, displayPrice
│
├─ External side effects (API, database, file system)?
│ └─ Extract to Service
│ Example: APIClient, DatabaseManager
│
└─ Just expensive computation?
└─ Cache with @State or create once
Example: let formatter = DateFormatter()Example: Refactoring Logic from View
// ❌ Before: Logic in view body
struct OrderListView: View {
let orders: [Order]
var body: some View {
let formatter = NumberFormatter() // ❌ Created every render
formatter.numberStyle = .currency
let discounted = orders.filter { order in // ❌ Computed every render
let discount = order.total * 0.1 // ❌ Business logic in view
return discount > 10.0
}
return List(discounted) { order in
Text(formatter.string(from: order.total)!) // ❌ Force unwrap
}
}
}// ✅ After: Logic extracted
// Model — Business logic
struct Order {
let id: UUID
let total: Decimal
var discount: Decimal {
total * 0.1
}
var qualifiesForDiscount: Bool {
discount > 10.0
}
}
// ViewModel — Presentation logic
@Observable
class OrderListViewModel {
let orders: [Order]
private let formatter: NumberFormatter // ✅ Created once
var discountedOrders: [Order] { // ✅ Computed property
orders.filter { $0.qualifiesForDiscount }
}
init(orders: [Order]) {
self.orders = orders
self.formatter = NumberFormatter()
formatter.numberStyle = .currency
}
func formattedTotal(_ order: Order) -> String {
formatter.string(from: order.total as NSNumber) ?? "$0.00"
}
}
// View — UI only
struct OrderListView: View {
let viewModel: OrderListViewModel
var body: some View {
List(viewModel.discountedOrders) { order in
Text(viewModel.formattedTotal(order))
}
}
}Step 3: Verify Testability
Your refactoring succeeded if:
// ✅ Can test without importing SwiftUI
import XCTest
final class OrderTests: XCTestCase {
func testDiscountCalculation() {
let order = Order(id: UUID(), total: 100)
XCTAssertEqual(order.discount, 10)
}
func testQualifiesForDiscount() {
let order = Order(id: UUID(), total: 100)
XCTAssertTrue(order.qualifiesForDiscount)
}
}
final class OrderViewModelTests: XCTestCase {
func testFilteredOrders() {
let orders = [
Order(id: UUID(), total: 50), // Discount: 5 ❌
Order(id: UUID(), total: 200), // Discount: 20 ✅
]
let viewModel = OrderListViewModel(orders: orders)
XCTAssertEqual(viewModel.discountedOrders.count, 1)
}
}Step 4: Update View Bindings
After extraction, update property wrappers:
// Before refactoring
struct OrderListView: View {
@State private var orders: [Order] = [] // View owned
// ... logic in body
}
// After refactoring
struct OrderListView: View {
@State private var viewModel: OrderListViewModel // View owns ViewModel
init(orders: [Order]) {
_viewModel = State(initialValue: OrderListViewModel(orders: orders))
}
}
// Or if parent owns it
struct OrderListView: View {
let viewModel: OrderListViewModel // Parent owns, just reading
}
// Or if need bindings
struct OrderListView: View {
@Bindable var viewModel: OrderListViewModel // Parent owns, need $
}---
Anti-Patterns (DO NOT DO THIS)
❌ Anti-Pattern 1: Logic in View Body
// ❌ Don't do this
struct ProductListView: View {
let products: [Product]
var body: some View {
let formatter = NumberFormatter() // ❌ Created every render!
formatter.numberStyle = .currency
let sorted = products.sorted { $0.price > $1.price } // ❌ Sorted every render!
return List(sorted) { product in
Text("\(product.name): \(formatter.string(from: product.price)!)")
}
}
}Why it's wrong:
formattercreated on every render (performance)sortedcomputed on every render (performance)- Business logic (
sorted) lives in view (not testable) - Force unwrap ('!') can crash
// ✅ Correct
@Observable
class ProductListViewModel {
let products: [Product]
private let formatter = NumberFormatter()
var sortedProducts: [Product] {
products.sorted { $0.price > $1.price }
}
init(products: [Product]) {
self.products = products
formatter.numberStyle = .currency
}
func formattedPrice(_ product: Product) -> String {
formatter.string(from: product.price as NSNumber) ?? "$0.00"
}
}
struct ProductListView: View {
let viewModel: ProductListViewModel
var body: some View {
List(viewModel.sortedProducts) { product in
Text("\(product.name): \(viewModel.formattedPrice(product))")
}
}
}❌ Anti-Pattern 2: Async Code Without Boundaries
See the State-as-Bridge pattern in Part 1 above — keep UI state changes synchronous (inside withAnimation), launch async work separately via Task.
❌ Anti-Pattern 3: Wrong Property Wrapper
// ❌ Don't use @State for passed-in models
struct DetailView: View {
@State var item: Item // ❌ Creates a copy, loses parent changes
}
// ✅ Correct: No wrapper for passed-in models
struct DetailView: View {
let item: Item // ✅ Or @Bindable if you need $item
}// ❌ Don't use @Environment for view-local state
struct FormView: View {
@Environment(FormData.self) var formData // ❌ Overkill for local form
}
// ✅ Correct: @State for view-local
struct FormView: View {
@State private var formData = FormData() // ✅ View owns it
}❌ Anti-Pattern 4: God ViewModel
See MVVM Mistake 2 in Part 2 above — split by concern into separate ViewModels.
❌ Anti-Pattern 5: @AppStorage Inside @Observable
Never use `@AppStorage` inside an `@Observable` class — it silently breaks observation. @AppStorage is a property wrapper designed for SwiftUI views, not model classes.
// ❌ BROKEN — @AppStorage silently breaks @Observable
@Observable
class Settings {
@AppStorage("theme") var theme = "light" // Changes won't trigger view updates
}
// ✅ Read @AppStorage in view, pass to model
struct SettingsView: View {
@AppStorage("theme") private var theme = "light"
// ...
}❌ Anti-Pattern 6: Binding(get:set:) in View Body
Creating Binding(get:set:) in the view body creates a new binding on every evaluation, breaking SwiftUI's identity tracking.
// ❌ New Binding created every body evaluation
var body: some View {
TextField("Name", text: Binding(
get: { model.name },
set: { model.name = $0 }
))
}
// ✅ Use @Bindable or computed binding
var body: some View {
@Bindable var model = model
TextField("Name", text: $model.name)
}❌ Anti-Pattern 7: Circular State in Closures
Any @ViewBuilder closure (.sheet, .fullScreenCover, NavigationStack destination, .popover) re-evaluates when parent state changes. If a child callback mutates the same parent @State that's passed as a child init parameter, the child gets re-initialized with changed values mid-lifecycle.
// ❌ Callback mutates the same state passed as init param
.sheet(item: $sheetItem) { _ in
ChildView(
savedResponse: cachedResponse, // ❌ Parent state as init param
onSuccess: { cachedResponse = $0 } // ❌ Mutates same state
)
}
// ✅ Don't pass state that callbacks will mutate
.sheet(item: $sheetItem) { _ in
ChildView(
onSuccess: { cachedResponse = $0 } // Update parent, but don't read it back
)
}Why it's wrong:
- Callback mutates parent state that the closure depends on
- Parent re-evaluates, which re-evaluates the closure with the mutated value
- Child silently skips loading/animation states — no crash, just wrong behavior
Fixes: (1) Don't pass the mutated state back as an init param. (2) Use a separate @State for the child's display logic. (3) Have the child query its own data source. See Root Cause 5 in skills/debugging.md for full diagnostic workflow.
---
Code Review Checklist
Before merging SwiftUI code, verify:
Views
- View bodies contain ONLY UI code (Text, Button, List, etc.)
- No formatters created in view body
- No calculations or transformations in view body
- No API calls or database queries in view body
- No business rules in view body
Logic Separation
- Business logic is in models (testable without SwiftUI)
- Presentation logic is in ViewModels or computed properties
- Side effects are in services or model methods
- Heavy computations are cached or computed once
Property Wrappers
- @State for view-owned models
- @Environment for app-wide models
- @Bindable when bindings are needed
- No wrapper when just reading
Animations & Async
- State changes for animations are synchronous
- Async boundaries use State-as-Bridge pattern
- No
awaitbetweenwithAnimation { }blocks
Testability
- Can test business logic without importing SwiftUI
- Can test ViewModels without rendering views
- Navigation logic is isolated (if using Coordinators)
---
Pressure Scenarios
Scenario 1: "Just put it in the view for now"
The Pressure
Manager: "We need this feature by Friday. Just put the logic in the view for now, we'll refactor later."
Red Flags
If you hear:
- ❌ "We'll refactor later" (tech debt that never gets paid)
- ❌ "It's just one view" (views multiply)
- ❌ "We don't have time for architecture" (costs more later)
Time Cost Comparison
Option A — Put logic in view
- Write feature in view: 2 hours
- Realize it's untestable: 1 hour
- Try to test it anyway: 2 hours
- Give up, ship with manual testing: 0 hours
- Total: 5 hours, 0 tests
Option B — Extract logic properly
- Create model/ViewModel: 30 min
- Write feature with separation: 2 hours
- Write tests: 1 hour
- Total: 3.5 hours, full test coverage
How to Push Back Professionally
Step 1: Acknowledge the deadline
"I understand Friday is the deadline. Let me show you why proper separation is actually faster."
Step 2: Show the time comparison
"Putting logic in views takes 5 hours with no tests. Extracting it properly takes 3.5 hours with full tests. We save 1.5 hours AND get tests."
Step 3: Offer the compromise
"If we're truly out of time, I can extract 80% now and mark the remaining 20% as tech debt with a ticket. But let's not skip extraction entirely."
Step 4: Document if pressured to proceed
// TODO: TECH DEBT - Extract business logic to ViewModel
// Ticket: PROJ-123
// Added: 2025-12-14
// Reason: Deadline pressure from manager
// Estimated refactor time: 2 hoursWhen to Accept
Only skip extraction if: 1. This is a throwaway prototype (deleted next week) 2. You have explicit time budget for refactoring (scheduled ticket) 3. The view will never grow beyond 20 lines
Scenario 2: "TCA is overkill, just use vanilla SwiftUI"
The Pressure
Tech Lead: "TCA is too complex for this project. Just use vanilla SwiftUI with @Observable."
Decision Criteria
Ask these questions:
| Question | TCA | Vanilla |
|---|---|---|
| Is testability critical (medical, financial)? | ✅ | ❌ |
| Do you have < 5 screens? | ❌ | ✅ |
| Is team experienced with functional programming? | ✅ | ❌ |
| Do you need rapid prototyping? | ❌ | ✅ |
| Is consistency across large team critical? | ✅ | ❌ |
| Do you have complex side effects (sockets, timers)? | ✅ | ~ |
Recommendation matrix:
- 4+ checks for TCA → Use TCA
- 4+ checks for Vanilla → Use Vanilla
- Tie → Start with Vanilla, migrate to TCA if needed
How to Push Back
If arguing FOR TCA:
"I understand TCA feels heavy. But we're building a banking app. The TestStore gives us exhaustive testing that catches bugs before production. The 2-week learning curve is worth it for 2 years of maintenance."
If arguing AGAINST TCA:
"I agree TCA is powerful, but we're prototyping features weekly. The boilerplate will slow us down. Let's use @Observable now and migrate to TCA if we prove the features are worth building."
Scenario 3: "Refactoring will take too long"
The Pressure
PM: "We have 3 features to ship this month. We can't spend 2 weeks refactoring existing views."
Incremental Extraction Strategy
You don't have to refactor everything at once:
Week 1: Extract 1 view
- Pick the most painful view (lots of logic)
- Extract to ViewModel
- Write tests
- Time: 4 hours
Week 2: Extract 2 views
- Now you have a pattern to follow
- Faster than week 1
- Time: 6 hours
Week 3: New features use proper architecture
- Don't refactor old code yet
- All NEW code follows the pattern
- Time: 0 hours (same as before)
Month 2: Gradually refactor as you touch files
- Refactor when fixing bugs in old views
- Refactor when adding features to old views
- Time: Amortized over feature work
How to Push Back
"I'm not proposing we stop feature work for 2 weeks. I'm proposing:
1. Week 1: Extract our worst view (the OrdersView with 500 lines)
2. Week 2: Extract 2 more problematic views
3. Going forward: All NEW features use proper architecture
4. We refactor old views when we touch them anyway
>
This costs 10 hours upfront and saves us 2+ hours per feature going forward."
---
Real-World Impact
Before: Logic in View
// 😰 200 lines of pain
struct OrderListView: View {
@State private var orders: [Order] = []
@State private var searchText = ""
@State private var selectedFilter: FilterType = .all
var body: some View {
// ❌ Formatters created every render
let currencyFormatter = NumberFormatter()
currencyFormatter.numberStyle = .currency
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .medium
// ❌ Business logic in view
let filtered = orders.filter { order in
if !searchText.isEmpty && !order.customerName.contains(searchText) {
return false
}
switch selectedFilter {
case .all: return true
case .pending: return !order.isCompleted
case .completed: return order.isCompleted
case .highValue: return order.total > 1000
}
}
// ❌ More business logic
let sorted = filtered.sorted { lhs, rhs in
if selectedFilter == .highValue {
return lhs.total > rhs.total
} else {
return lhs.date > rhs.date
}
}
return List(sorted) { order in
VStack(alignment: .leading) {
Text(order.customerName)
Text(currencyFormatter.string(from: order.total as NSNumber)!)
Text(dateFormatter.string(from: order.date))
if order.isCompleted {
Image(systemName: "checkmark.circle.fill")
} else {
Button("Complete") {
// ❌ Async logic in view
Task {
do {
try await completeOrder(order)
await loadOrders()
} catch {
print(error) // ❌ No error handling
}
}
}
}
}
}
.searchable(text: $searchText)
.task {
await loadOrders()
}
}
func loadOrders() async {
// ❌ API call in view
// ... 50 more lines
}
func completeOrder(_ order: Order) async throws {
// ❌ API call in view
// ... 30 more lines
}
}Problems:
- 200+ lines in one file
- Formatters created every render (performance)
- Business logic untestable
- No error handling
- Hard to reason about
After: Proper Architecture
// Model — 30 lines
struct Order {
let id: UUID
let customerName: String
let total: Decimal
let date: Date
var isCompleted: Bool
var isHighValue: Bool {
total > 1000
}
}
// ViewModel — 60 lines
@Observable
class OrderListViewModel {
private let orderService: OrderService
private let currencyFormatter = NumberFormatter()
private let dateFormatter = DateFormatter()
var orders: [Order] = []
var searchText = ""
var selectedFilter: FilterType = .all
var error: Error?
var filteredOrders: [Order] {
orders
.filter(matchesSearch)
.filter(matchesFilter)
.sorted(by: sortComparator)
}
init(orderService: OrderService) {
self.orderService = orderService
currencyFormatter.numberStyle = .currency
dateFormatter.dateStyle = .medium
}
func loadOrders() async {
do {
orders = try await orderService.fetchOrders()
} catch {
self.error = error
}
}
func completeOrder(_ order: Order) async {
do {
try await orderService.complete(order.id)
await loadOrders()
} catch {
self.error = error
}
}
func formattedTotal(_ order: Order) -> String {
currencyFormatter.string(from: order.total as NSNumber) ?? "$0.00"
}
func formattedDate(_ order: Order) -> String {
dateFormatter.string(from: order.date)
}
private func matchesSearch(_ order: Order) -> Bool {
searchText.isEmpty || order.customerName.contains(searchText)
}
private func matchesFilter(_ order: Order) -> Bool {
switch selectedFilter {
case .all: true
case .pending: !order.isCompleted
case .completed: order.isCompleted
case .highValue: order.isHighValue
}
}
private func sortComparator(_ lhs: Order, _ rhs: Order) -> Bool {
selectedFilter == .highValue
? lhs.total > rhs.total
: lhs.date > rhs.date
}
}
// View — 40 lines
struct OrderListView: View {
@Bindable var viewModel: OrderListViewModel
var body: some View {
List(viewModel.filteredOrders) { order in
OrderRow(order: order, viewModel: viewModel)
}
.searchable(text: $viewModel.searchText)
.task {
await viewModel.loadOrders()
}
.alert("Error", error: $viewModel.error) { }
}
}
struct OrderRow: View {
let order: Order
let viewModel: OrderListViewModel
var body: some View {
VStack(alignment: .leading) {
Text(order.customerName)
Text(viewModel.formattedTotal(order))
Text(viewModel.formattedDate(order))
if order.isCompleted {
Image(systemName: "checkmark.circle.fill")
} else {
Button("Complete") {
Task {
await viewModel.completeOrder(order)
}
}
}
}
}
}
// Tests — 100 lines
final class OrderViewModelTests: XCTestCase {
func testFilterBySearch() async {
let viewModel = OrderListViewModel(orderService: MockOrderService())
await viewModel.loadOrders()
viewModel.searchText = "John"
XCTAssertEqual(viewModel.filteredOrders.count, 1)
}
func testFilterByHighValue() async {
let viewModel = OrderListViewModel(orderService: MockOrderService())
await viewModel.loadOrders()
viewModel.selectedFilter = .highValue
XCTAssertTrue(viewModel.filteredOrders.allSatisfy { $0.isHighValue })
}
// ... 10 more tests
}Benefits:
- View: 40 lines (was 200)
- ViewModel: Fully testable without SwiftUI
- Model: Pure business logic
- Formatters: Created once, not every render
- Error handling: Proper with alerts
- Tests: 10+ tests covering all logic
---
Resources
WWDC: 2025-266, 2024-10150, 2023-10149, 2023-10160
Docs: /swiftui/managing-model-data-in-your-app
External: github.com/pointfreeco/swift-composable-architecture
---
Platforms: iOS 26+, iPadOS 26+, macOS Tahoe+, watchOS 26+, visionOS 26+ Xcode: 26+ Status: Production-ready (v1.0)
Related skills
How it compares
Use axiom-swiftui as the entry router for SwiftUI symptoms; drill into axiom-performance only after domain-specific SwiftUI fixes fail.
FAQ
What does axiom-swiftui route first for slow lists?
axiom-swiftui tells agents to try SwiftUI-specific fixes in skills/swiftui-performance.md before general profiling. The conflict-resolution rule prioritizes LazyVStack, view identity, and @State optimizations that often resolve UI lag in minutes.
How many sub-skill references does axiom-swiftui index?
axiom-swiftui indexes 18 symptom-to-file routes in its quick-reference table, covering debugging, previews, hot reload, navigation, layout, performance, architecture, animations, gestures, search, toolbars, and iOS 26 features.
Is Axiom Swiftui safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.