Skip to content

Commit 0fe7fcd

Browse files
authored
Merge pull request #14 from PastaPastaPasta/feat/bottom-sheet-dismissal-control
feat(bottom-sheet): add dismissal controls
2 parents 26f3a34 + 3b5ed0b commit 0fe7fcd

3 files changed

Lines changed: 384 additions & 22 deletions

File tree

‎Sources/DashUIKit/Components/BottomSheet/BottomSheet.swift‎

Lines changed: 240 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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, *)
165328
public 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

Comments
 (0)