Swift-ios-skills swiftui-navigation

Implement SwiftUI navigation patterns including NavigationStack, NavigationSplitView, sheet presentation, tab-based navigation, and deep linking. Use when building push navigation, programmatic routing, multi-column layouts, modal sheets, tab bars, universal links, or custom URL scheme handling.

install
source · Clone the upstream repo
git clone https://github.com/dpearson2699/swift-ios-skills
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/dpearson2699/swift-ios-skills "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/swiftui-navigation" ~/.claude/skills/dpearson2699-swift-ios-skills-swiftui-navigation && rm -rf "$T"
manifest: skills/swiftui-navigation/SKILL.md
source content

SwiftUI Navigation

Navigation patterns for SwiftUI apps targeting iOS 26+ with Swift 6.3. Covers push navigation, multi-column layouts, sheet presentation, tab architecture, and deep linking. Patterns are backward-compatible to iOS 17 unless noted.

Contents

NavigationStack (Push Navigation)

Use

NavigationStack
with a
NavigationPath
binding for programmatic, type-safe push navigation. Define routes as a
Hashable
enum and map them with
.navigationDestination(for:)
.

struct ContentView: View {
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            List(items) { item in
                NavigationLink(value: item) {
                    ItemRow(item: item)
                }
            }
            .navigationDestination(for: Item.self) { item in
                DetailView(item: item)
            }
            .navigationTitle("Items")
        }
    }
}

Programmatic navigation:

path.append(item)        // Push
path.removeLast()        // Pop one
path = NavigationPath()  // Pop to root

Router pattern: For apps with complex navigation, use a router object that owns the path and sheet state. Each tab gets its own router instance injected via

.environment()
. Centralize destination mapping with a single
.navigationDestination(for:)
block or a shared
withAppRouter()
modifier.

See references/navigationstack.md for full router examples including per-tab stacks, centralized destination mapping, and generic tab routing.

NavigationSplitView (Multi-Column)

Use

NavigationSplitView
for sidebar-detail layouts on iPad and Mac. Falls back to stack navigation on iPhone.

struct MasterDetailView: View {
    @State private var selectedItem: Item?

    var body: some View {
        NavigationSplitView {
            List(items, selection: $selectedItem) { item in
                NavigationLink(value: item) { ItemRow(item: item) }
            }
            .navigationTitle("Items")
        } detail: {
            if let item = selectedItem {
                ItemDetailView(item: item)
            } else {
                ContentUnavailableView("Select an Item", systemImage: "sidebar.leading")
            }
        }
    }
}

Custom Split Column (Manual HStack)

For custom multi-column layouts (e.g., a dedicated notification column independent of selection), use a manual

HStack
split with
horizontalSizeClass
checks:

@MainActor
struct AppView: View {
  @Environment(\.horizontalSizeClass) private var horizontalSizeClass
  @AppStorage("showSecondaryColumn") private var showSecondaryColumn = true

  var body: some View {
    HStack(spacing: 0) {
      primaryColumn
      if shouldShowSecondaryColumn {
        Divider().edgesIgnoringSafeArea(.all)
        secondaryColumn
      }
    }
  }

  private var shouldShowSecondaryColumn: Bool {
    horizontalSizeClass == .regular
      && showSecondaryColumn
  }

  private var primaryColumn: some View {
    TabView { /* tabs */ }
  }

  private var secondaryColumn: some View {
    NotificationsTab()
      .environment(\.isSecondaryColumn, true)
      .frame(maxWidth: .secondaryColumnWidth)
  }
}

Use the manual HStack split when you need full control or a non-standard secondary column. Use

NavigationSplitView
when you want a standard system layout with minimal customization.

Sheet Presentation

Prefer

.sheet(item:)
over
.sheet(isPresented:)
when state represents a selected model. Sheets should own their actions and call
dismiss()
internally.

@State private var selectedItem: Item?

.sheet(item: $selectedItem) { item in
    EditItemSheet(item: item)
}

Presentation sizing (iOS 18+): Control sheet dimensions with

.presentationSizing
:

.sheet(item: $selectedItem) { item in
    EditItemSheet(item: item)
        .presentationSizing(.form)  // .form, .page, .fitted, .automatic
}

PresentationSizing
values:

  • .automatic
    -- platform default
  • .page
    -- roughly paper size, for informational content
  • .form
    -- slightly narrower than page, for form-style UI
  • .fitted
    -- sized by the content's ideal size

Fine-tuning:

.fitted(horizontal:vertical:)
constrains fitting axes;
.sticky(horizontal:vertical:)
grows but does not shrink in specified dimensions.

Dismissal confirmation (macOS 15+ / iOS 26+): Use

.dismissalConfirmationDialog("Discard?", shouldPresent: hasUnsavedChanges)
to prevent accidental dismissal of sheets with unsaved changes.

Enum-driven sheet routing: Define a

SheetDestination
enum that is
Identifiable
, store it on the router, and map it with a shared view modifier. This lets any child view present sheets without prop-drilling. See references/sheets.md for the full centralized sheet routing pattern.

Tab-Based Navigation

Use the

Tab
API with a selection binding for scalable tab architecture. Each tab should wrap its content in an independent
NavigationStack
.

struct MainTabView: View {
    @State private var selectedTab: AppTab = .home

    var body: some View {
        TabView(selection: $selectedTab) {
            Tab("Home", systemImage: "house", value: .home) {
                NavigationStack { HomeView() }
            }
            Tab("Search", systemImage: "magnifyingglass", value: .search) {
                NavigationStack { SearchView() }
            }
            Tab("Profile", systemImage: "person", value: .profile) {
                NavigationStack { ProfileView() }
            }
        }
    }
}

Custom binding with side effects: Route selection changes through a function to intercept special tabs (e.g., compose) that should trigger an action instead of changing selection.

iOS 26 Tab Additions

  • Tab(role: .search)
    -- replaces the tab bar with a search field when active
  • .tabBarMinimizeBehavior(_:)
    --
    .onScrollDown
    ,
    .onScrollUp
    ,
    .never
    (iPhone only)
  • .tabViewSidebarHeader/Footer
    -- customize sidebar sections on iPadOS/macOS
  • .tabViewBottomAccessory { }
    -- attach content below the tab bar (e.g., Now Playing bar)
  • TabSection
    -- group tabs into sidebar sections with
    .tabPlacement(.sidebarOnly)

See references/tabview.md for full TabView patterns including custom bindings, dynamic tabs, and sidebar customization.

Deep Links

Universal Links

Universal links let iOS open your app for standard HTTPS URLs. They require:

  1. An Apple App Site Association (AASA) file at
    /.well-known/apple-app-site-association
  2. An Associated Domains entitlement (
    applinks:example.com
    )

Handle in SwiftUI with

.onOpenURL
and
.onContinueUserActivity
:

@main
struct MyApp: App {
    @State private var router = Router()

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(router)
                .onOpenURL { url in router.handle(url: url) }
                .onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
                    guard let url = activity.webpageURL else { return }
                    router.handle(url: url)
                }
        }
    }
}

Custom URL Schemes

Register schemes in

Info.plist
under
CFBundleURLTypes
. Handle with
.onOpenURL
. Prefer universal links over custom schemes for publicly shared links -- they provide web fallback and domain verification.

Handoff (NSUserActivity)

Advertise activities with

.userActivity()
and receive them with
.onContinueUserActivity()
. Declare activity types in
Info.plist
under
NSUserActivityTypes
. Set
isEligibleForHandoff = true
and provide a
webpageURL
as fallback.

See references/deeplinks.md for full examples of AASA configuration, router URL handling, custom URL schemes, and NSUserActivity continuation.

Common Mistakes

  1. Using deprecated
    NavigationView
    -- use
    NavigationStack
    or
    NavigationSplitView
  2. Sharing one
    NavigationPath
    across all tabs -- each tab needs its own path
  3. Using
    .sheet(isPresented:)
    when state represents a model -- use
    .sheet(item:)
    instead
  4. Storing view instances in
    NavigationPath
    -- store lightweight
    Hashable
    route data
  5. Nesting
    @Observable
    router objects inside other
    @Observable
    objects
  6. Prefer
    Tab(value:)
    with
    TabView(selection:)
    over the older
    .tabItem { }
    API
  7. Assuming
    tabBarMinimizeBehavior
    works on iPad -- it is iPhone only
  8. Handling deep links in multiple places -- centralize URL parsing in the router
  9. Hard-coding sheet frame dimensions -- use
    .presentationSizing(.form)
    instead
  10. Missing
    @MainActor
    on router classes -- required for Swift 6 concurrency safety

Review Checklist

  • NavigationStack
    used (not
    NavigationView
    )
  • Each tab has its own
    NavigationStack
    with independent path
  • Route enum is
    Hashable
    with stable identifiers
  • .navigationDestination(for:)
    maps all route types
  • .sheet(item:)
    preferred over
    .sheet(isPresented:)
  • Sheets own their dismiss logic internally
  • Router object is
    @MainActor
    and
    @Observable
  • Deep link URLs parsed and validated before navigation
  • Universal links have AASA and Associated Domains configured
  • Tab selection uses
    Tab(value:)
    with binding

References