Intercepting SwiftUI Sheet Dismissal


Greetings, traveler!

SwiftUI sheets are great until you need one specific behavior: react when the user tries to dismiss the sheet interactively.

You can disable interactive dismissal with .interactiveDismissDisabled(), and you can provide a “Done” button, but there’s still a common UX gap: the user drags the sheet down, expects it to close, and you want to stop them and ask for confirmation (for example, “Discard changes?”).

The code below implements a clean workaround using presentation detents as a “dismissal threshold”. It doesn’t try to detect the drag gesture directly. Instead, it watches the sheet’s detent selection and treats one detent as a signal that the user is attempting to close.

The core idea

A sheet with detents has a current “height state” represented by PresentationDetent. When the user drags the sheet, SwiftUI updates the selected detent.

This implementation:

  1. Configures a set of allowed detents and binds the selection to local state.
  2. Picks one detent (tresholdDetent) as a threshold that means “the user tried to go past this point”.
  3. When the selection reaches the threshold, it:
    • immediately restores the previous detent
    • calls a closure (onIntercept) so you can show an alert, save draft state, log analytics, etc.
  4. Disables system interactive dismissal to ensure the sheet won’t close behind your back.

The result is a sheet that feels dismissible, but becomes “sticky” at a chosen detent and gives you a reliable interception point.

Public API: a View modifier

The entry point is a small View extension:

func dismissInterceptor(
    config: DismissInterceptorConfig,
    onIntercept: @escaping () -> Void
) -> some View

You apply it inside the sheet content, not on the presenting view. That’s important because the modifier configures .presentationDetents(...) and the state that tracks the current detent must live in the presented content.

The API is straightforward: you pass a configuration and a closure to run when interception happens.

Configuration object: keeping the modifier ergonomic

DismissInterceptorConfig is a tiny type that describes three things:

  • detents: the full set of detents the sheet should support
  • defaultDetent: where the sheet starts when presented
  • tresholdDetent: the detent that acts as the interception trigger

The initializer ensures correctness by forcing both the threshold and the default detent to be included in the available detents:

public struct DismissInterceptorConfig {
    /// The full set of detents available to the sheet, including the threshold and default detents.
    let detents: Set<PresentationDetent>
    /// The detent the sheet should start at when presented.
    let defaultDetent: PresentationDetent
    /// The detent that acts as the dismissal threshold to intercept at.
    let tresholdDetent: PresentationDetent
    
    /// Creates a new configuration.
    ///
    /// - Parameters:
    ///   - tresholdDetent: The detent that acts as the interception threshold. When the
    ///     sheet is dragged to this detent, the attempt is intercepted and `onIntercept`
    ///     is invoked.
    ///   - defaultDetent: The detent that should be selected by default when the sheet appears.
    ///   - other: Additional detents to make available alongside the threshold and default detents.
    public init(
        tresholdDetent: PresentationDetent,
        defaultDetent: PresentationDetent,
        other: Set<PresentationDetent>
    ) {
        self.detents = other.union(Set([tresholdDetent, defaultDetent]))
        self.defaultDetent = defaultDetent
        self.tresholdDetent = tresholdDetent
    }
}

That prevents a class of mistakes where the modifier is configured with a threshold that isn’t actually part of the sheet’s detents (which would make interception impossible).

The modifier: detent tracking and interception

DismissInterceptorModifier is where the behavior lives.

1) Tracking the detent selection

The modifier owns:

  • @State private var currentDetent
  • private let treshold
  • private let detents
  • private let onIntercept

It applies:

.presentationDetents(detents, selection: $currentDetent)

Binding selection is the key: it gives you a value SwiftUI updates whenever the user drags the sheet.

2) Detecting the “dismiss attempt”

The implementation also uses a compatibility helper onChangeCompat, which backports the onChange(of:old:new:) API described in this article.

.onChangeCompat(of: currentDetent) { oldValue, newValue in
    if newValue == treshold {
        currentDetent = oldValue
        Task { onIntercept() }
    }
}

When the user drags the sheet and the detent becomes the threshold:

  • it restores the previous detent (currentDetent = oldValue) so the sheet visually snaps back
  • it triggers your interception closure

A small detail: the closure is executed inside Task { ... }. That avoids doing UI work re-entrantly inside the state change callback and plays nicer with SwiftUI’s update cycle (especially if your interception toggles state for an alert).

3) Preventing “real” interactive dismissal

Finally, the modifier sets:

.interactiveDismissDisabled()

This disables the system swipe-to-dismiss behavior. Combined with the detent logic, you now fully control when the sheet actually closes (typically by toggling the isPresented binding from the presenting view).

private struct DismissInterceptorModifier: ViewModifier {
    
    @State private var currentDetent = PresentationDetent.large
    private let treshold: PresentationDetent
    private let detents: Set<PresentationDetent>
    private let onIntercept: () -> Void
    
    init(
        config: DismissInterceptorConfig,
        onIntercept: @escaping () -> Void
    ) {
        self.currentDetent = config.defaultDetent
        self.treshold = config.tresholdDetent
        self.detents = config.detents
        self.onIntercept = onIntercept
    }
    
    func body(content: Content) -> some View {
        content
            .presentationDetents(detents, selection: $currentDetent)
            .onChangeCompat(of: currentDetent) { oldValue, newValue in
                if newValue == treshold {
                    currentDetent = oldValue
                    Task {
                        onIntercept()
                    }
                }
            }
            .interactiveDismissDisabled()
    }
}

Example behavior in practice

In the sample, the sheet has three detents:

  • .large (default)
  • .medium
  • .height(300) as the threshold

When the user drags the sheet down and hits .height(300), you toggle isAlertPresented and show a confirmation alert. If the user confirms, you toggle isSheetPresented to close the sheet.

This pattern maps well to “unsaved changes” flows:

  • editing a form
  • composing a message
  • adjusting settings that must be saved explicitly
  • multi-step onboarding where leaving mid-way needs confirmation
import SwiftUI

struct DismissInterceptorExampleView: View {
    @State private var isSheetPresented = false
    @State private var isAlertPresented = false

    var body: some View {
        VStack(spacing: 16) {
            Button("Present Sheet") {
                isSheetPresented = true
            }
        }
        .sheet(isPresented: $isSheetPresented) {
            Text("Pull me down to intercept")
                .dismissInterceptor(
                    config: DismissInterceptorConfig(
                        tresholdDetent: .height(300),
                        defaultDetent: .large,
                        other: [.medium]
                    )
                ) {
                    isAlertPresented.toggle()
                }
                .alert("Are you sure?", isPresented: $isAlertPresented) {
                    Button("Cancel") {}
                    Button("OK") {
                        isSheetPresented.toggle()
                    }
                }
        }
    }
}

Conclusion

This code turns a SwiftUI sheet into a controlled surface that can intercept dismissal attempts without fighting gestures.

It’s a practical workaround for a real SwiftUI limitation, and it fits nicely into a reusable library because it’s implemented as a small, composable ViewModifier.

The code is available as a Swift Package on my GitHub.