
Ios Navigation
- 207 installs
- 191 repo stars
- Updated July 24, 2026
- pproenca/dot-skills
ios-navigation: A skill for development. This provides functionality for development workflows.
Key points
- ios-navigation
Ios Navigation by the numbers
- 207 all-time installs (skills.sh)
- +6 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #1,892 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/pproenca/dot-skills --skill ios-navigationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 207 |
|---|---|
| repo stars | ★ 191 |
| Last updated | July 24, 2026 |
| Repository | pproenca/dot-skills ↗ |
How do I use ios-navigation for development tasks?
Use ios-navigation for development tasks
Who is it for?
Best when you're working on backend & apis and need structured help with ios-navigation.
Skip if: Teams with no backend & apis needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to use ios-navigation for development tasks, or when ios-navigation: a skill for development. this provides functionality for development workflows.
What you get
Structured output aligned to ios-navigation: ios-navigation.
Files
iOS Navigation (Modular MVVM-C)
Opinionated navigation enforcement for SwiftUI apps using the clinic modular architecture. Focus on coordinator + route shell wiring, feature isolation, and resilient push/sheet/deep-link flows.
Non-Negotiable Constraints (iOS 26 / Swift 6.2)
@Equatablemacro on every navigation view,AnyViewnever@Observableeverywhere,ObservableObject/@Publishednever- App-target coordinators own
NavigationPath; route shells own.navigationDestinationmappings - Coordinator-owned modal state, inline
@Statebooleans for sheets never - Domain layer defines coordinator protocols; concrete coordinators stay out of feature modules
Clinic Architecture Contract (iOS 26 / Swift 6.2)
All guidance in this skill assumes the clinic modular MVVM-C architecture:
- Feature modules import
Domain+DesignSystemonly (neverData, never sibling features) - App target is the convergence point and owns
DependencyContainer, concrete coordinators, and Route Shell wiring Domainstays pure Swift and defines models plus repository,*Coordinating,ErrorRouting, andAppErrorcontractsDataowns SwiftData/network/sync/retry/background I/O and implements Domain protocols- Read/write flow defaults to stale-while-revalidate reads and optimistic queued writes
- ViewModels call repository protocols directly (no default use-case/interactor layer)
When to Apply
Reference these guidelines when:
- Designing navigation hierarchies with NavigationStack or NavigationSplitView
- Choosing between push, sheet, and fullScreenCover
- Implementing hero animations, zoom transitions, or gesture-driven dismissals
- Building multi-step flows (onboarding, checkout, registration)
- Using @Observable with @Environment and @Bindable for shared navigation state
- Reviewing code for navigation anti-patterns and modular architecture compliance
- Adding deep linking, state restoration, or tab persistence
- Ensuring VoiceOver and reduce motion support for navigation
Rule Categories by Priority
| Priority | Category | Impact | Prefix |
|---|---|---|---|
| 1 | Navigation Architecture | CRITICAL | arch- |
| 2 | Navigation Anti-Patterns | CRITICAL | anti- |
| 3 | Transition & Animation | HIGH | anim- | | 4 | Modal Presentation | HIGH | modal- | | 5 | Flow Orchestration | HIGH | flow- | | 6 | Navigation Performance | MEDIUM-HIGH | perf- | | 7 | Navigation Accessibility | MEDIUM | ally- | | 8 | State & Restoration | MEDIUM | state- |
Quick Reference
1. Navigation Architecture (CRITICAL)
- `arch-navigation-stack` - Use NavigationStack over deprecated NavigationView
- `arch-value-based-links` - Use value-based NavigationLink over destination closures
- `arch-destination-registration` - Register navigationDestination at stack root
- `arch-destination-item` - Use navigationDestination(item:) for optional-based navigation (iOS 26 / Swift 6.2)
- `arch-route-enum` - Define routes as Hashable enums
- `arch-split-view` - Use NavigationSplitView for multi-column layouts
- `arch-coordinator` - Extract navigation logic into Observable coordinator
- `arch-observable-environment` - Use @Environment with @Observable and @Bindable for shared state
- `arch-deep-linking` - Handle deep links by appending to NavigationPath
- `arch-navigation-path` - Use NavigationPath for heterogeneous type-erased navigation
- `arch-equatable-views` - Apply @Equatable macro to every navigation view
- `arch-observable-only` - Use @Observable only — never ObservableObject or @Published
- `arch-no-anyview` - Never use AnyView in navigation — use @ViewBuilder or generics
- `arch-coordinator-modals` - Present all modals via coordinator — never inline @State
2. Navigation Anti-Patterns (CRITICAL)
- `anti-mixed-link-styles` - Avoid mixing NavigationLink(destination:) with NavigationLink(value:)
- `anti-scattered-destinations` - Avoid scattering navigationDestination across views
- `anti-shared-stack` - Avoid sharing NavigationStack across tabs
- `anti-hidden-back-button` - Avoid hiding back button without preserving swipe gesture
- `anti-navigation-in-init` - Avoid heavy work in view initializers
- `anti-hamburger-menu` - Avoid hamburger menu navigation
- `anti-programmatic-tab-switch` - Avoid programmatic tab selection changes
3. Transition & Animation (HIGH)
- `anim-zoom-transition` - Use zoom navigation transition for hero animations (iOS 18+)
- `anim-matched-geometry-same-view` - Use matchedGeometryEffect only within same view hierarchy
- `anim-spring-config` - Use modern spring animation syntax (iOS 26 / Swift 6.2)
- `anim-gesture-driven` - Use interactive spring animations for gesture-driven transitions
- `anim-transition-source-styling` - Style transition sources with shape and background
- `anim-reduce-motion-transitions` - Respect reduce motion for all navigation animations
- `anim-scroll-driven` - Use onScrollGeometryChange for scroll-driven transitions (iOS 18+)
4. Modal Presentation (HIGH)
- `modal-sheet-vs-push` - Use push for drill-down, sheet for supplementary content
- `modal-detents` - Use presentation detents for contextual sheet sizing
- `modal-fullscreen-cover` - Use fullScreenCover only for immersive standalone experiences
- `modal-sheet-placement` - Place .sheet on container view, not on NavigationLink
- `modal-interactive-dismiss` - Guard unsaved changes with interactiveDismissDisabled
- `modal-nested-navigation` - Use separate NavigationStack inside modals
5. Flow Orchestration (HIGH)
- `flow-tab-independence` - Give each tab its own NavigationStack
- `flow-multi-step` - Use NavigationStack with route array for multi-step flows
- `flow-sidebar-navigation` - Use NavigationSplitView with selection binding for sidebar
- `flow-tab-sidebar-adaptive` - Use sidebarAdaptable TabView for iPad tab-to-sidebar (iOS 18+)
- `flow-pop-to-root` - Implement pop-to-root by clearing NavigationPath
- `flow-screen-independence` - Keep screens independent of parent navigation context
6. Navigation Performance (MEDIUM-HIGH)
- `perf-lazy-destinations` - Use value-based NavigationLink for lazy destination construction
- `perf-task-modifier` - Use .task for async data loading on navigation
- `perf-state-object-ownership` - Own @Observable state with @State, pass as plain property
- `perf-avoid-body-side-effects` - Avoid side effects in view body
7. Navigation Accessibility (MEDIUM)
- `ally-rotor-headers` - Mark navigation section headers for VoiceOver rotor
- `ally-focus-after-navigation` - Manage focus after programmatic navigation events
- `ally-group-navigation-elements` - Group related navigation elements to reduce swipe count
- `ally-hide-decorative-navigation` - Hide decorative navigation elements from VoiceOver
- `ally-keyboard-focus` - Use @FocusState for keyboard navigation in forms
8. State & Restoration (MEDIUM)
- `state-codable-routes` - Make route enums Codable for navigation persistence
- `state-scene-storage` - Use SceneStorage for per-scene navigation persistence
- `state-tab-persistence` - Persist selected tab with SceneStorage
- `state-deep-link-urls` - Parse deep link URLs into route enums
- `state-avoid-app-level-path` - Avoid defining NavigationPath at App level
How to Use
Read individual reference files for detailed explanations and code examples:
- Section definitions - Category structure and impact levels
- Rule template - Template for adding new rules
Reference Files
| File | Description |
|---|---|
| references/_sections.md | Category definitions and ordering |
| assets/templates/_template.md | Template for new rules |
| metadata.json | Version and reference information |
{Rule Title}
{1-3 sentences explaining WHY this matters. Focus on navigation/UX implications.}
Incorrect ({what's wrong}):
{Bad code example - production-realistic, not strawman}
{// Comments explaining the cost}Correct ({what's right}):
{Good code example - minimal diff from incorrect}
{// Comments explaining the benefit}{
"version": "1.0.8",
"organization": "Airbnb Engineering",
"technology": "iOS Navigation (SwiftUI, iOS 26 / Swift 6.2)",
"date": "February 2026",
"abstract": "Opinionated navigation architecture and transition guide for SwiftUI iOS 26 / Swift 6.2 apps, aligned with modular MVVM-C. Contains 54 rules across 8 categories enforcing @Equatable views, @Observable state, App-target coordinator + route-shell navigation, coordinator-owned modal state, zoom transitions, and zero legacy APIs. Each rule includes incorrect vs. correct examples with quantified impact metrics. Aligned with the iOS 26 / Swift 6.2 clinic modular MVVM-C architecture.",
"references": [
"https://airbnb.tech/uncategorized/understanding-and-improving-swiftui-performance/",
"https://developer.apple.com/videos/play/wwdc2022/10054/",
"https://developer.apple.com/videos/play/wwdc2024/10145/",
"https://developer.apple.com/design/human-interface-guidelines/navigation-and-search",
"https://developer.apple.com/documentation/swiftui/navigationstack",
"https://developer.apple.com/documentation/swiftui/navigationsplitview",
"https://developer.apple.com/documentation/swiftui/navigationpath",
"https://www.kodeco.com/books/advanced-ios-app-architecture",
"https://frankrausch.com/ios-navigation/",
"https://swiftwithmajid.com/2022/06/21/mastering-navigationstack-in-swiftui-deep-linking/",
"https://peterfriese.dev/blog/2024/hero-animation/"
]
}
Sections
This file defines all sections, their ordering, impact levels, and descriptions. The section ID (in parentheses) is the filename prefix used to group rules.
---
1. Navigation Architecture (arch)
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Impact: CRITICAL Description: NavigationStack, NavigationSplitView, value-based links, destination registration, and coordinator patterns are the foundation of every navigation flow. Wrong architecture makes fluid transitions, deep linking, and state restoration impossible.
2. Navigation Anti-Patterns (anti)
Impact: CRITICAL Description: Deprecated APIs, mixed navigation paradigms, scattered destination registrations, and shared navigation stacks cause crashes, state loss, and undefined behavior that cascade through the entire app.
3. Transition & Animation (anim)
Impact: HIGH Description: Zoom transitions, hero animations, spring configurations, gesture-driven dismissals, and matched geometry effects are what separate fluid, golden-standard navigation from jarring screen swaps.
4. Modal Presentation (modal)
Impact: HIGH Description: Choosing sheet vs fullScreenCover vs push, configuring detents, managing nested navigation in modals, and handling interactive dismissal determines whether supplementary content feels native or fights the user.
5. Flow Orchestration (flow)
Impact: HIGH Description: Tab independence, multi-step wizards, onboarding sequences, sidebar navigation, and programmatic route manipulation define how users move through complex journeys without losing context.
6. Navigation Performance (perf)
Impact: MEDIUM-HIGH Description: Lazy destination construction, avoiding view reconstruction on navigation, prefetching with .task, and correct state object ownership prevent hitches and memory spikes during screen transitions.
7. Navigation Accessibility (ally)
Impact: MEDIUM Description: VoiceOver rotor navigation, programmatic focus management, reduce motion support for transitions, and element grouping ensure all users can navigate your app fluidly.
8. State & Restoration (state)
Impact: MEDIUM Description: NavigationPath persistence with SceneStorage, Codable route enums, deep link URL handling, and tab selection persistence ensure users never lose their place across app launches and scene changes.
Manage Focus After Programmatic Navigation Events
When a modal is dismissed, a flow completes, or an error appears, VoiceOver focus may land on an unpredictable element — often the first element on screen or the navigation bar. This disorients users who cannot see the screen. Use @AccessibilityFocusState to explicitly direct VoiceOver focus to the most relevant element after programmatic navigation changes, such as a success message, error field, or the triggering button.
Incorrect (no focus management after sheet dismissal):
struct ProfileView: View {
@State private var showEditSheet = false
@State private var saveConfirmation = ""
var body: some View {
NavigationStack {
VStack(spacing: 16) {
Text("Profile").font(.largeTitle)
// BAD: VoiceOver focus lands on nav bar or first element
// Confirmation message is never announced
if !saveConfirmation.isEmpty {
Text(saveConfirmation).foregroundColor(.green)
}
Button("Edit Profile") { showEditSheet = true }
}
.sheet(isPresented: $showEditSheet) {
EditProfileSheet { result in
saveConfirmation = "Profile saved successfully"
showEditSheet = false
// BAD: VoiceOver focus is lost
}
}
}
}
}Correct (focus directed to confirmation after dismissal):
@Equatable
struct ProfileView: View {
@State private var showEditSheet = false
@State private var saveConfirmation = ""
@AccessibilityFocusState private var isConfirmationFocused: Bool
var body: some View {
NavigationStack {
VStack(spacing: 16) {
Text("Profile").font(.largeTitle)
if !saveConfirmation.isEmpty {
Text(saveConfirmation)
.foregroundColor(.green)
.accessibilityFocused($isConfirmationFocused)
}
Button("Edit Profile") { showEditSheet = true }
}
.sheet(isPresented: $showEditSheet) {
EditProfileSheet { result in
saveConfirmation = "Profile saved successfully"
showEditSheet = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isConfirmationFocused = true
}
}
}
}
}
}
// Enum-based focus for forms with multiple error targets:
@Equatable
struct SignUpView: View {
enum FocusTarget: Hashable { case emailError, passwordError }
@AccessibilityFocusState private var focusTarget: FocusTarget?
var body: some View {
Form {
TextField("Email", text: $email)
if let emailError {
Text(emailError)
.foregroundColor(.red)
.accessibilityFocused($focusTarget, equals: .emailError)
}
// focusTarget = .emailError
}
}
}Group Related Navigation Elements to Reduce Swipe Count
List rows with multiple text, image, and icon elements require one VoiceOver swipe per element. A row with an avatar, title, subtitle, timestamp, and chevron costs 5 swipes just to traverse one row — in a 20-row list, that is 100 swipes to scan the screen. Combining related elements into a single accessibility element with a descriptive label reduces this to 1 swipe per row (20 total), conveying the same information 5x faster.
Incorrect (each sub-element is a separate VoiceOver stop):
struct ConversationListView: View {
let conversations: [Conversation]
var body: some View {
NavigationStack {
List(conversations) { conversation in
NavigationLink(value: conversation) {
// BAD: 5 swipes per row (avatar, name, message, time, badge)
// 20 conversations = 100 swipes to scan the list
HStack(spacing: 12) {
AsyncImage(url: conversation.avatarURL)
.frame(width: 44, height: 44)
.clipShape(Circle())
VStack(alignment: .leading) {
Text(conversation.senderName).font(.headline)
Text(conversation.lastMessage)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(1)
}
Spacer()
VStack(alignment: .trailing) {
Text(conversation.timeAgo).font(.caption)
if conversation.isUnread {
Circle().fill(.blue).frame(width: 10, height: 10)
}
}
}
}
}
}
}
}Correct (elements combined into a single VoiceOver stop):
@Equatable
struct ConversationListView: View {
let conversations: [Conversation]
var body: some View {
NavigationStack {
List(conversations) { conversation in
NavigationLink(value: conversation) {
HStack(spacing: 12) {
AsyncImage(url: conversation.avatarURL)
.frame(width: 44, height: 44)
.clipShape(Circle())
VStack(alignment: .leading) {
Text(conversation.senderName).font(.headline)
Text(conversation.lastMessage)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(1)
}
Spacer()
VStack(alignment: .trailing) {
Text(conversation.timeAgo).font(.caption)
if conversation.isUnread {
Circle().fill(.blue).frame(width: 10, height: 10)
}
}
}
.accessibilityElement(children: .combine)
.accessibilityLabel(conversationAccessibilityLabel(conversation))
}
}
.navigationDestination(for: Conversation.self) { conversation in
ConversationDetailView(conversation: conversation)
}
}
}
private func conversationAccessibilityLabel(_ conversation: Conversation) -> String {
var label = "\(conversation.senderName), \(conversation.lastMessage), \(conversation.timeAgo)"
if conversation.isUnread { label += ", unread" }
return label
}
}Hide Decorative Navigation Elements from VoiceOver
Decorative elements — chevron indicators, divider lines, background gradients, placeholder images, and ornamental icons — add visual structure for sighted users but create noise for VoiceOver. Each decorative element is an extra swipe stop that conveys no information. VoiceOver reads Image(systemName: "chevron.right") as "chevron.right" which is confusing and meaningless. Hide these elements so VoiceOver focuses exclusively on actionable, informational content.
Incorrect (decorative elements announced by VoiceOver):
struct MenuItemView: View {
let item: MenuItem
var body: some View {
NavigationLink(value: item.route) {
HStack {
// BAD: VoiceOver reads "star.fill, Image" — extra swipe stop
Image(systemName: item.icon)
.foregroundColor(.accentColor)
.frame(width: 28)
Text(item.title)
Spacer()
// BAD: VoiceOver reads "chevron.right, Image" — redundant
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
}
struct SectionDividerView: View {
var body: some View {
// BAD: VoiceOver stops and reads nothing useful
HStack {
Rectangle().frame(height: 1).foregroundColor(.secondary.opacity(0.3))
Image(systemName: "circle.fill").font(.system(size: 4))
Rectangle().frame(height: 1).foregroundColor(.secondary.opacity(0.3))
}
.padding(.vertical, 8)
}
}Correct (decorative elements hidden from VoiceOver):
@Equatable
struct MenuItemView: View {
let item: MenuItem
var body: some View {
NavigationLink(value: item.route) {
HStack {
Image(systemName: item.icon)
.foregroundColor(.accentColor)
.frame(width: 28)
.accessibilityHidden(true)
Text(item.title)
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundColor(.secondary)
.accessibilityHidden(true)
}
}
// VoiceOver: "Favorites, Button" — one swipe, all info
}
}
@Equatable
struct SectionDividerView: View {
var body: some View {
HStack {
Rectangle().frame(height: 1).foregroundColor(.secondary.opacity(0.3))
Image(systemName: "circle.fill").font(.system(size: 4))
Rectangle().frame(height: 1).foregroundColor(.secondary.opacity(0.3))
}
.padding(.vertical, 8)
.accessibilityHidden(true)
}
}
// Exception: meaningful icons need labels, not hiding
@Equatable
struct StatusBadge: View {
let isOnline: Bool
var body: some View {
Circle()
.fill(isOnline ? .green : .gray)
.frame(width: 10, height: 10)
.accessibilityLabel(isOnline ? "Online" : "Offline")
}
}Use @FocusState for Keyboard Navigation in Forms
Forms presented via sheet or push navigation need @FocusState to control which field is active. Without it, the keyboard toolbar shows no Next/Previous buttons, users cannot tab between fields with an external keyboard, and there is no way to programmatically move focus after validation errors. This is critical for iPad users with external keyboards and anyone relying on assistive switch controls.
Incorrect (no focus management in navigation form):
struct CreateAccountView: View {
@State private var name = ""
@State private var email = ""
@State private var password = ""
@State private var errorMessage = ""
var body: some View {
NavigationStack {
Form {
Section("Personal Info") {
// BAD: No keyboard toolbar Next/Previous arrows
// External keyboard Tab does nothing
TextField("Full Name", text: $name)
TextField("Email", text: $email)
.textContentType(.emailAddress)
.keyboardType(.emailAddress)
}
Section("Security") {
SecureField("Password", text: $password)
}
if !errorMessage.isEmpty {
Section {
Text(errorMessage).foregroundColor(.red)
}
}
Button("Create Account") { validate() }
}
.navigationTitle("Sign Up")
}
}
private func validate() {
if email.isEmpty {
errorMessage = "Email is required"
// BAD: No way to move focus to the email field
}
}
}Correct (@FocusState enables keyboard navigation and error focus):
@Equatable
struct CreateAccountView: View {
enum Field: Hashable { case name, email, password }
@State private var name = ""
@State private var email = ""
@State private var password = ""
@State private var errorMessage = ""
@FocusState private var focusedField: Field?
var body: some View {
NavigationStack {
Form {
Section("Personal Info") {
TextField("Full Name", text: $name)
.focused($focusedField, equals: .name)
.submitLabel(.next)
.onSubmit { focusedField = .email }
TextField("Email", text: $email)
.textContentType(.emailAddress)
.focused($focusedField, equals: .email)
.submitLabel(.next)
.onSubmit { focusedField = .password }
}
Section("Security") {
SecureField("Password", text: $password)
.focused($focusedField, equals: .password)
.submitLabel(.done)
.onSubmit { validate() }
}
if !errorMessage.isEmpty {
Section { Text(errorMessage).foregroundColor(.red) }
}
Button("Create Account") { validate() }
}
.navigationTitle("Sign Up")
.onAppear { focusedField = .name }
}
}
private func validate() {
if name.isEmpty { errorMessage = "Name is required"; focusedField = .name }
else if email.isEmpty || !email.contains("@") { errorMessage = "Valid email is required"; focusedField = .email }
else if password.count < 8 { errorMessage = "Password >= 8 chars"; focusedField = .password }
else { errorMessage = ""; focusedField = nil; submitAccount() }
}
}Mark Navigation Section Headers for VoiceOver Rotor
VoiceOver's rotor provides a "Headings" mode that lets users jump directly between sections with a single flick. Without .isHeader traits, users must swipe through every element sequentially — in a settings screen with 40 items across 6 sections, that is up to 40 swipes to reach the last section versus 6 rotor flicks. This is the difference between usable and unusable for VoiceOver users.
Incorrect (visual-only headers with no accessibility trait):
struct SettingsView: View {
var body: some View {
NavigationStack {
List {
// BAD: Section headers look like headers visually
// but VoiceOver treats them as plain text.
// Rotor "Headings" mode finds nothing — user must
// swipe through all 40+ items linearly.
Section {
NavigationLink("Profile", value: Route.profile)
NavigationLink("Email", value: Route.email)
NavigationLink("Password", value: Route.password)
} header: {
// .font(.headline) is visual only — VoiceOver
// does not infer semantic role from font size.
Text("Account").font(.headline)
}
Section {
NavigationLink("Notifications", value: Route.notifications)
NavigationLink("Privacy", value: Route.privacy)
NavigationLink("Data Usage", value: Route.dataUsage)
} header: {
Text("Preferences").font(.headline)
}
// ... 4 more sections, 30+ more items
}
}
}
}Correct (headers marked with .isHeader trait for rotor navigation):
@Equatable
struct SettingsView: View {
var body: some View {
NavigationStack {
List {
// VoiceOver rotor "Headings" mode now finds these sections.
// User can flick up/down to jump: Account -> Preferences -> ...
// Total flicks to reach last section: 5 (not 40).
Section {
NavigationLink("Profile", value: Route.profile)
NavigationLink("Email", value: Route.email)
NavigationLink("Password", value: Route.password)
} header: {
Text("Account")
.font(.headline)
// Registers this element in the VoiceOver rotor
// under "Headings". Works with built-in List
// Section headers and custom header views.
.accessibilityAddTraits(.isHeader)
}
Section {
NavigationLink("Notifications", value: Route.notifications)
NavigationLink("Privacy", value: Route.privacy)
NavigationLink("Data Usage", value: Route.dataUsage)
} header: {
Text("Preferences")
.font(.headline)
.accessibilityAddTraits(.isHeader)
}
// Apply to ALL section headers consistently.
// Partial adoption is worse than none — users expect
// rotor to find ALL sections once they see one.
}
.navigationTitle("Settings")
.navigationDestination(for: Route.self) { route in
route.destinationView
}
}
}
}Use Interactive Spring Animations for Gesture-Driven Transitions
During active gestures, use .interactiveSpring to retarget animations smoothly as the user's finger moves. On gesture end, switch to a standard .spring that inherits the gesture's velocity for natural deceleration. Using linear or ease-based timing curves during gestures causes visible stuttering because they cannot retarget mid-flight, and snapping on release feels mechanical because velocity is discarded.
Incorrect (linear animation during gesture, snap on release):
// BAD: .linear cannot retarget mid-flight -- each new drag value
// queues a new animation, causing stutter at 30fps or worse.
// On release the card snaps without velocity, feeling mechanical.
struct DismissableCardView: View {
@State private var offset: CGFloat = 0
@State private var isDismissed = false
var body: some View {
CardContent()
.offset(y: offset)
.gesture(
DragGesture()
.onChanged { value in
withAnimation(.linear(duration: 0.1)) {
offset = value.translation.height
}
}
.onEnded { value in
withAnimation(.easeInOut(duration: 0.3)) {
if offset > 200 {
offset = 800 // WRONG: ignores release velocity
isDismissed = true
} else {
offset = 0 // WRONG: snaps back without momentum
}
}
}
)
}
}Correct (interactive spring during gesture, velocity-aware spring on release):
@Equatable
struct DismissableCardView: View {
@State private var offset: CGFloat = 0
@State private var isDismissed = false
@GestureState private var isDragging = false
var body: some View {
CardContent()
.offset(y: offset)
.opacity(opacity(for: offset))
.gesture(
DragGesture()
.updating($isDragging) { _, state, _ in
state = true
}
.onChanged { value in
// interactiveSpring retargets smoothly each frame,
// keeping the card glued to the finger at 60fps
withAnimation(.interactiveSpring) {
offset = value.translation.height
}
}
.onEnded { value in
let velocity = value.predictedEndTranslation.height
let shouldDismiss = offset > 200
|| velocity > 500
// Standard spring inherits gesture velocity
// for natural deceleration
withAnimation(
.spring(duration: 0.5, bounce: 0)
) {
if shouldDismiss {
offset = 1000 // Large value to animate offscreen
isDismissed = true
} else {
offset = 0
}
}
}
)
}
private func opacity(for offset: CGFloat) -> Double {
let progress = min(max(offset / 400, 0), 1)
return 1 - Double(progress) * 0.5
}
}Use matchedGeometryEffect Only Within Same View Hierarchy
matchedGeometryEffect animates geometry changes between two states within a single view hierarchy -- it does NOT work across NavigationStack pushes because the source view is removed from the tree before the destination appears. This is one of the most common SwiftUI animation mistakes. Use .navigationTransition(.zoom) for hero animations across navigation, and reserve matchedGeometryEffect for in-place expand/collapse transitions.
Incorrect (matchedGeometryEffect across NavigationLink push):
// BAD: Namespace cannot bridge across NavigationStack push
// Source cell deallocated before detail appears, animation fails
struct PhotoGalleryView: View {
@Namespace private var animation
let photos: [Photo]
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
ForEach(photos) { photo in
NavigationLink(value: photo) {
AsyncImage(url: photo.thumbnailURL)
.matchedGeometryEffect(id: photo.id, in: animation)
}
}
}
}
.navigationDestination(for: Photo.self) { photo in
AsyncImage(url: photo.fullURL)
.matchedGeometryEffect(id: photo.id, in: animation, isSource: false)
// WRONG: source gone, animation fails
}
}
}
}Correct (matchedGeometryEffect for in-place expansion, zoom for navigation):
@Equatable
struct PhotoGalleryView: View { // In-place expand/collapse (no navigation push)
@Namespace private var animation
@State private var expandedPhoto: Photo?
var body: some View {
ZStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
ForEach(photos) { photo in
if expandedPhoto != photo {
AsyncImage(url: photo.thumbnailURL)
.matchedGeometryEffect(id: photo.id, in: animation)
.onTapGesture {
withAnimation(.spring(duration: 0.4, bounce: 0)) { expandedPhoto = photo }
}
}
}
}
}
if let photo = expandedPhoto {
PhotoDetailOverlay(photo: photo)
.matchedGeometryEffect(id: photo.id, in: animation)
.onTapGesture {
withAnimation(.spring(duration: 0.4, bounce: 0)) { expandedPhoto = nil }
}
}
}
}
}
// OPTION B: Navigation push with zoom transition (iOS 18+)
@Equatable
struct PhotoGalleryNavigationView: View {
@Namespace private var zoomNamespace
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 100))]) {
ForEach(photos) { photo in
NavigationLink(value: photo) {
AsyncImage(url: photo.thumbnailURL)
}
.matchedTransitionSource(id: photo.id, in: zoomNamespace)
}
}
}
.navigationDestination(for: Photo.self) { photo in
PhotoDetailView(photo: photo)
.navigationTransition(.zoom(sourceID: photo.id, in: zoomNamespace))
}
}
}
}Respect Reduce Motion for All Navigation Animations
When accessibilityReduceMotion is enabled, spring and movement-based animations should be replaced with instant transitions or simple crossfades. System navigation transitions (push, pop, tab switch) already handle this automatically, but any custom animation -- gesture-driven dismissals, hero transitions, parallax effects -- must check this preference explicitly. Ignoring it can cause discomfort for users with vestibular disorders, and violates WCAG 2.3.3 (Animation from Interactions).
Incorrect (spring animation regardless of accessibility settings):
// BAD: Users with vestibular disorders experience discomfort from
// bouncy spring animations. 10-15% of iOS users enable Reduce Motion.
// This code forces the same animation on everyone.
struct CustomModalView: View {
@Binding var isPresented: Bool
@State private var offset: CGFloat = 0
var body: some View {
if isPresented {
ModalContent()
.offset(y: offset)
.transition(.move(edge: .bottom))
.onAppear {
withAnimation(.spring(duration: 0.6, bounce: 0.3)) {
offset = 0
}
}
.gesture(
DragGesture()
.onEnded { value in
// Always animates with spring, even with
// Reduce Motion enabled
withAnimation(.spring(duration: 0.5, bounce: 0.2)) {
if value.translation.height > 150 {
isPresented = false
} else {
offset = 0
}
}
}
)
}
}
}Correct (conditional animation respecting reduce motion):
@Equatable
struct CustomModalView: View {
@Binding var isPresented: Bool
@State private var offset: CGFloat = 0
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
if isPresented {
ModalContent()
.offset(y: reduceMotion ? 0 : offset)
.transition(reduceMotion ? .opacity : .move(edge: .bottom).combined(with: .opacity))
.onAppear {
withAnimation(presentationAnimation) {
offset = 0
}
}
.gesture(
DragGesture()
.onEnded { value in
withAnimation(dismissAnimation) {
if value.translation.height > 150 {
isPresented = false
} else {
offset = 0
}
}
}
)
}
}
private var presentationAnimation: Animation {
reduceMotion
? .linear(duration: 0.15)
: .spring(duration: 0.6, bounce: 0.3)
}
private var dismissAnimation: Animation {
reduceMotion
? .linear(duration: 0.15)
: .spring(duration: 0.5, bounce: 0)
}
}Use onScrollGeometryChange for Scroll-Driven Transitions (iOS 18+)
The .onScrollGeometryChange modifier provides scroll offset and content geometry directly from the scroll view without GeometryReader workarounds. Using GeometryReader inside a ScrollView causes layout feedback loops, unexpected frame changes, and dropped frames because the reader's size depends on the scroll position which in turn depends on content size. The dedicated scroll geometry API avoids these issues entirely and runs at 60fps.
Incorrect (GeometryReader inside ScrollView causes layout cycles):
// BAD: GeometryReader inside ScrollView creates a layout feedback loop.
// The reader's frame changes with scroll position, which triggers
// re-layout, which changes the frame again. This causes stuttering
// and can drop to 30fps or below on complex views.
struct CollapsibleHeaderView: View {
@State private var headerHeight: CGFloat = 250
@State private var scrollOffset: CGFloat = 0
var body: some View {
ScrollView {
GeometryReader { proxy in
// WRONG: reading minY here causes layout cycles
Color.clear
.preference(
key: ScrollOffsetKey.self,
value: proxy.frame(in: .global).minY
)
}
.frame(height: 0)
VStack(spacing: 0) {
HeaderImage()
.frame(height: max(headerHeight + scrollOffset, 80))
.clipped()
ContentList()
}
}
.onPreferenceChange(ScrollOffsetKey.self) { value in
scrollOffset = value // Triggers re-layout every frame
}
}
}Correct (onScrollGeometryChange for scroll-linked effects):
@Equatable
struct CollapsibleHeaderView: View {
@State private var scrollOffset: CGFloat = 0
private let expandedHeight: CGFloat = 250
private let collapsedHeight: CGFloat = 80
var body: some View {
ScrollView {
VStack(spacing: 0) {
HeaderImage()
.frame(height: currentHeaderHeight)
.clipped()
.opacity(headerOpacity)
ContentList()
}
}
.onScrollGeometryChange(for: CGFloat.self) { geometry in
geometry.contentOffset.y
} action: { _, newOffset in
scrollOffset = newOffset
}
}
private var currentHeaderHeight: CGFloat {
let height = expandedHeight - scrollOffset
return max(height, collapsedHeight)
}
private var headerOpacity: Double {
let progress = scrollOffset / (expandedHeight - collapsedHeight)
return max(1 - Double(progress) * 1.5, 0)
}
}Use Modern Spring Animation Syntax (iOS 17+)
iOS 17 introduced a simplified spring API that uses duration and bounce instead of physics parameters like response, dampingFraction, and blendDuration. The new syntax expresses design intent directly -- how long the animation should feel and how bouncy it should be. SwiftUI's built-in navigation transitions already use non-bouncy springs, so matching this convention for custom animations creates a cohesive feel throughout the app.
Incorrect (legacy spring parameters):
// BAD: response/dampingFraction/blendDuration are physics terms that
// don't communicate design intent. Designers can't reason about what
// dampingFraction: 0.7 looks like, and blendDuration is almost always 0.
struct ExpandableCard: View {
@State private var isExpanded = false
var body: some View {
VStack {
CardHeader()
.onTapGesture {
withAnimation(
.spring(
response: 0.5,
dampingFraction: 0.7,
blendDuration: 0
)
) {
isExpanded.toggle()
}
}
if isExpanded {
CardContent()
.transition(
.move(edge: .top)
.combined(with: .opacity)
)
}
}
}
}Correct (modern duration/bounce spring syntax):
@Equatable
struct ExpandableCard: View {
@State private var isExpanded = false
var body: some View {
VStack {
CardHeader()
.onTapGesture {
// duration: perceived settling time
// bounce: 0 = no overshoot (navigation-like),
// 0.3 = subtle bounce, negative = overdamped
withAnimation(.spring(duration: 0.5, bounce: 0.3)) {
isExpanded.toggle()
}
}
if isExpanded {
CardContent()
.transition(
.move(edge: .top)
.combined(with: .opacity)
)
}
}
// For navigation-matching animations, use zero bounce:
// .spring(duration: 0.4, bounce: 0)
//
// For playful interactions:
// .spring(duration: 0.5, bounce: 0.3)
//
// Presets also available:
// .smooth -> duration: 0.5, bounce: 0
// .snappy -> duration: 0.5, bounce: 0.15
// .bouncy -> duration: 0.5, bounce: 0.3
}
}Style Transition Sources with Shape and Background
The .matchedTransitionSource modifier accepts a configuration closure that controls how the source cell appears during the zoom animation. This is the correct place to apply clipShape, background, and shadow for the transition -- applying these directly to the NavigationLink or its content has no effect on the zoom animation, resulting in a raw rectangular morph that looks unfinished.
Incorrect (styling on NavigationLink, ignored by zoom transition):
// BAD: cornerRadius and shadow applied to the link content are not
// picked up by the zoom transition engine. The animation uses a
// plain rectangular clip, producing an unpolished morph effect.
struct PlaceListView: View {
@Namespace private var zoomNamespace
var body: some View {
NavigationStack {
List(places) { place in
NavigationLink(value: place) {
PlaceRow(place: place)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(radius: 8)
}
.matchedTransitionSource(
id: place.id,
in: zoomNamespace
)
// cornerRadius and shadow above are NOT used by the
// zoom transition -- the animation clips to a rectangle
}
.navigationDestination(for: Place.self) { place in
PlaceDetailView(place: place)
.navigationTransition(
.zoom(sourceID: place.id, in: zoomNamespace)
)
}
}
}
}Correct (styling via matchedTransitionSource configuration closure):
@Equatable
struct PlaceListView: View {
@Namespace private var zoomNamespace
var body: some View {
NavigationStack {
List(places) { place in
NavigationLink(value: place) {
PlaceRow(place: place)
}
.matchedTransitionSource(
id: place.id,
in: zoomNamespace
) { source in
source
.background(.fill.tertiary)
.clipShape(RoundedRectangle(cornerRadius: 16))
.shadow(radius: 8, y: 4)
}
}
.navigationDestination(for: Place.self) { place in
PlaceDetailView(place: place)
.navigationTransition(
.zoom(sourceID: place.id, in: zoomNamespace)
)
}
}
}
}Use Zoom Navigation Transition for Hero Animations (iOS 18+)
The .navigationTransition(.zoom) API provides first-class hero animations for navigation pushes. It automatically handles matching source and destination geometry, interactive back gestures, and accessibility reduce-motion fallbacks. Building equivalent behavior manually requires hundreds of lines of fragile transition code that breaks across OS updates.
Incorrect (manual matchedGeometryEffect for navigation hero):
// BAD: matchedGeometryEffect does NOT work across NavigationStack pushes.
// This produces broken, jumpy animations and ignores interactive pop gestures.
struct RecipeListView: View {
@Namespace private var heroNamespace
@State private var selectedRecipe: Recipe?
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(recipes) { recipe in
NavigationLink(value: recipe) {
RecipeCard(recipe: recipe)
.matchedGeometryEffect(
id: recipe.id,
in: heroNamespace
) // WRONG: this namespace is lost on push
}
}
}
}
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(recipe: recipe)
.matchedGeometryEffect(
id: recipe.id,
in: heroNamespace,
isSource: false
) // WRONG: animation won't connect across navigation push
}
}
}
}Correct (zoom navigation transition with matched source):
@Equatable
struct RecipeListView: View {
@Namespace private var zoomNamespace
var body: some View {
NavigationStack {
ScrollView {
LazyVGrid(columns: columns) {
ForEach(recipes) { recipe in
NavigationLink(value: recipe) {
RecipeCard(recipe: recipe)
}
.matchedTransitionSource(
id: recipe.id,
in: zoomNamespace
)
}
}
}
.navigationDestination(for: Recipe.self) { recipe in
RecipeDetailView(recipe: recipe)
.navigationTransition(
.zoom(
sourceID: recipe.id,
in: zoomNamespace
)
)
}
}
}
}Avoid Hamburger Menu Navigation
Hamburger menus hide primary navigation behind a tap, reducing feature discoverability by approximately 50% compared to visible tab bars (Nielsen Norman Group research). Apple's Human Interface Guidelines recommend TabView for top-level destinations. The mobile industry largely abandoned hamburger menus after 2015 when A/B tests at Facebook, Spotify, and others consistently showed that visible navigation increased engagement. On iPad, use NavigationSplitView with a persistent sidebar instead.
Incorrect (custom hamburger/drawer menu for primary navigation):
// BAD: Primary sections hidden behind a hamburger icon.
// Users must tap the icon, scan the list, then tap again.
// Nielsen Norman research shows ~50% lower task completion
// for hidden navigation vs. visible tab bars.
struct MainView: View {
@State private var isDrawerOpen = false
@State private var selectedSection: AppSection = .home
var body: some View {
ZStack {
// Content area
NavigationStack {
selectedSection.rootView
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button {
withAnimation { isDrawerOpen.toggle() }
} label: {
Image(systemName: "line.horizontal.3") // Hamburger icon
}
}
}
}
// Side drawer overlay
if isDrawerOpen {
Color.black.opacity(0.3)
.ignoresSafeArea()
.onTapGesture { isDrawerOpen = false }
HStack {
VStack(alignment: .leading, spacing: 0) {
ForEach(AppSection.allCases) { section in
Button(section.title) {
selectedSection = section
isDrawerOpen = false
}
.padding()
}
Spacer()
}
.frame(width: 280)
.background(.ultraThickMaterial)
Spacer()
}
.transition(.move(edge: .leading))
}
}
}
}Correct (TabView for primary navigation, sidebar for iPad):
// GOOD: All primary sections visible at all times via TabView.
// Users can see every section and switch with a single tap.
// On iPad, NavigationSplitView provides a persistent sidebar
// that matches platform conventions.
@Equatable
struct MainView: View {
@State private var selectedTab: AppSection = .home
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
if sizeClass == .compact {
// iPhone: tab bar with 3-5 visible sections
TabView(selection: $selectedTab) {
NavigationStack {
HomeView()
}
.tabItem { Label("Home", systemImage: "house") }
.tag(AppSection.home)
NavigationStack {
SearchView()
}
.tabItem { Label("Search", systemImage: "magnifyingglass") }
.tag(AppSection.search)
NavigationStack {
FavoritesView()
}
.tabItem { Label("Favorites", systemImage: "heart") }
.tag(AppSection.favorites)
NavigationStack {
ProfileView()
}
.tabItem { Label("Profile", systemImage: "person") }
.tag(AppSection.profile)
}
} else {
// iPad: persistent sidebar with NavigationSplitView
NavigationSplitView {
List(AppSection.allCases, selection: $selectedTab) { section in
Label(section.title, systemImage: section.icon)
}
} detail: {
selectedTab.rootView
}
}
}
}Avoid Hiding Back Button Without Preserving Swipe Gesture
Applying .navigationBarBackButtonHidden(true) disables both the visible back button and the interactive swipe-back gesture. Most iOS users rely on the edge-swipe to go back — it is deeply ingrained muscle memory. Removing it without a replacement makes the app feel broken and increases the cognitive load of every navigation action. Prefer keeping the system back button and adding supplementary toolbar items alongside it.
Incorrect (back button hidden with no swipe-back alternative):
// BAD: Hides the back button AND kills the edge-swipe gesture.
// Users are trapped unless they find the custom button.
struct OrderDetailView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
Text("Order #12345")
Button("Go Back") { dismiss() } // Only way out
}
.navigationBarBackButtonHidden(true)
}
}Correct (keep system back button, add supplementary toolbar items):
// GOOD: System back button remains — swipe-back works automatically.
// Custom toolbar items add functionality alongside the back button,
// not as a replacement for it.
@Equatable
struct OrderDetailView: View {
var body: some View {
ScrollView {
Text("Order #12345")
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
ShareLink(item: orderURL)
}
}
}
}Last resort (custom back button with UIKit gesture re-enablement):
If you absolutely must hide the system back button (e.g., for a branded navigation bar), re-enable the swipe-back gesture through UIKit interop. Warning: Setting delegate = nil on interactivePopGestureRecognizer can cause a frozen state if the user swipes back from the root view. This approach relies on UIKit internals and may break across iOS versions. Test thoroughly.
@Equatable
struct BrandedDetailView: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack { /* ... */ }
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button { dismiss() } label: {
HStack(spacing: 4) {
Image(systemName: "chevron.left")
Text("Orders")
}
}
}
}
.background(SwipeBackEnabler())
}
}
// WARNING: UIKit interop hack — fragile across iOS versions.
struct SwipeBackEnabler: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
let controller = UIViewController()
DispatchQueue.main.async {
controller.navigationController?
.interactivePopGestureRecognizer?.isEnabled = true
}
return controller
}
func updateUIViewController(_ vc: UIViewController, context: Context) {}
}Avoid Mixing NavigationLink(destination:) with NavigationLink(value:)
Mixing the legacy NavigationLink(destination:) API with the modern NavigationLink(value:) API inside the same NavigationStack corrupts the stack's internal data model. The destination-based variant manages its own push/pop state outside the NavigationPath, so calling path.removeLast() may pop two screens instead of one, or leave ghost entries in the path. This is the single most common cause of "blank screen" bugs during incremental migration to NavigationStack.
Incorrect (mixed link styles in the same stack):
// BAD: Two link styles fighting over the same stack.
// destination-based links bypass NavigationPath entirely,
// causing removeLast() to double-pop or crash.
struct CatalogView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List {
// Legacy style — pushes outside the path
NavigationLink(destination: CategoryDetailView(id: "shoes")) {
Text("Shoes")
}
// Modern style — pushes via the path
NavigationLink(value: Category(id: "hats")) {
Text("Hats")
}
}
.navigationDestination(for: Category.self) { cat in
CategoryDetailView(id: cat.id)
}
}
}
}Correct (all value-based links with a single destination registration):
// GOOD: Every link pushes through NavigationPath.
// removeLast(), popToRoot, and deep-link append all work
// because there is a single source of truth for the stack.
@Equatable
struct CatalogView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List {
NavigationLink(value: Category(id: "shoes")) {
Text("Shoes")
}
NavigationLink(value: Category(id: "hats")) {
Text("Hats")
}
}
.navigationDestination(for: Category.self) { category in
CategoryDetailView(id: category.id)
}
}
}
}Avoid Heavy Work in View Initializers
SwiftUI may construct destination views eagerly -- especially with NavigationLink(destination:) on pre-iOS 16 and during prefetching in lazy containers. Heavy work in init() (network calls, database queries, large allocations) blocks the main thread during the push animation, causing visible frame drops and hitches. The fix is to keep initializers lightweight and defer all real work to .task {} or .onAppear.
Incorrect (expensive work in init blocks the push animation):
// BAD: The view model fetches data synchronously in init().
// When NavigationLink constructs this view, the main thread
// stalls for 200-800ms, causing a visible hitch in the push animation.
struct ProductDetailView: View {
@State private var viewModel: ProductDetailViewModel
init(productId: String) {
// Heavy work: synchronous database query + JSON parsing
let product = ProductDatabase.shared.fetchSync(id: productId)
let recommendations = RecommendationEngine.shared
.computeSync(for: product)
self.viewModel = ProductDetailViewModel(
product: product,
recommendations: recommendations
)
}
var body: some View {
ProductContent(viewModel: viewModel)
}
}Correct (lightweight init with deferred async loading):
// GOOD: init() only stores the product ID — near-zero cost.
// All expensive work runs asynchronously in .task {},
// which fires after the view appears so the push stays at 60fps.
@Equatable
struct ProductDetailView: View {
@State private var viewModel: ProductDetailViewModel
init(productId: String) {
self.viewModel = ProductDetailViewModel(productId: productId)
}
var body: some View {
Group {
if viewModel.isLoading {
ProgressView()
} else {
ProductContent(viewModel: viewModel)
}
}
.task {
await viewModel.loadProduct()
await viewModel.loadRecommendations()
}
}
}
@Observable @MainActor
final class ProductDetailViewModel {
let productId: String
var product: Product?
var recommendations: [Product] = []
var isLoading = true
init(productId: String) {
self.productId = productId // No I/O in init
}
func loadProduct() async {
product = await ProductDatabase.shared.fetch(id: productId)
}
func loadRecommendations() async {
guard let product else { return }
recommendations = await RecommendationEngine.shared
.compute(for: product)
isLoading = false
}
}Avoid Programmatic Tab Selection Changes
Apple's Human Interface Guidelines state that tabs should not change programmatically without direct user action. When the app switches tabs in response to server events, timers, or background state changes, users lose spatial context -- they cannot predict where they will end up, and their mental model of the app's layout breaks. Instead, use badges, indicators, or in-tab banners to draw attention to other tabs while leaving the user in control of navigation.
Incorrect (tab switches automatically based on external events):
// BAD: The selected tab changes without user interaction.
// A push notification or timer fires and yanks the user
// to a different tab, losing their scroll position and
// context in the current tab. This is disorienting.
struct MainTabView: View {
@State private var selectedTab: Tab = .home
@Environment(NotificationHandler.self) private var notificationHandler
var body: some View {
TabView(selection: $selectedTab) {
HomeView()
.tabItem { Label("Home", systemImage: "house") }
.tag(Tab.home)
MessagesView()
.tabItem { Label("Messages", systemImage: "message") }
.tag(Tab.messages)
SettingsView()
.tabItem { Label("Settings", systemImage: "gear") }
.tag(Tab.settings)
}
.onReceive(notificationHandler.deepLinkPublisher) { link in
// Forcefully switches tab without user consent
switch link {
case .newMessage:
selectedTab = .messages // User is yanked away
case .settingsUpdate:
selectedTab = .settings // User loses context
default:
break
}
}
.onReceive(Timer.publish(every: 30, on: .main, in: .common).autoconnect()) { _ in
// Even worse: periodic forced tab switch
if notificationHandler.unreadCount > 0 {
selectedTab = .messages // Switches every 30 seconds
}
}
}
}Correct (user controls tab selection; badges draw attention):
// GOOD: The user is always in control of which tab is active.
// Deep links and events update badge counts and in-tab banners
// to guide the user's attention without forcefully switching context.
@Equatable
struct MainTabView: View {
@State private var selectedTab: Tab = .home
@Environment(NotificationHandler.self) private var notificationHandler
var body: some View {
TabView(selection: $selectedTab) {
HomeView()
.tabItem { Label("Home", systemImage: "house") }
.tag(Tab.home)
MessagesView()
.tabItem { Label("Messages", systemImage: "message") }
.badge(notificationHandler.unreadCount) // Visual indicator
.tag(Tab.messages)
SettingsView()
.tabItem { Label("Settings", systemImage: "gear") }
.badge(notificationHandler.hasSettingsUpdate ? "!" : nil)
.tag(Tab.settings)
}
.onReceive(notificationHandler.deepLinkPublisher) { link in
// Update badge state, but never force-switch the tab.
// If the user is already on the target tab, navigate
// within that tab's stack instead.
switch link {
case .newMessage:
notificationHandler.incrementUnread()
if selectedTab == .messages {
// User is already here — push the conversation
notificationHandler.pendingConversationId = link.conversationId
}
case .settingsUpdate:
notificationHandler.hasSettingsUpdate = true
default:
break
}
}
}
}When NOT to use this pattern:
- Programmatic tab switching IS appropriate when the user directly initiated the action, such as tapping a deep link, notification, or universal link. The anti-pattern is switching tabs from background events, timers, or server pushes without any user gesture.
- Apple's own apps (Messages, Mail) switch tabs when resolving user-tapped deep links.
Avoid Scattering navigationDestination Across Views
When multiple child views register .navigationDestination(for:) for the same type, SwiftUI does not merge them -- it picks one non-deterministically. Which registration wins can change between app launches, view reloads, or even device rotations, leading to wrong destinations, blank screens, or outright crashes. The only safe pattern is a single registration per type, placed at or near the NavigationStack root.
Incorrect (same type registered in multiple children):
// BAD: Two children both claim Item.self destinations.
// SwiftUI silently picks one at random — tapping an item
// may show ChildViewA's detail OR ChildViewB's detail
// depending on render order. This is undefined behavior.
struct ContentView: View {
var body: some View {
NavigationStack {
VStack {
ChildViewA()
ChildViewB()
}
}
}
}
struct ChildViewA: View {
var body: some View {
List(itemsA) { item in
NavigationLink(value: item) { Text(item.name) }
}
.navigationDestination(for: Item.self) { item in
ItemEditView(item: item) // Registration #1
}
}
}
struct ChildViewB: View {
var body: some View {
List(itemsB) { item in
NavigationLink(value: item) { Text(item.name) }
}
.navigationDestination(for: Item.self) { item in
ItemReadOnlyView(item: item) // Registration #2 — conflicts with #1
}
}
}Correct (single destination registration at the stack root):
// GOOD: One registration per Hashable type at the stack root.
// Every NavigationLink(value: Item) resolves to the same,
// predictable destination regardless of where the link lives.
@Equatable
struct ContentView: View {
var body: some View {
NavigationStack {
VStack {
ChildViewA()
ChildViewB()
}
.navigationDestination(for: Item.self) { item in
ItemDetailView(item: item)
}
}
}
}
@Equatable
struct ChildViewA: View {
var body: some View {
List(itemsA) { item in
NavigationLink(value: item) { Text(item.name) }
}
}
}
@Equatable
struct ChildViewB: View {
var body: some View {
List(itemsB) { item in
NavigationLink(value: item) { Text(item.name) }
}
}
}Avoid Sharing NavigationStack Across Tabs
Each tab must own its own NavigationStack with an independent navigation path. Wrapping an entire TabView in a single NavigationStack causes pushes in one tab to remain visible when switching tabs, the back button to appear on unrelated tabs, and popToRoot to affect the wrong content. Apple explicitly warns against this pattern in the Human Interface Guidelines.
Incorrect (single NavigationStack wrapping TabView):
// BAD: One stack for all tabs. Pushing a detail in the
// Home tab leaves the back button visible when the user
// switches to Profile. The navigation bar title shows
// "Home Detail" on the Profile tab. State is fully corrupted.
struct AppRootView: View {
@State private var selectedTab = 0
var body: some View {
NavigationStack { // Shared stack — every tab shares this
TabView(selection: $selectedTab) {
HomeView()
.tabItem { Label("Home", systemImage: "house") }
.tag(0)
SearchView()
.tabItem { Label("Search", systemImage: "magnifyingglass") }
.tag(1)
ProfileView()
.tabItem { Label("Profile", systemImage: "person") }
.tag(2)
}
}
}
}Correct (each tab owns its NavigationStack):
// GOOD: Independent stacks per tab. Each tab has its own
// back stack, path state, and navigation bar configuration.
// Switching tabs preserves each tab's navigation history.
@Equatable
struct AppRootView: View {
@State private var selectedTab = 0
var body: some View {
TabView(selection: $selectedTab) {
NavigationStack {
HomeView()
}
.tabItem { Label("Home", systemImage: "house") }
.tag(0)
NavigationStack {
SearchView()
}
.tabItem { Label("Search", systemImage: "magnifyingglass") }
.tag(1)
NavigationStack {
ProfileView()
}
.tabItem { Label("Profile", systemImage: "person") }
.tag(2)
}
}
}Present All Modals Via Coordinator — Never Inline @State
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Modal presentations (sheet, fullScreenCover, alert, confirmationDialog) must be driven by coordinator-owned state, not inline view @State booleans. The coordinator exposes observable modal state as Identifiable enums, and the root NavigationStack view binds .sheet(item:) to it. This keeps modal logic testable, allows coordinators to present modals from deep links or push notifications, and eliminates scattered @State private var showX = false across views.
Incorrect (inline @State booleans for modals — untestable, scattered):
@Equatable
struct OrderDetailView: View {
let orderId: String
// Modal state scattered across the view — untestable
@State private var showRefundSheet = false
@State private var showCancelAlert = false
@State private var showContactSheet = false
var body: some View {
VStack {
Button("Request Refund") { showRefundSheet = true }
Button("Cancel Order") { showCancelAlert = true }
}
// Cannot be triggered from deep link or push notification
.sheet(isPresented: $showRefundSheet) {
RefundView(orderId: orderId)
}
.alert("Cancel Order?", isPresented: $showCancelAlert) {
Button("Cancel Order", role: .destructive) { }
}
}
}Correct (coordinator owns all modal state — testable, externally triggerable):
// Modal routes as Identifiable enums
enum OrderSheetRoute: Identifiable {
case refund(orderId: String)
case contactSupport(orderId: String)
var id: String {
switch self {
case .refund(let id): "refund-\(id)"
case .contactSupport(let id): "contact-\(id)"
}
}
}
@Observable @MainActor
final class OrderCoordinator {
var path = NavigationPath()
var presentedSheet: OrderSheetRoute?
func presentRefund(orderId: String) {
presentedSheet = .refund(orderId: orderId)
}
@ViewBuilder
func sheetView(for route: OrderSheetRoute) -> some View {
switch route {
case .refund(let orderId):
NavigationStack { RefundView(orderId: orderId) }
case .contactSupport(let orderId):
NavigationStack { ContactSupportView(orderId: orderId) }
}
}
}
// Root view binds modals to coordinator state — single binding point
@Equatable
struct OrderFlowView: View {
@State private var coordinator = OrderCoordinator()
var body: some View {
@Bindable var coordinator = coordinator
NavigationStack(path: $coordinator.path) {
OrderListView()
.navigationDestination(for: OrderRoute.self) { route in
coordinator.destinationView(for: route)
}
}
.sheet(item: $coordinator.presentedSheet) { route in
coordinator.sheetView(for: route)
}
.environment(coordinator)
}
}
// Views request modals via coordinator — no @State booleans
@Equatable
struct OrderDetailView: View {
let orderId: String
@Environment(OrderCoordinator.self) private var coordinator
var body: some View {
VStack {
Button("Request Refund") {
coordinator.presentRefund(orderId: orderId)
}
}
}
}Reference: Advanced iOS App Architecture (4th Ed.)
Extract Navigation Logic into Observable Coordinator
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Every feature MUST have a coordinator. When navigation state is scattered across views with @State properties, it becomes impossible to unit test navigation flows, handle deep links centrally, or coordinate complex multi-step transitions like authentication gates or onboarding flows. An @Observable coordinator centralizes all navigation state (stack path, presented sheets, full-screen covers, alerts) into a single testable object. Views become pure renderers of coordinator state, and navigation logic can be verified without UI tests.
Incorrect (navigation state scattered across multiple views):
// COST: Navigation state spread across views, no central control
// Deep linking requires reaching into each view's @State
// Testing navigation flows impossible without UI tests
struct HomeView: View {
@State private var showSettings = false
@State private var showProfile = false
@State private var selectedProduct: Product? = nil
@State private var showPurchaseConfirmation = false
@State private var showLoginSheet = false
var body: some View {
NavigationStack {
VStack {
Button("Settings") { showSettings = true }
Button("Profile") {
if AuthService.shared.isLoggedIn {
showProfile = true
} else {
showLoginSheet = true // Auth check duplicated everywhere
}
}
}
.sheet(isPresented: $showSettings) { SettingsView() }
.sheet(isPresented: $showProfile) { ProfileView() }
.sheet(isPresented: $showLoginSheet) { LoginView() }
.fullScreenCover(isPresented: $showPurchaseConfirmation) {
PurchaseConfirmationView()
}
}
}
}Correct (@Observable coordinator centralizing all navigation state):
// All navigation state in one testable object
@Observable @MainActor
final class AppCoordinator {
var path: [AppRoute] = []
var presentedSheet: SheetDestination?
var presentedFullScreenCover: FullScreenCoverDestination?
private let authService: AuthServiceProtocol
init(authService: AuthServiceProtocol = AuthService.shared) { self.authService = authService }
func navigate(to route: AppRoute) { path.append(route) }
func pop() { guard !path.isEmpty else { return }; path.removeLast() }
func popToRoot() { path.removeAll() }
func showSettings() { presentedSheet = .settings }
func showProfile() {
guard authService.isLoggedIn else {
presentedSheet = .login(returnAction: .profile); return
}
presentedSheet = .profile
}
func showPurchaseConfirmation(orderId: String) {
presentedFullScreenCover = .purchaseConfirmation(orderId: orderId)
}
func handleDeepLink(_ url: URL) {
guard let route = AppRoute(from: url) else { return }
popToRoot(); navigate(to: route)
}
func saveState() -> Data? { try? JSONEncoder().encode(path) }
func restoreState(from data: Data) {
path = (try? JSONDecoder().decode([AppRoute].self, from: data)) ?? []
}
}
@Equatable
struct HomeView: View {
@Environment(AppCoordinator.self) private var coordinator
var body: some View {
@Bindable var coordinator = coordinator
NavigationStack(path: $coordinator.path) {
ProductGrid()
.navigationDestination(for: AppRoute.self) { route in
/* ... switch route cases ... */
}
}
.sheet(item: $coordinator.presentedSheet) { sheetContent(for: $0) }
.fullScreenCover(item: $coordinator.presentedFullScreenCover) { fullScreenContent(for: $0) }
.onOpenURL { coordinator.handleDeepLink($0) }
}
}Handle Deep Links by Appending to NavigationPath
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
Deep links from universal links, push notifications, Spotlight, and widgets must resolve to the same navigation state as manual user navigation. Converting incoming URLs to route enum values and appending them to NavigationPath ensures consistent behavior regardless of entry point. Manually toggling booleans or presenting views imperatively creates parallel navigation paths that bypass the stack, break the back button, and cannot be serialized for state restoration.
Incorrect (boolean flags and imperative view presentation for deep links):
// COST: Each deep link needs its own boolean and .sheet modifier
// Back stack not updated, user cannot swipe back through deep link path
// State restoration impossible
struct AppRootView: View {
@State private var showProduct = false
@State private var deepLinkedProductId: String?
@State private var showOrder = false
@State private var deepLinkedOrderId: String?
var body: some View {
NavigationStack {
HomeView()
}
.onOpenURL { url in
let components = URLComponents(url: url, resolvingAgainstBaseURL: true)
let path = components?.path ?? ""
// Fragile string parsing, no type safety
if path.contains("product") {
deepLinkedProductId = path.components(separatedBy: "/").last
showProduct = true
} else if path.contains("order") {
deepLinkedOrderId = path.components(separatedBy: "/").last
showOrder = true
}
}
.sheet(isPresented: $showProduct) {
if let id = deepLinkedProductId {
ProductDetailView(productId: id)
}
}
.sheet(isPresented: $showOrder) {
if let id = deepLinkedOrderId {
OrderDetailView(orderId: id)
}
}
}
}Correct (URL-to-route conversion appended to NavigationPath):
// BENEFIT: Deep links produce same navigation state as manual navigation
// Full back stack preserved, state restoration works
@Equatable
struct AppRootView: View {
@Environment(AppCoordinator.self) private var coordinator
var body: some View {
@Bindable var coordinator = coordinator
NavigationStack(path: $coordinator.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { RouteDestinationView(route: $0) }
}
.onOpenURL { coordinator.handleDeepLink($0) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
guard let url = activity.webpageURL else { return }
coordinator.handleDeepLink(url)
}
}
}
extension AppCoordinator {
func handleDeepLink(_ url: URL) {
guard let routes = DeepLinkParser.parse(url) else { return }
popToRoot(); for route in routes { navigate(to: route) }
}
}
enum DeepLinkParser {
static func parse(_ url: URL) -> [AppRoute]? {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let host = components.host else { return nil }
let segments = components.path.split(separator: "/").map(String.init)
switch host {
case "products":
guard let productId = segments.first else { return nil }
return [.productList(categoryId: "all"), .productDetail(productId: productId)]
case "orders":
guard let orderId = segments.first else { return nil }
return [.orderDetail(orderId: orderId)]
case "sellers":
guard let sellerId = segments.first else { return nil }
var routes: [AppRoute] = [.sellerProfile(sellerId: sellerId)]
if segments.count > 1, segments[1] == "products" {
routes.insert(.productList(categoryId: "seller-\(sellerId)"), at: 0)
}
return routes
default: return nil
}
}
}Use navigationDestination(item:) for Optional-Based Navigation (iOS 17+)
The navigationDestination(item:destination:) modifier (iOS 17+) pushes a view when an optional binding becomes non-nil and pops it when the value resets to nil. This replaces the common pattern of using a separate @State var isShowingDetail = false boolean alongside an optional selection, eliminating an entire class of state synchronization bugs where the boolean and optional get out of sync.
Incorrect (manual boolean + optional state synchronization):
struct OrderListView: View {
@State private var selectedOrder: Order?
@State private var isShowingDetail = false
var body: some View {
NavigationStack {
List(orders) { order in
Button(order.title) {
// BAD: Two pieces of state must stay in sync.
// If isShowingDetail is true but selectedOrder is nil
// (or vice versa), the UI breaks silently.
selectedOrder = order
isShowingDetail = true
}
}
.navigationDestination(isPresented: $isShowingDetail) {
if let order = selectedOrder {
OrderDetailView(order: order)
}
}
}
}
}Correct (single optional drives navigation):
@Equatable
struct OrderListView: View {
@State private var selectedOrder: Order?
var body: some View {
NavigationStack {
List(orders) { order in
Button(order.title) {
// Single state change drives both push and pop.
// Setting to nil pops automatically.
selectedOrder = order
}
}
// Pushes when selectedOrder becomes non-nil,
// pops and resets to nil on back navigation.
.navigationDestination(item: $selectedOrder) { order in
OrderDetailView(order: order)
}
}
}
}Register navigationDestination at Stack Root
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
When .navigationDestination(for:) modifiers are scattered across child views, SwiftUI may encounter duplicate registrations for the same type, or fail to find a registration that has not yet appeared in the view hierarchy. This causes silent navigation failures, crashes, or race conditions where the destination resolves differently depending on which child rendered first. Registering all destinations once at the NavigationStack root guarantees deterministic resolution and makes the navigation contract explicit.
Incorrect (destination registrations scattered across child views):
// COST: Each child registers its own .navigationDestination, leading to
// duplicate registrations for the same type. SwiftUI picks one
// non-deterministically, causing the wrong detail view to appear or
// navigation to silently fail when a child hasn't rendered yet.
struct CatalogTab: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
CatalogGrid()
}
}
}
struct CatalogGrid: View {
let products: [Product]
var body: some View {
LazyVGrid(columns: columns) {
ForEach(products) { product in
NavigationLink(value: product) {
ProductCard(product: product)
}
}
}
// Registration buried in child — may conflict with other registrations
.navigationDestination(for: Product.self) { product in
ProductDetailView(product: product)
}
}
}
struct SearchResultsView: View {
let results: [Product]
var body: some View {
List(results) { product in
NavigationLink(value: product) { ProductRow(product: product) }
}
// Duplicate registration for Product.self — undefined behavior
.navigationDestination(for: Product.self) { product in
SearchProductDetailView(product: product)
}
}
}Correct (single destination registration at NavigationStack root using route enum):
// BENEFIT: All destinations registered once at the stack root. No
// duplicates, no race conditions, deterministic resolution. Adding
// a new route is a single case in the enum and a single switch branch.
@Equatable
struct CatalogTab: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
CatalogGrid()
.navigationDestination(for: CatalogRoute.self) { route in
switch route {
case .productDetail(let product):
ProductDetailView(product: product)
case .categoryListing(let category):
CategoryListingView(category: category)
case .sellerStore(let sellerId):
SellerStoreView(sellerId: sellerId)
case .searchResults(let query):
SearchResultsView(query: query)
}
}
}
}
}
@Equatable
struct CatalogGrid: View {
let products: [Product]
var body: some View {
LazyVGrid(columns: columns) {
ForEach(products) { product in
// Child views only push values — no destination registration
NavigationLink(value: CatalogRoute.productDetail(product)) {
ProductCard(product: product)
}
}
}
}
}Apply @Equatable Macro to Every Navigation View
SwiftUI's reflection-based diffing fails silently when views contain non-Equatable properties. If ANY stored property isn't diffable, the ENTIRE view body re-evaluates on every parent update — including every navigation push, pop, and tab switch. The @Equatable macro generates Equatable conformance for all stored properties, excluding @State/@Environment wrappers. Build fails if a non-Equatable property is added without @SkipEquatable — acting as a compile-time performance linter.
Incorrect (no @Equatable — body re-evaluates on every parent update):
// Navigation destination view without @Equatable
// Body re-evaluates on EVERY parent state change, even if
// product hasn't changed. In a list of 50 items, this means
// 50 unnecessary body evaluations per state change.
struct ProductDetailView: View {
let productId: String
let onAddToCart: () -> Void // closure — NOT diffable
var body: some View {
ScrollView {
Text(productId)
Button("Add to Cart", action: onAddToCart)
}
.navigationTitle("Product")
}
}Correct (@Equatable macro — body only re-evaluates when data changes):
// @Equatable generates Equatable conformance for all stored properties
// @SkipEquatable excludes closures from comparison
// @State and @Environment are automatically excluded
@Equatable
struct ProductDetailView: View {
let productId: String
@SkipEquatable
let onAddToCart: () -> Void // excluded from comparison
var body: some View {
ScrollView {
Text(productId)
Button("Add to Cart", action: onAddToCart)
}
.navigationTitle("Product")
}
}Prerequisite: The @Equatable macro requires the `ordo-one/equatable` SPM package. Add it via Package.swift or Xcode's package manager. The open-source package uses @EquatableIgnored instead of @SkipEquatable (Airbnb's internal name).
Alternative (built-in SwiftUI, no third-party dependency):
struct ProductDetailView: View, Equatable {
let productId: String
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.productId == rhs.productId
}
var body: some View {
ScrollView {
Text(productId)
}
.navigationTitle("Product")
}
}Reference: Airbnb Engineering — Understanding and Improving SwiftUI Performance
Use NavigationPath for Heterogeneous Type-Erased Navigation
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
NavigationPath is a type-erased container that can hold any Hashable values, making it suitable when a single NavigationStack must handle pushes from multiple unrelated data types. However, when all destinations share a common route enum, a typed array [Route] provides compile-time safety, direct subscript access, and straightforward Codable persistence without the indirection of NavigationPath's CodableRepresentation. Choose NavigationPath for heterogeneous stacks across module boundaries; choose a typed array when routes are centralized in a single enum.
Incorrect (separate @State booleans simulating a navigation stack):
// COST: Each destination needs its own boolean, no ordered stack
// Cannot determine back navigation order, pop-to-root requires resetting every flag
struct DashboardView: View {
@State private var showTransactionDetail = false
@State private var selectedTransaction: Transaction?
@State private var showAccountSettings = false
@State private var showTransferFlow = false
var body: some View {
NavigationStack {
VStack {
TransactionListView(onSelect: { transaction in
selectedTransaction = transaction
showTransactionDetail = true
})
Button("Transfer") { showTransferFlow = true }
Button("Settings") { showAccountSettings = true }
}
.navigationDestination(isPresented: $showTransactionDetail) {
if let tx = selectedTransaction {
TransactionDetailView(transaction: tx)
}
}
.navigationDestination(isPresented: $showTransferFlow) {
TransferFlowView()
}
.navigationDestination(isPresented: $showAccountSettings) {
AccountSettingsView()
}
}
}
func popToRoot() {
showTransactionDetail = false
showAccountSettings = false
showTransferFlow = false
selectedTransaction = nil
}
}Correct (NavigationPath for heterogeneous stacks, typed array for single-enum routing):
// Option A: NavigationPath for mixed Hashable types
@Equatable
struct DashboardView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
VStack {
TransactionListView(onSelect: { path.append($0) })
Button("Transfer") { path.append(TransferRequest()) }
}
.navigationDestination(for: Transaction.self) { TransactionDetailView(transaction: $0) }
.navigationDestination(for: TransferRequest.self) { TransferFlowView(request: $0) }
}
}
func popToRoot() { path = NavigationPath() }
func saveState() -> Data? {
guard let repr = path.codable else { return nil }
return try? JSONEncoder().encode(repr)
}
func restoreState(from data: Data) {
guard let repr = try? JSONDecoder().decode(NavigationPath.CodableRepresentation.self, from: data) else { return }
path = NavigationPath(repr)
}
}
// Option B: Typed [Route] array for single enum
@Equatable
struct DashboardView_TypedPath: View {
@State private var path: [DashboardRoute] = []
var body: some View {
NavigationStack(path: $path) {
VStack {
TransactionListView(onSelect: { path.append(.transactionDetail($0.id)) })
Button("Transfer") { path.append(.transferFlow(accountId: "default")) }
}
.navigationDestination(for: DashboardRoute.self) { route in
switch route {
case .transactionDetail(let id): TransactionDetailView(transactionId: id)
case .transferFlow(let accountId): TransferFlowView(accountId: accountId)
case .accountSettings(let section): AccountSectionView(section: section)
case .beneficiaryList: BeneficiaryListView()
}
}
}
}
func popToRoot() { path.removeAll() }
func saveState() -> Data? { try? JSONEncoder().encode(path) }
}Use NavigationStack Over Deprecated NavigationView
NavigationView was deprecated in iOS 16 and exhibits undefined column behavior on iPad, lacks programmatic navigation, and cannot serialize navigation state for restoration. NavigationStack provides a data-driven path model, type-safe destination registration, and full programmatic control over the back stack. Migrating early prevents accumulating technical debt around a dead API.
Incorrect (deprecated NavigationView with eager destination construction):
// COST: NavigationView is deprecated in iOS 16+. On iPad it defaults to
// DoubleColumnNavigationViewStyle, causing blank detail panes and layout
// bugs. No programmatic push/pop or state restoration support.
// NOTE: @StateObject is also legacy — replaced by @State with @Observable
struct ProductCatalogView: View {
@StateObject private var viewModel = CatalogViewModel()
var body: some View {
NavigationView {
List(viewModel.products) { product in
NavigationLink(destination: ProductDetailView(product: product)) {
ProductRow(product: product)
}
}
.navigationTitle("Catalog")
}
.navigationViewStyle(.stack) // Workaround that won't exist forever
}
}Correct (NavigationStack with value-based links and destination registration):
// BENEFIT: NavigationStack provides a data-driven path, type-safe
// destinations, lazy view construction, and programmatic navigation.
// State restoration is built-in via Codable NavigationPath.
@Equatable
struct ProductCatalogView: View {
@State private var viewModel = CatalogViewModel()
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
List(viewModel.products) { product in
NavigationLink(value: Route.product(product)) {
ProductRow(product: product)
}
}
.navigationTitle("Catalog")
.navigationDestination(for: Route.self) { route in
switch route {
case .product(let product):
ProductDetailView(product: product)
case .category(let category):
CategoryView(category: category)
case .reviews(let productId):
ReviewsView(productId: productId)
}
}
}
}
}Never Use AnyView in Navigation — Use @ViewBuilder or Generics
AnyView type-erases the view hierarchy, preventing SwiftUI from diffing efficiently. When used in navigationDestination or coordinator view factories, EVERY navigation push/pop forces full-tree re-evaluation instead of targeted updates. Use @ViewBuilder for conditional composition and generic constraints for reusable containers.
Incorrect (AnyView in destination factory — disables diffing):
@Observable
final class AppCoordinator {
var path = NavigationPath()
// AnyView disables SwiftUI's diffing — every navigation
// event forces full-tree re-evaluation of the destination
func destinationView(for route: AppRoute) -> AnyView {
switch route {
case .productDetail(let id):
AnyView(ProductDetailView(productId: id))
case .sellerProfile(let id):
AnyView(SellerProfileView(sellerId: id))
case .settings:
AnyView(SettingsView())
}
}
}Correct (@ViewBuilder preserves type information for diffing):
@Observable @MainActor
final class AppCoordinator {
var path = NavigationPath()
// @ViewBuilder preserves concrete types — SwiftUI can diff
// each branch independently, only updating changed properties
@ViewBuilder
func destinationView(for route: AppRoute) -> some View {
switch route {
case .productDetail(let id):
ProductDetailView(productId: id)
case .sellerProfile(let id):
SellerProfileView(sellerId: id)
case .settings:
SettingsView()
}
}
}
@Equatable
struct AppRootView: View {
@State private var coordinator = AppCoordinator()
var body: some View {
@Bindable var coordinator = coordinator
NavigationStack(path: $coordinator.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
coordinator.destinationView(for: route)
}
}
.environment(coordinator)
}
}Reference: Airbnb Engineering — Understanding and Improving SwiftUI Performance
Use @Environment with @Observable and @Bindable for Shared State
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
@Observable objects are injected with .environment(object) and read with @Environment(Type.self). This replaces the legacy .environmentObject(object) / @EnvironmentObject pattern. To get Binding access to an @Observable object's properties (e.g., for NavigationStack(path:)), re-declare it as @Bindable var inside the body property. Using @EnvironmentObject with an @Observable class does not work — it requires ObservableObject conformance.
Incorrect (@EnvironmentObject with @Observable — does not compile):
@Observable
class AppCoordinator {
var path: [Route] = []
var presentedSheet: SheetDestination?
}
struct HomeView: View {
// BAD: @EnvironmentObject requires ObservableObject conformance.
// @Observable classes do NOT conform to ObservableObject.
// This crashes at runtime with "No ObservableObject found."
@EnvironmentObject var coordinator: AppCoordinator
var body: some View {
// Cannot get Binding for NavigationStack path
NavigationStack(path: $coordinator.path) { /* ... */ }
}
}
struct MyApp: App {
var body: some Scene {
WindowGroup {
// BAD: .environmentObject() requires ObservableObject
HomeView().environmentObject(AppCoordinator())
}
}
}Correct (@Environment + @Bindable for @Observable objects):
@Observable @MainActor
class AppCoordinator {
var path: [Route] = []
var presentedSheet: SheetDestination?
func navigate(to route: Route) { path.append(route) }
func popToRoot() { path.removeAll() }
}
@Equatable
struct HomeView: View {
// Read @Observable from environment — type-safe, no protocol needed
@Environment(AppCoordinator.self) private var coordinator
var body: some View {
// Re-declare as @Bindable to get Binding access
@Bindable var coordinator = coordinator
NavigationStack(path: $coordinator.path) {
ProductGrid()
.navigationDestination(for: Route.self) { route in
route.destinationView
}
}
.sheet(item: $coordinator.presentedSheet) { sheet in
sheet.content
}
}
}
struct MyApp: App {
@State private var coordinator = AppCoordinator()
var body: some Scene {
WindowGroup {
// .environment() injects @Observable objects
HomeView().environment(coordinator)
}
}
}Use @Observable Only — Never ObservableObject or @Published
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
@Observable (iOS 26 / Swift 6.2) tracks which properties each view reads in its body and only triggers re-render when THOSE specific properties change. ObservableObject with @Published triggers re-render for ANY property change on the object — even unrelated ones. In navigation coordinators this is catastrophic: changing presentedSheet re-renders every view observing the coordinator, even those only reading path.
Incorrect (ObservableObject coordinator — all views re-render on any change):
// ObservableObject notifies ALL subscribers when ANY @Published changes
// Changing presentedSheet re-renders views that only read path
class NavigationCoordinator: ObservableObject {
@Published var path = NavigationPath()
@Published var presentedSheet: SheetRoute?
@Published var presentedAlert: AlertRoute?
}
struct OrderListView: View {
@StateObject var coordinator = NavigationCoordinator()
// When presentedSheet changes, this ENTIRE view re-renders
// even though it only reads path
var body: some View {
NavigationStack(path: $coordinator.path) {
List { /* ... */ }
}
}
}Correct (@Observable coordinator — property-level tracking):
// @Observable tracks which properties each view reads
// Changing presentedSheet does NOT re-render views only reading path
@Observable @MainActor
final class NavigationCoordinator {
var path = NavigationPath()
var presentedSheet: SheetRoute?
var presentedAlert: AlertRoute?
func navigate(to route: AppRoute) { path.append(route) }
func popToRoot() { path = NavigationPath() }
}
@Equatable
struct OrderListView: View {
@Environment(NavigationCoordinator.self) private var coordinator
var body: some View {
@Bindable var coordinator = coordinator
NavigationStack(path: $coordinator.path) {
List { /* ... */ }
}
}
}Key replacements:
ObservableObject→@Observable@Published var→ plainvar@StateObject→@State@ObservedObject→ plain property@EnvironmentObject→@Environment(Type.self).environmentObject()→.environment()
Reference: WWDC23 — Discover Observation in SwiftUI
Define Routes as Hashable Enums
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
A single Hashable + Codable enum for all routes provides compile-time safety for every navigation path in the app. The compiler enforces exhaustive switch handling so adding a new screen cannot be forgotten. Codable conformance enables NavigationPath serialization for state restoration and process termination recovery. Centralizing routes also makes deep linking a simple URL-to-enum mapping instead of scattered conditional logic.
Incorrect (untyped string-based or boolean-based navigation):
// COST: Zero compile-time safety, typos cause silent failures
// No Codable support, no state restoration
struct MainView: View {
@State private var activeScreen: String? = nil
@State private var showSettings = false
@State private var showProfile = false
var body: some View {
NavigationStack {
VStack {
Button("Products") { activeScreen = "products" }
Button("Settings") { showSettings = true }
}
.navigationDestination(isPresented: Binding(
get: { activeScreen == "prodcts" }, // Typo — silent failure
set: { if !$0 { activeScreen = nil } }
)) {
ProductListView()
}
}
}
}Correct (Hashable + Codable route enum with associated values):
// BENEFIT: Exhaustive switch handling, typed payloads, state restoration
enum AppRoute: Hashable, Codable {
case productList(categoryId: String)
case productDetail(productId: String)
case sellerProfile(sellerId: String)
case checkout(cartId: String)
case settings
case search(query: String)
init?(from url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
let host = components.host else { return nil }
let pathSegments = components.path.split(separator: "/").map(String.init)
switch (host, pathSegments.first) {
case ("products", let productId?): self = .productDetail(productId: productId)
case ("sellers", let sellerId?): self = .sellerProfile(sellerId: sellerId)
case ("search", _):
let query = components.queryItems?.first(where: { $0.name == "q" })?.value ?? ""
self = .search(query: query)
default: return nil
}
}
}
// Coordinator owns the typed route array — see arch-coordinator for full pattern
@Observable @MainActor
final class AppCoordinator {
var path: [AppRoute] = []
func navigate(to route: AppRoute) { path.append(route) }
func popToRoot() { path.removeAll() }
@ViewBuilder
func destinationView(for route: AppRoute) -> some View {
switch route {
case .productList(let categoryId): ProductListView(categoryId: categoryId)
case .productDetail(let productId): ProductDetailView(productId: productId)
case .sellerProfile(let sellerId): SellerProfileView(sellerId: sellerId)
case .checkout(let cartId): CheckoutView(cartId: cartId)
case .settings: SettingsView()
case .search(let query): SearchResultsView(query: query)
}
}
}
@Equatable
struct AppRootView: View {
@State private var coordinator = AppCoordinator()
var body: some View {
@Bindable var coordinator = coordinator
NavigationStack(path: $coordinator.path) {
HomeView()
.navigationDestination(for: AppRoute.self) { route in
coordinator.destinationView(for: route)
}
}
.environment(coordinator)
}
}Use NavigationSplitView for Multi-Column Layouts
NavigationSplitView provides 2-column or 3-column layouts that automatically collapse into a single NavigationStack on compact-width devices. Attempting to replicate this behavior manually with GeometryReader and conditional NavigationStack layouts leads to state synchronization bugs, broken back-swipe gestures, and duplicated navigation logic. NavigationSplitView also integrates with sidebar visibility, column width preferences, and the system toolbar placement conventions that users expect on iPadOS and macOS Catalyst.
Incorrect (manual layout switching with GeometryReader):
// COST: Duplicates navigation logic, state sync breaks on rotation
// Back-swipe gesture disappears, toolbar items render incorrectly
// NOTE: @StateObject is also legacy — replaced by @State with @Observable
struct MailboxView: View {
@StateObject private var viewModel = MailboxViewModel()
@Environment(\.horizontalSizeClass) private var sizeClass
var body: some View {
if sizeClass == .regular {
HStack(spacing: 0) {
NavigationStack {
MailSidebarView(folders: viewModel.folders, selection: $viewModel.selectedFolder)
.frame(width: 320)
}
Divider()
NavigationStack {
if let folder = viewModel.selectedFolder {
MessageListView(folder: folder)
} else {
Text("Select a folder")
}
}
}
} else {
NavigationStack {
MailSidebarView(folders: viewModel.folders, selection: $viewModel.selectedFolder)
.navigationDestination(for: Folder.self) { MessageListView(folder: $0) }
}
}
}
}Correct (NavigationSplitView with embedded detail NavigationStack):
// BENEFIT: Automatic column layout, collapse, sidebar visibility
// iPhone becomes stack, iPad renders resizable sidebar
@Equatable
struct MailboxView: View {
@State private var viewModel = MailboxViewModel()
@State private var selectedFolder: Folder?
@State private var selectedMessage: Message?
@State private var columnVisibility: NavigationSplitViewVisibility = .all
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
List(viewModel.folders, selection: $selectedFolder) { folder in
Label(folder.name, systemImage: folder.icon).badge(folder.unreadCount)
}
.navigationTitle("Mailboxes")
} content: {
if let folder = selectedFolder {
List(folder.messages, selection: $selectedMessage) { message in
MessageRow(message: message)
}
.navigationTitle(folder.name)
} else {
ContentUnavailableView("No Folder Selected", systemImage: "folder")
}
} detail: {
NavigationStack {
if let message = selectedMessage {
MessageDetailView(message: message)
.navigationDestination(for: Attachment.self) {
AttachmentPreviewView(attachment: $0)
}
} else {
ContentUnavailableView("No Message Selected", systemImage: "envelope")
}
}
}
.navigationSplitViewStyle(.balanced)
}
}Use Value-Based NavigationLink Over Destination Closures
Clinic architecture alignment (iOS 26 / Swift 6.2): Keep Feature modules on Domain + DesignSystem only; keep App-target DependencyContainer, route shells, and concrete coordinators as the integration point; keep Data as the only owner of SwiftData/network/sync I/O.
NavigationLink(destination:) allocates the destination view struct and runs its initializer for every visible row, even if the user never taps it. When initializers trigger view model creation, network setup, or heavy allocations, this causes memory spikes and delays initial display. Value-based NavigationLink defers all construction until the push occurs, integrates with NavigationPath for programmatic control, and enforces type-safe routing through the destination registration pattern.
Incorrect (destination closure eagerly allocates views):
// COST: Each NavigationLink immediately constructs a ProductDetailView,
// including its view model, network layer, and image prefetch pipeline.
// For a list of 200 products this creates 200 detail view graphs on
// first render, spiking memory significantly and delaying initial display.
struct ProductListView: View {
let products: [Product]
var body: some View {
List(products) { product in
NavigationLink(destination: ProductDetailView(
viewModel: ProductDetailViewModel(
product: product,
repository: ProductRepository(),
imageLoader: ImageLoader()
)
)) {
ProductRow(product: product)
}
}
}
}Correct (value-based link with lazy destination resolution):
// BENEFIT: Only the Hashable value is stored per row. The destination
// view is constructed lazily when the user actually navigates. Memory
// stays flat regardless of list size, and the value integrates with
// NavigationPath for programmatic push/pop and deep linking.
@Equatable
struct ProductListView: View {
let products: [Product]
var body: some View {
List(products) { product in
NavigationLink(value: Route.productDetail(product.id)) {
ProductRow(product: product)
}
}
.navigationDestination(for: Route.self) { route in
switch route {
case .productDetail(let productId):
ProductDetailView(
viewModel: ProductDetailViewModel(productId: productId)
)
case .sellerProfile(let sellerId):
SellerProfileView(sellerId: sellerId)
case .reviewsList(let productId):
ReviewsListView(productId: productId)
}
}
}
}Related skills
FAQ
What does ios-navigation do?
ios-navigation: A skill for development. This provides functionality for development workflows.
When should I use ios-navigation?
When you need to use ios-navigation for development tasks, or when ios-navigation: a skill for development. this provides functionality for development workflows.
What are the main capabilities?
ios-navigation.