All skills
dpearson2699 avatar

/swiftui-navigation

@cf3fe87

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.

Use this Skill: https://skilld.dev/gh/dpearson2699/swift-ios-skills/swiftui-navigation

This session only. Nothing lands on disk.

referencessheets.md

≈1.5k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Sheets

Contents

Intent

Use a centralized sheet routing pattern so any view can present modals without prop-drilling. This keeps sheet state in one place and scales as the app grows.

Core architecture

  • Define a SheetDestination enum that describes every modal and is Identifiable.
  • Store the current sheet in a router object (presentedSheet: SheetDestination?).
  • Create a view modifier like withSheetDestinations(...) that maps the enum to concrete sheet views.
  • Inject the router into the environment so child views can set presentedSheet directly.

Example: SheetDestination enum

enum SheetDestination: Identifiable, Hashable {
  case composer
  case editProfile
  case settings
  case report(itemID: String)

  var id: String {
    switch self {
    case .composer, .editProfile:
      // Use the same id to ensure only one editor-like sheet is active at a time.
      return "editor"
    case .settings:
      return "settings"
    case .report:
      return "report"
    }
  }
}

Example: withSheetDestinations modifier

extension View {
  func withSheetDestinations(
    sheet: Binding<SheetDestination?>
  ) -> some View {
    sheet(item: sheet) { destination in
      Group {
        switch destination {
        case .composer:
          ComposerView()
        case .editProfile:
          EditProfileView()
        case .settings:
          SettingsView()
        case .report(let itemID):
          ReportView(itemID: itemID)
        }
      }
    }
  }
}

Example: presenting from a child view

struct StatusRow: View {
  @Environment(RouterPath.self) private var router

  var body: some View {
    Button("Report") {
      router.presentedSheet = .report(itemID: "123")
    }
  }
}

Required wiring

For the child view to work, a parent view must:

  • own the router instance,
  • attach withSheetDestinations(sheet: $router.presentedSheet) (or an equivalent sheet(item:) handler), and
  • inject it with .environment(router) after the sheet modifier so the modal content inherits it.

This makes the child assignment to router.presentedSheet drive presentation at the root.

Example: sheets that need their own navigation

Wrap sheet content in a NavigationStack so it can push within the modal.

struct NavigationSheet<Content: View>: View {
  var content: () -> Content

  var body: some View {
    NavigationStack {
      content()
        .toolbar { CloseToolbarItem() }
    }
  }
}

Design choices to keep

  • Centralize sheet routing so features can present modals without wiring bindings through many layers.
  • Use sheet(item:) to guarantee a single sheet is active and to drive presentation from the enum.
  • Group related sheets under the same id when they are mutually exclusive (e.g., editor flows).
  • Keep sheet views lightweight and composed from smaller views; avoid large monoliths.

iOS 26 Presentation Sizing

Control sheet dimensions with presentationSizing(_:) (iOS 18+):

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

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

Modifier methods for fine-tuning:

  • .fitted(horizontal:vertical:) -- constrain fitting to specific axes
  • .sticky(horizontal:vertical:) -- grow but do not shrink in specified dimensions

Dismissal Protection

On iOS/iPadOS, prevent gesture dismissal while unsaved changes exist and expose explicit Save/Discard actions inside the sheet:

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

interactiveDismissDisabled covers interactive dismissal. Route toolbar buttons and other programmatic close paths through the same validation/save or explicit-discard decision before calling dismiss().

On macOS 15+, show a confirmation dialog when the user tries to dismiss a sheet with unsaved changes:

.sheet(item: $selectedItem) { item in
    EditItemSheet(item: item)
        .dismissalConfirmationDialog(
            "Discard changes?",
            shouldPresent: hasUnsavedChanges
        ) {
            Button("Discard", role: .destructive) { discardChanges() }
        }
}
  • For dismissalConfirmationDialog, the Cancel action is included automatically and prevents dismissal.
  • All other action buttons allow dismissal to proceed.
  • Use .keyboardShortcut(.defaultAction) to set the default button

Pitfalls

  • Avoid mixing sheet(isPresented:) and sheet(item:) for the same concern; prefer a single enum.
  • Do not store heavy state inside SheetDestination; pass lightweight identifiers or models.
  • If multiple sheets can appear from the same screen, give them distinct id values.
  • Use presentationSizing(.form) for form sheets instead of hard-coding frame dimensions.
  • Use interactiveDismissDisabled(_:) for iOS/iPadOS dismissal prevention.
  • Always pair dismissalConfirmationDialog with a shouldPresent condition on macOS; showing it when there are no changes is confusing.

Source: SKILL.md on GitHub

No alerts17d4 checks · Risk SAFE
  • Gen Agent Trust Hub17d

    The skill provides comprehensive documentation and development patterns for SwiftUI navigation targeting iOS 26 and Swift 6.3. It includes best practices for NavigationStack, sheets, and deep links without any security violations.

  • Socket17d

    No alerts

  • Snyk17d

    Risk: LOW · No issues

  • ZeroLeaks5mo

    Score: 93/100 · 2 sections analyzed

Signed by skilld at cf3fe87. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 days ago.

Steadyupdated 3 months ago
  • swiftui
  • ios
  • navigation
  • routing
  • deep-linking
  • tab-navigation
  • swift
  • sheet-presentation

README badge

README badge for dpearson2699/swift-ios-skills/swiftui-navigation

Implement SwiftUI navigation patterns for iOS 17+ including NavigationStack for push navigation, NavigationSplitView for multi-column layouts, sheet presentation, tab-based navigation with the Tab API, and deep linking via universal links and custom URL schemes. Includes router patterns for programmatic routing and centralized sheet/destination mapping.

Generated from the current SKILL.md.

Does this skill work with iOS versions before iOS 26?
Most patterns are backward-compatible to iOS 17. NavigationStack, NavigationSplitView, sheet presentation, and tab-based navigation work on iOS 17+. iOS 26-specific features like Tab(role: .search), .presentationSizing, and .dismissalConfirmationDialog are noted separately in the skill.
How do I handle deep linking with custom URL schemes?
Register schemes in Info.plist under CFBundleURLTypes, then parse and route URLs in a centralized router object using .onOpenURL. The skill recommends universal links over custom schemes for public links because they provide web fallback and domain verification.
Should each tab have its own NavigationStack?
Yes. Each tab must have an independent NavigationStack with its own NavigationPath to avoid sharing navigation state across tabs, which is a common mistake noted in the skill.
What's the difference between .sheet(item:) and .sheet(isPresented:)?
Use .sheet(item:) when state represents a selected model, and .sheet(isPresented:) for simple boolean flags. The skill prefers .sheet(item:) because it binds to actual data rather than a presentation flag.

Generated from the current SKILL.md. These answers refresh after source changes.