-
Notifications
You must be signed in to change notification settings - Fork 1
feat(bottom-sheet): add dismissal controls #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,12 @@ public struct BottomSheet<Content: View>: View { | |
| public var title: String = "" | ||
| @Binding public var showBackButton: Bool | ||
| public var onBackButtonPressed: (() -> Void)? = nil | ||
| /// Controls every dismissal affordance owned by the sheet. When `false`, the close button is | ||
| /// disabled and interactive dismissal is blocked on iOS 15+ / macOS 12+. | ||
| @Binding public var isDismissalEnabled: Bool | ||
| public var showsCloseButton: Bool = true | ||
| /// Overrides the close button action. The callback is responsible for dismissing the sheet. | ||
| public var onClose: (() -> Void)? = nil | ||
| /// `true` (default) — greedy: content fills the sheet (use with an explicit detent or a | ||
| /// `.large`/`.medium` detent). `false` — natural height: pair with `.selfSizingSheet()` so | ||
| /// the sheet snaps to its content. Prefer `BottomSheet.selfSizing(...)` as the entry point | ||
|
|
@@ -28,13 +34,19 @@ public struct BottomSheet<Content: View>: View { | |
| title: String = "", | ||
| showBackButton: Binding<Bool>, | ||
| onBackButtonPressed: (() -> Void)? = nil, | ||
| isDismissalEnabled: Binding<Bool> = .constant(true), | ||
| showsCloseButton: Bool = true, | ||
| onClose: (() -> Void)? = nil, | ||
| fillsHeight: Bool = true, | ||
| background: Color = .dash.primaryBackground, | ||
| @ViewBuilder content: @escaping () -> Content | ||
| ) { | ||
| self.title = title | ||
| self._showBackButton = showBackButton | ||
| self.onBackButtonPressed = onBackButtonPressed | ||
| self._isDismissalEnabled = isDismissalEnabled | ||
| self.showsCloseButton = showsCloseButton | ||
| self.onClose = onClose | ||
| self.fillsHeight = fillsHeight | ||
| self.background = background | ||
| self.content = content | ||
|
|
@@ -51,28 +63,31 @@ public struct BottomSheet<Content: View>: View { | |
| } | ||
| .background(background) | ||
|
|
||
| if fillsHeight { | ||
| sheet.edgesIgnoringSafeArea(.bottom) | ||
| } else { | ||
| // Publish the natural content height for `.selfSizingSheet()`. The bottom safe area is | ||
| // intentionally NOT ignored here, so the measured height excludes the home-indicator | ||
| // inset — `.presentationDetents([.height])` adds that inset itself. | ||
| // | ||
| // `.fixedSize(vertical:)` is critical: it makes the sheet report its *ideal* height | ||
| // independent of the height the sheet currently offers. Without it the measurement is | ||
| // coupled to the detent (detent <- measured <- offered height <- detent), so it ping-pongs | ||
| // by ~the safe-area inset and the presenting view (HomeView) jitters up/down. | ||
| sheet | ||
| .fixedSize(horizontal: false, vertical: true) | ||
| .background( | ||
| GeometryReader { proxy in | ||
| Color.clear.preference( | ||
| key: BottomSheetHeightPreferenceKey.self, | ||
| value: proxy.size.height | ||
| ) | ||
| } | ||
| ) | ||
| Group { | ||
| if fillsHeight { | ||
| sheet.edgesIgnoringSafeArea(.bottom) | ||
| } else { | ||
| // Publish the natural content height for `.selfSizingSheet()`. The bottom safe area is | ||
| // intentionally NOT ignored here, so the measured height excludes the home-indicator | ||
| // inset — `.presentationDetents([.height])` adds that inset itself. | ||
| // | ||
| // `.fixedSize(vertical:)` is critical: it makes the sheet report its *ideal* height | ||
| // independent of the height the sheet currently offers. Without it the measurement is | ||
| // coupled to the detent (detent <- measured <- offered height <- detent), so it ping-pongs | ||
| // by ~the safe-area inset and the presenting view (HomeView) jitters up/down. | ||
| sheet | ||
| .fixedSize(horizontal: false, vertical: true) | ||
| .background( | ||
| GeometryReader { proxy in | ||
| Color.clear.preference( | ||
| key: BottomSheetHeightPreferenceKey.self, | ||
| value: proxy.size.height | ||
| ) | ||
| } | ||
| ) | ||
| } | ||
| } | ||
| .modifier(BottomSheetDismissalModifier(isEnabled: isDismissalEnabled)) | ||
| } | ||
|
|
||
| private var grabber: some View { | ||
|
|
@@ -96,7 +111,17 @@ public struct BottomSheet<Content: View>: View { | |
| .foregroundColor(.dash.primaryText) | ||
| }, | ||
| trailing: { | ||
| NavigationBarElement.close.button { presentationMode.wrappedValue.dismiss() } | ||
| if showsCloseButton { | ||
| NavigationBarElement.close.button { | ||
| BottomSheetDismissalAction.perform( | ||
| isEnabled: isDismissalEnabled, | ||
| onClose: onClose, | ||
| dismiss: { presentationMode.wrappedValue.dismiss() } | ||
| ) | ||
| } | ||
| .disabled(!isDismissalEnabled) | ||
| .opacity(isDismissalEnabled ? 1 : 0.35) | ||
| } | ||
| } | ||
| ) | ||
| } | ||
|
|
@@ -139,6 +164,9 @@ public extension BottomSheet { | |
| title: String = "", | ||
| showBackButton: Binding<Bool>, | ||
| onBackButtonPressed: (() -> Void)? = nil, | ||
| isDismissalEnabled: Binding<Bool> = .constant(true), | ||
| showsCloseButton: Bool = true, | ||
| onClose: (() -> Void)? = nil, | ||
| fallback: CGFloat = 0, | ||
| maxHeightFraction: CGFloat = 0.95, | ||
| background: Color = .dash.primaryBackground, | ||
|
|
@@ -149,6 +177,9 @@ public extension BottomSheet { | |
| title: title, | ||
| showBackButton: showBackButton, | ||
| onBackButtonPressed: onBackButtonPressed, | ||
| isDismissalEnabled: isDismissalEnabled, | ||
| showsCloseButton: showsCloseButton, | ||
| onClose: onClose, | ||
| fillsHeight: false, | ||
| background: background, | ||
| content: content | ||
|
|
@@ -161,6 +192,35 @@ public extension BottomSheet { | |
| } | ||
| } | ||
|
|
||
| @available(iOS 14, macOS 11, *) | ||
| enum BottomSheetDismissalAction { | ||
| static func perform(isEnabled: Bool, onClose: (() -> Void)?, dismiss: () -> Void) { | ||
| guard isEnabled else { return } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This To keep If that case is in scope, swipe-blocking and close-button enablement probably want to be separate flags. |
||
|
|
||
| if let onClose { | ||
| onClose() | ||
| } else { | ||
| dismiss() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 14, macOS 11, *) | ||
| private struct BottomSheetDismissalModifier: ViewModifier { | ||
| let isEnabled: Bool | ||
|
|
||
| @ViewBuilder | ||
| func body(content: Content) -> some View { | ||
| if isEnabled { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocker: flipping this flag tears down the sheet's content. The Applying the modifier unconditionally and passing the value keeps one stable identity — the remaining branch is on @ViewBuilder
func body(content: Content) -> some View {
if #available(iOS 15, macOS 12, *) {
content.interactiveDismissDisabled(!isEnabled)
} else {
content
}
} |
||
| content | ||
| } else if #available(iOS 15, macOS 12, *) { | ||
| content.interactiveDismissDisabled() | ||
| } else { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. On iOS 14 — the package's declared floor, and per CLAUDE.md non-negotiable — this branch is a no-op, so
|
||
| content | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @available(iOS 14, macOS 11, *) | ||
| public extension View { | ||
| /// Sizes a `BottomSheet` (built with `fillsHeight: false`) to its content's natural height — | ||
|
|
@@ -339,4 +399,46 @@ private struct SelfSizingSheetModifier: ViewModifier { | |
| } | ||
| } | ||
|
|
||
| @available(iOS 17, macOS 14, *) | ||
| #Preview("BottomSheet Dismissal States") { | ||
| VStack(spacing: 12) { | ||
| BottomSheet( | ||
| title: "Dismissal enabled", | ||
| showBackButton: .constant(false), | ||
| isDismissalEnabled: .constant(true), | ||
| fillsHeight: false | ||
| ) { | ||
| Text("Swipe or use the close button.") | ||
| .dashFont(.body) | ||
| .foregroundColor(.dash.secondaryText) | ||
| .padding() | ||
| } | ||
|
|
||
| BottomSheet( | ||
| title: "Dismissal disabled", | ||
| showBackButton: .constant(false), | ||
| isDismissalEnabled: .constant(false), | ||
| fillsHeight: false | ||
| ) { | ||
| Text("The dimmed close button and swipe are disabled.") | ||
| .dashFont(.body) | ||
| .foregroundColor(.dash.secondaryText) | ||
| .padding() | ||
| } | ||
|
|
||
| BottomSheet( | ||
| title: "Close hidden", | ||
| showBackButton: .constant(false), | ||
| showsCloseButton: false, | ||
| fillsHeight: false | ||
| ) { | ||
| Text("The host intentionally provides no close control.") | ||
| .dashFont(.body) | ||
| .foregroundColor(.dash.secondaryText) | ||
| .padding() | ||
| } | ||
| } | ||
| .background(Color.dash.primaryBackground) | ||
| } | ||
|
|
||
| #endif | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,44 @@ | ||
| import XCTest | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing the MIT license header that every other file in the repo carries, including the sibling |
||
| @testable import DashUIKit | ||
|
|
||
| final class BottomSheetDismissalActionTests: XCTestCase { | ||
| func testDisabledDismissalDoesNotInvokeAnyAction() { | ||
| var didClose = false | ||
| var didDismiss = false | ||
|
|
||
| BottomSheetDismissalAction.perform( | ||
| isEnabled: false, | ||
| onClose: { didClose = true }, | ||
| dismiss: { didDismiss = true } | ||
| ) | ||
|
|
||
| XCTAssertFalse(didClose) | ||
| XCTAssertFalse(didDismiss) | ||
| } | ||
|
|
||
| func testCustomCloseActionOverridesDefaultDismissal() { | ||
| var didClose = false | ||
| var didDismiss = false | ||
|
|
||
| BottomSheetDismissalAction.perform( | ||
| isEnabled: true, | ||
| onClose: { didClose = true }, | ||
| dismiss: { didDismiss = true } | ||
| ) | ||
|
|
||
| XCTAssertTrue(didClose) | ||
| XCTAssertFalse(didDismiss) | ||
| } | ||
|
|
||
| func testDefaultCloseActionDismissesPresentation() { | ||
| var didDismiss = false | ||
|
|
||
| BottomSheetDismissalAction.perform( | ||
| isEnabled: true, | ||
| onClose: nil, | ||
| dismiss: { didDismiss = true } | ||
| ) | ||
|
|
||
| XCTAssertTrue(didDismiss) | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
onCloseis wired to the button only, so an interactive swipe dismisses the sheet without ever calling it. The doc comment says the callback "is responsible for dismissing the sheet", which reads as though it owns every dismissal — a host using it for cleanup or custom routing gets that work done on tap and silently skipped on swipe.Either forward the swipe through
onDismiss:on the presenting.sheet, or say in the doc comment thatonClosecovers the close button and nothing else.