# Sheet Routing

Part of the `swiftui-navigation` skill. Routing every modal through one place
lets any view open a sheet without threading bindings through its parents, and
it keeps working as screens are added.

## Building blocks

- A `SheetDestination` enum, `Identifiable`, with one case per modal.
- `presentedSheet: SheetDestination?` stored on the router.
- A modifier, here `withSheetDestinations(sheet:)`, that turns a case into its
  view.
- The router in the environment, so children assign `presentedSheet` directly.

## The destination enum

```swift
enum SheetDestination: Hashable, Identifiable {
    case newTrip
    case editTrip(Trip.ID)
    case sharePhoto(Photo.ID)
    case report(itemID: String)

    var id: String {
        if case .sharePhoto = self { return "share" }
        if case .report = self { return "report" }
        return "trip-editor"
    }
}
```

`newTrip` and `editTrip` return the same `id`. They are two ways into one
editor, so they can never be on screen together.

## The mapping modifier

```swift
extension View {
    func withSheetDestinations(sheet: Binding<SheetDestination?>) -> some View {
        self.sheet(item: sheet) { modal in
            Group {
                switch modal {
                case .newTrip: TripEditor(trip: nil)
                case .editTrip(let id): TripEditor(tripID: id)
                case .sharePhoto(let id): PhotoShareView(photoID: id)
                case .report(let itemID): ReportForm(itemID: itemID)
                }
            }
        }
    }
}
```

## Opening a sheet from deep in the tree

```swift
struct CommentRow: View {
    @Environment(Router.self) private var router
    let comment: Comment

    var body: some View {
        Text(comment.body)
            .contextMenu {
                Button("Report", systemImage: "flag") {
                    router.presentedSheet = .report(itemID: comment.id)
                }
            }
    }
}
```

## Wiring at the root

1. The parent owns the router instance.
2. It installs `withSheetDestinations(sheet: $router.presentedSheet)`; a plain
   `sheet(item:)` does the same job.
3. `.environment(router)` comes later in the chain than the sheet modifier, so
   whatever the sheet shows can read the router as well.

```swift
struct TripsRoot: View {
    @State private var router = Router()

    var body: some View {
        NavigationStack(path: $router.path) { TripsList().withAppRouter() }
            .withSheetDestinations(sheet: $router.presentedSheet)
            .environment(router)
    }
}
```

With that in place, a child that sets `presentedSheet` makes the root present
the sheet.

## Sheets that push their own screens

Wrap the content in a stack with a close button when a sheet needs its own
navigation.

```swift
struct NavigationSheet<Content: View>: View {
    @ViewBuilder var content: Content
    @Environment(\.dismiss) private var close

    var body: some View {
        NavigationStack {
            content
                .toolbar {
                    ToolbarItem(placement: .cancellationAction) {
                        Button("Close", systemImage: "xmark") { close() }
                    }
                }
        }
    }
}
```

## Rules worth keeping

- Route sheets centrally so features do not pass bindings through layers.
- `sheet(item:)` keeps exactly one sheet active, chosen by the enum.
- Mutually exclusive sheets share an `id`.
- Sheet views stay small and composed from pieces.

## Presentation sizing

The section concerns iOS 26 layouts, but `presentationSizing(_:)` itself is
available from iOS 18. Values: `.automatic`, `.page`, `.form`, `.fitted`.
`.fitted(horizontal:vertical:)` fits only the axes you pass, and
`.sticky(horizontal:vertical:)` lets the sheet grow along them without
shrinking.

```swift
TripEditor(trip: nil)
    .presentationSizing(.form.sticky(horizontal: false, vertical: true))
```

## Confirming dismissal

On iOS, block the swipe with `.interactiveDismissDisabled(_:)` while there are
unsaved edits, and ask from your own Close button with `confirmationDialog`:

```swift
struct TripEditorSheet: View {
    let id: Trip.ID
    let hasEdits: Bool
    let draft: Draft
    @State private var confirmsDiscard = false
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            TripEditor(tripID: id)
                .toolbar {
                    ToolbarItem(placement: .cancellationAction) {
                        Button("Close", systemImage: "xmark") {
                            if hasEdits { confirmsDiscard = true } else { dismiss() }
                        }
                        .confirmationDialog("Throw away your edits?", isPresented: $confirmsDiscard) {
                            Button("Throw Away", role: .destructive) {
                                draft.revert()
                                dismiss()
                            }
                            Button("Save and Close") {
                                draft.save()
                                dismiss()
                            }
                        }
                    }
                }
        }
        .interactiveDismissDisabled(hasEdits)
    }
}
```

On macOS 15 and later, `dismissalConfirmationDialog(_:shouldPresent:)` does
this for you. The SDK marks it unavailable on iOS, iPadOS, tvOS, watchOS and
visionOS, so it only compiles in a macOS target:

```swift
TripEditor(tripID: id)
    .dismissalConfirmationDialog("Throw away your edits?", shouldPresent: hasEdits) {
        Button("Throw Away", role: .destructive) { draft.revert() }
        Button("Save and Close") { draft.save() }
            .keyboardShortcut(.defaultAction)
    }
```

The system adds Cancel on its own, and Cancel keeps the sheet open. Any other
button lets the dismissal go ahead. `.keyboardShortcut(.defaultAction)` marks
the default choice.

## Pitfalls

- Two presentation styles, `sheet(isPresented:)` plus `sheet(item:)`, for one
  concern. A single enum is enough.
- Carrying heavy state inside `SheetDestination`. Pass identifiers or small
  models.
- Reusing an `id` for sheets that may legitimately come from the same screen
  one after another; give them distinct values.
- Fixed frames instead of `presentationSizing(.form)`.
- A dismissal dialog without a real `shouldPresent` condition. Asking when
  nothing changed only confuses people.
