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:
- Configures a set of allowed detents and binds the selection to local state.
- Picks one detent (
tresholdDetent) as a threshold that means “the user tried to go past this point”. - 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.
- 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 ViewYou 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 supportdefaultDetent: where the sheet starts when presentedtresholdDetent: 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 currentDetentprivate let tresholdprivate let detentsprivate 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.
