@@ -11,6 +11,20 @@ public struct BottomSheet<Content: View>: View {
1111 public var title : String = " "
1212 @Binding public var showBackButton : Bool
1313 public var onBackButtonPressed : ( ( ) -> Void ) ? = nil
14+ /// Whether the sheet may dismiss itself: blocks the interactive swipe, and blocks the
15+ /// close button's default `dismiss()`. It does not silence `onClose` — a host that took
16+ /// the close action over stays in charge of it, which is what makes "block the swipe but
17+ /// ask before closing" expressible. Use `isCloseButtonEnabled` to disable the button too.
18+ @Binding public var isDismissalEnabled : Bool
19+ public var showsCloseButton : Bool = true
20+ /// Whether the close button accepts taps. It also goes inert on its own when it would have
21+ /// nothing left to do — dismissal disabled and no `onClose` to run.
22+ public var isCloseButtonEnabled : Bool = true
23+ /// Overrides the close button's action; the callback is then responsible for dismissing the
24+ /// sheet. It covers the **button only** — an interactive swipe dismisses the sheet without
25+ /// calling it, so a host that needs to hear about every dismissal should also pass
26+ /// `onDismiss:` to the presenting `.sheet`.
27+ public var onClose : ( ( ) -> Void ) ? = nil
1428 /// `true` (default) — greedy: content fills the sheet (use with an explicit detent or a
1529 /// `.large`/`.medium` detent). `false` — natural height: pair with `.selfSizingSheet()` so
1630 /// the sheet snaps to its content. Prefer `BottomSheet.selfSizing(...)` as the entry point
@@ -28,13 +42,21 @@ public struct BottomSheet<Content: View>: View {
2842 title: String = " " ,
2943 showBackButton: Binding < Bool > ,
3044 onBackButtonPressed: ( ( ) -> Void ) ? = nil ,
45+ isDismissalEnabled: Binding < Bool > = . constant( true ) ,
46+ showsCloseButton: Bool = true ,
47+ isCloseButtonEnabled: Bool = true ,
48+ onClose: ( ( ) -> Void ) ? = nil ,
3149 fillsHeight: Bool = true ,
3250 background: Color = . dash. primaryBackground,
3351 @ViewBuilder content: @escaping ( ) -> Content
3452 ) {
3553 self . title = title
3654 self . _showBackButton = showBackButton
3755 self . onBackButtonPressed = onBackButtonPressed
56+ self . _isDismissalEnabled = isDismissalEnabled
57+ self . showsCloseButton = showsCloseButton
58+ self . isCloseButtonEnabled = isCloseButtonEnabled
59+ self . onClose = onClose
3860 self . fillsHeight = fillsHeight
3961 self . background = background
4062 self . content = content
@@ -51,28 +73,31 @@ public struct BottomSheet<Content: View>: View {
5173 }
5274 . background ( background)
5375
54- if fillsHeight {
55- sheet. edgesIgnoringSafeArea ( . bottom)
56- } else {
57- // Publish the natural content height for `.selfSizingSheet()`. The bottom safe area is
58- // intentionally NOT ignored here, so the measured height excludes the home-indicator
59- // inset — `.presentationDetents([.height])` adds that inset itself.
60- //
61- // `.fixedSize(vertical:)` is critical: it makes the sheet report its *ideal* height
62- // independent of the height the sheet currently offers. Without it the measurement is
63- // coupled to the detent (detent <- measured <- offered height <- detent), so it ping-pongs
64- // by ~the safe-area inset and the presenting view (HomeView) jitters up/down.
65- sheet
66- . fixedSize ( horizontal: false , vertical: true )
67- . background (
68- GeometryReader { proxy in
69- Color . clear. preference (
70- key: BottomSheetHeightPreferenceKey . self,
71- value: proxy. size. height
72- )
73- }
74- )
76+ Group {
77+ if fillsHeight {
78+ sheet. edgesIgnoringSafeArea ( . bottom)
79+ } else {
80+ // Publish the natural content height for `.selfSizingSheet()`. The bottom safe area is
81+ // intentionally NOT ignored here, so the measured height excludes the home-indicator
82+ // inset — `.presentationDetents([.height])` adds that inset itself.
83+ //
84+ // `.fixedSize(vertical:)` is critical: it makes the sheet report its *ideal* height
85+ // independent of the height the sheet currently offers. Without it the measurement is
86+ // coupled to the detent (detent <- measured <- offered height <- detent), so it ping-pongs
87+ // by ~the safe-area inset and the presenting view (HomeView) jitters up/down.
88+ sheet
89+ . fixedSize ( horizontal: false , vertical: true )
90+ . background (
91+ GeometryReader { proxy in
92+ Color . clear. preference (
93+ key: BottomSheetHeightPreferenceKey . self,
94+ value: proxy. size. height
95+ )
96+ }
97+ )
98+ }
7599 }
100+ . modifier ( BottomSheetDismissalModifier ( isEnabled: isDismissalEnabled) )
76101 }
77102
78103 private var grabber : some View {
@@ -83,6 +108,13 @@ public struct BottomSheet<Content: View>: View {
83108 . cornerRadius ( 5 )
84109 }
85110
111+ private var isCloseButtonActive : Bool {
112+ BottomSheetDismissalAction . isCloseButtonActive (
113+ isCloseButtonEnabled: isCloseButtonEnabled,
114+ isDismissalEnabled: isDismissalEnabled,
115+ hasCustomCloseAction: onClose != nil )
116+ }
117+
86118 private var header : some View {
87119 NavigationBar (
88120 leading: {
@@ -96,7 +128,17 @@ public struct BottomSheet<Content: View>: View {
96128 . foregroundColor ( . dash. primaryText)
97129 } ,
98130 trailing: {
99- NavigationBarElement . close. button { presentationMode. wrappedValue. dismiss ( ) }
131+ if showsCloseButton {
132+ NavigationBarElement . close. button {
133+ BottomSheetDismissalAction . perform (
134+ isDismissalEnabled: isDismissalEnabled,
135+ onClose: onClose,
136+ dismiss: { presentationMode. wrappedValue. dismiss ( ) }
137+ )
138+ }
139+ . disabled ( !isCloseButtonActive)
140+ . opacity ( isCloseButtonActive ? 1 : 0.35 )
141+ }
100142 }
101143 )
102144 }
@@ -139,6 +181,10 @@ public extension BottomSheet {
139181 title: String = " " ,
140182 showBackButton: Binding < Bool > ,
141183 onBackButtonPressed: ( ( ) -> Void ) ? = nil ,
184+ isDismissalEnabled: Binding < Bool > = . constant( true ) ,
185+ showsCloseButton: Bool = true ,
186+ isCloseButtonEnabled: Bool = true ,
187+ onClose: ( ( ) -> Void ) ? = nil ,
142188 fallback: CGFloat = 0 ,
143189 maxHeightFraction: CGFloat = 0.95 ,
144190 background: Color = . dash. primaryBackground,
@@ -149,6 +195,10 @@ public extension BottomSheet {
149195 title: title,
150196 showBackButton: showBackButton,
151197 onBackButtonPressed: onBackButtonPressed,
198+ isDismissalEnabled: isDismissalEnabled,
199+ showsCloseButton: showsCloseButton,
200+ isCloseButtonEnabled: isCloseButtonEnabled,
201+ onClose: onClose,
152202 fillsHeight: false ,
153203 background: background,
154204 content: content
@@ -161,6 +211,119 @@ public extension BottomSheet {
161211 }
162212}
163213
214+ @available ( iOS 14 , macOS 11 , * )
215+ enum BottomSheetDismissalAction {
216+ /// The button is live while it still has something to do. Blocking dismissal only
217+ /// takes away what the sheet itself owns — the `dismiss()` it would call — so a host
218+ /// that supplied `onClose` keeps its action, and a sheet can block the swipe while
219+ /// still answering the close button with a confirmation. `isCloseButtonEnabled`
220+ /// remains the way to take the button away outright.
221+ static func isCloseButtonActive(
222+ isCloseButtonEnabled: Bool ,
223+ isDismissalEnabled: Bool ,
224+ hasCustomCloseAction: Bool
225+ ) -> Bool {
226+ isCloseButtonEnabled && ( isDismissalEnabled || hasCustomCloseAction)
227+ }
228+
229+ static func perform( isDismissalEnabled: Bool , onClose: ( ( ) -> Void ) ? , dismiss: ( ) -> Void ) {
230+ if let onClose {
231+ onClose ( )
232+ } else if isDismissalEnabled {
233+ dismiss ( )
234+ }
235+ }
236+ }
237+
238+ @available ( iOS 14 , macOS 11 , * )
239+ private struct BottomSheetDismissalModifier : ViewModifier {
240+ let isEnabled : Bool
241+
242+ // The branch is on `#available` alone, never on `isEnabled`. A `@ViewBuilder`
243+ // if/else produces `_ConditionalContent`, and the two branches are different
244+ // views to SwiftUI: switching between them tears the sheet down and rebuilds
245+ // it, taking every piece of `@State` the host keeps inside `content()` with
246+ // it — a half-typed field, the scroll position, the keyboard. `#available`
247+ // cannot flip while the app runs, so this branch is decided once and the
248+ // sheet keeps one identity for as long as it is on screen.
249+ @ViewBuilder
250+ func body( content: Content ) -> some View {
251+ if #available( iOS 15 , macOS 12 , * ) {
252+ content. interactiveDismissDisabled ( !isEnabled)
253+ } else {
254+ content. modifier ( LegacyInteractiveDismissModifier ( isDismissDisabled: !isEnabled) )
255+ }
256+ }
257+ }
258+
259+ #if canImport(UIKit)
260+
261+ /// `interactiveDismissDisabled` is iOS 15, and this library ships to 14. The flag
262+ /// it sets underneath — `UIViewController.isModalInPresentation` — is iOS 13, so
263+ /// the older systems can be given the same protection rather than none at all.
264+ @available ( iOS 14 , macOS 11 , * )
265+ private struct LegacyInteractiveDismissModifier : ViewModifier {
266+ let isDismissDisabled : Bool
267+
268+ func body( content: Content ) -> some View {
269+ content. background (
270+ ModalInPresentationSetter ( isModal: isDismissDisabled)
271+ . frame ( width: 0 , height: 0 )
272+ )
273+ }
274+ }
275+
276+ @available ( iOS 14 , macOS 11 , * )
277+ private struct ModalInPresentationSetter : UIViewControllerRepresentable {
278+ let isModal : Bool
279+
280+ func makeUIViewController( context: Context ) -> Controller {
281+ Controller ( )
282+ }
283+
284+ func updateUIViewController( _ controller: Controller , context: Context ) {
285+ controller. isModal = isModal
286+ }
287+
288+ final class Controller : UIViewController {
289+ var isModal = false {
290+ didSet { applyToPresentedController ( ) }
291+ }
292+
293+ override func didMove( toParent parent: UIViewController ? ) {
294+ super. didMove ( toParent: parent)
295+ applyToPresentedController ( )
296+ }
297+
298+ override func viewWillAppear( _ animated: Bool ) {
299+ super. viewWillAppear ( animated)
300+ applyToPresentedController ( )
301+ }
302+
303+ /// The swipe belongs to the controller that was actually presented, not to
304+ /// this one: the representable sits in a background deep inside the sheet's
305+ /// hosting controller, so walk up to the top of the containment chain.
306+ private func applyToPresentedController( ) {
307+ var controller : UIViewController = self
308+ while let parent = controller. parent {
309+ controller = parent
310+ }
311+ controller. isModalInPresentation = isModal
312+ }
313+ }
314+ }
315+
316+ #else
317+
318+ @available ( iOS 14 , macOS 11 , * )
319+ private struct LegacyInteractiveDismissModifier : ViewModifier {
320+ let isDismissDisabled : Bool
321+
322+ func body( content: Content ) -> some View { content }
323+ }
324+
325+ #endif
326+
164327@available ( iOS 14 , macOS 11 , * )
165328public extension View {
166329 /// Sizes a `BottomSheet` (built with `fillsHeight: false`) to its content's natural height —
@@ -339,4 +502,59 @@ private struct SelfSizingSheetModifier: ViewModifier {
339502 }
340503}
341504
505+ @available ( iOS 17 , macOS 14 , * )
506+ #Preview( " BottomSheet Dismissal States " ) {
507+ VStack ( spacing: 12 ) {
508+ BottomSheet (
509+ title: " Dismissal enabled " ,
510+ showBackButton: . constant( false ) ,
511+ isDismissalEnabled: . constant( true ) ,
512+ fillsHeight: false
513+ ) {
514+ Text ( " Swipe or use the close button. " )
515+ . dashFont ( . body)
516+ . foregroundColor ( . dash. secondaryText)
517+ . padding ( )
518+ }
519+
520+ BottomSheet (
521+ title: " Dismissal disabled " ,
522+ showBackButton: . constant( false ) ,
523+ isDismissalEnabled: . constant( false ) ,
524+ fillsHeight: false
525+ ) {
526+ Text ( " The dimmed close button and swipe are disabled. " )
527+ . dashFont ( . body)
528+ . foregroundColor ( . dash. secondaryText)
529+ . padding ( )
530+ }
531+
532+ BottomSheet (
533+ title: " Swipe blocked, close confirms " ,
534+ showBackButton: . constant( false ) ,
535+ isDismissalEnabled: . constant( false ) ,
536+ onClose: { /* host shows a "discard changes?" alert */ } ,
537+ fillsHeight: false
538+ ) {
539+ Text ( " The swipe is blocked, but the close button still reaches the host. " )
540+ . dashFont ( . body)
541+ . foregroundColor ( . dash. secondaryText)
542+ . padding ( )
543+ }
544+
545+ BottomSheet (
546+ title: " Close hidden " ,
547+ showBackButton: . constant( false ) ,
548+ showsCloseButton: false ,
549+ fillsHeight: false
550+ ) {
551+ Text ( " The host intentionally provides no close control. " )
552+ . dashFont ( . body)
553+ . foregroundColor ( . dash. secondaryText)
554+ . padding ( )
555+ }
556+ }
557+ . background ( Color . dash. primaryBackground)
558+ }
559+
342560#endif
0 commit comments