diff --git a/Sources/DashUIKit/Components/BottomSheet/BottomSheet.swift b/Sources/DashUIKit/Components/BottomSheet/BottomSheet.swift index cc0cdc9..3a6a3e0 100644 --- a/Sources/DashUIKit/Components/BottomSheet/BottomSheet.swift +++ b/Sources/DashUIKit/Components/BottomSheet/BottomSheet.swift @@ -11,6 +11,20 @@ public struct BottomSheet: View { public var title: String = "" @Binding public var showBackButton: Bool public var onBackButtonPressed: (() -> Void)? = nil + /// Whether the sheet may dismiss itself: blocks the interactive swipe, and blocks the + /// close button's default `dismiss()`. It does not silence `onClose` — a host that took + /// the close action over stays in charge of it, which is what makes "block the swipe but + /// ask before closing" expressible. Use `isCloseButtonEnabled` to disable the button too. + @Binding public var isDismissalEnabled: Bool + public var showsCloseButton: Bool = true + /// Whether the close button accepts taps. It also goes inert on its own when it would have + /// nothing left to do — dismissal disabled and no `onClose` to run. + public var isCloseButtonEnabled: Bool = true + /// Overrides the close button's action; the callback is then responsible for dismissing the + /// sheet. It covers the **button only** — an interactive swipe dismisses the sheet without + /// calling it, so a host that needs to hear about every dismissal should also pass + /// `onDismiss:` to the presenting `.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,6 +42,10 @@ public struct BottomSheet: View { title: String = "", showBackButton: Binding, onBackButtonPressed: (() -> Void)? = nil, + isDismissalEnabled: Binding = .constant(true), + showsCloseButton: Bool = true, + isCloseButtonEnabled: Bool = true, + onClose: (() -> Void)? = nil, fillsHeight: Bool = true, background: Color = .dash.primaryBackground, @ViewBuilder content: @escaping () -> Content @@ -35,6 +53,10 @@ public struct BottomSheet: View { self.title = title self._showBackButton = showBackButton self.onBackButtonPressed = onBackButtonPressed + self._isDismissalEnabled = isDismissalEnabled + self.showsCloseButton = showsCloseButton + self.isCloseButtonEnabled = isCloseButtonEnabled + self.onClose = onClose self.fillsHeight = fillsHeight self.background = background self.content = content @@ -51,28 +73,31 @@ public struct BottomSheet: 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 { @@ -83,6 +108,13 @@ public struct BottomSheet: View { .cornerRadius(5) } + private var isCloseButtonActive: Bool { + BottomSheetDismissalAction.isCloseButtonActive( + isCloseButtonEnabled: isCloseButtonEnabled, + isDismissalEnabled: isDismissalEnabled, + hasCustomCloseAction: onClose != nil) + } + private var header: some View { NavigationBar( leading: { @@ -96,7 +128,17 @@ public struct BottomSheet: View { .foregroundColor(.dash.primaryText) }, trailing: { - NavigationBarElement.close.button { presentationMode.wrappedValue.dismiss() } + if showsCloseButton { + NavigationBarElement.close.button { + BottomSheetDismissalAction.perform( + isDismissalEnabled: isDismissalEnabled, + onClose: onClose, + dismiss: { presentationMode.wrappedValue.dismiss() } + ) + } + .disabled(!isCloseButtonActive) + .opacity(isCloseButtonActive ? 1 : 0.35) + } } ) } @@ -139,6 +181,10 @@ public extension BottomSheet { title: String = "", showBackButton: Binding, onBackButtonPressed: (() -> Void)? = nil, + isDismissalEnabled: Binding = .constant(true), + showsCloseButton: Bool = true, + isCloseButtonEnabled: Bool = true, + onClose: (() -> Void)? = nil, fallback: CGFloat = 0, maxHeightFraction: CGFloat = 0.95, background: Color = .dash.primaryBackground, @@ -149,6 +195,10 @@ public extension BottomSheet { title: title, showBackButton: showBackButton, onBackButtonPressed: onBackButtonPressed, + isDismissalEnabled: isDismissalEnabled, + showsCloseButton: showsCloseButton, + isCloseButtonEnabled: isCloseButtonEnabled, + onClose: onClose, fillsHeight: false, background: background, content: content @@ -161,6 +211,119 @@ public extension BottomSheet { } } +@available(iOS 14, macOS 11, *) +enum BottomSheetDismissalAction { + /// The button is live while it still has something to do. Blocking dismissal only + /// takes away what the sheet itself owns — the `dismiss()` it would call — so a host + /// that supplied `onClose` keeps its action, and a sheet can block the swipe while + /// still answering the close button with a confirmation. `isCloseButtonEnabled` + /// remains the way to take the button away outright. + static func isCloseButtonActive( + isCloseButtonEnabled: Bool, + isDismissalEnabled: Bool, + hasCustomCloseAction: Bool + ) -> Bool { + isCloseButtonEnabled && (isDismissalEnabled || hasCustomCloseAction) + } + + static func perform(isDismissalEnabled: Bool, onClose: (() -> Void)?, dismiss: () -> Void) { + if let onClose { + onClose() + } else if isDismissalEnabled { + dismiss() + } + } +} + +@available(iOS 14, macOS 11, *) +private struct BottomSheetDismissalModifier: ViewModifier { + let isEnabled: Bool + + // The branch is on `#available` alone, never on `isEnabled`. A `@ViewBuilder` + // if/else produces `_ConditionalContent`, and the two branches are different + // views to SwiftUI: switching between them tears the sheet down and rebuilds + // it, taking every piece of `@State` the host keeps inside `content()` with + // it — a half-typed field, the scroll position, the keyboard. `#available` + // cannot flip while the app runs, so this branch is decided once and the + // sheet keeps one identity for as long as it is on screen. + @ViewBuilder + func body(content: Content) -> some View { + if #available(iOS 15, macOS 12, *) { + content.interactiveDismissDisabled(!isEnabled) + } else { + content.modifier(LegacyInteractiveDismissModifier(isDismissDisabled: !isEnabled)) + } + } +} + +#if canImport(UIKit) + +/// `interactiveDismissDisabled` is iOS 15, and this library ships to 14. The flag +/// it sets underneath — `UIViewController.isModalInPresentation` — is iOS 13, so +/// the older systems can be given the same protection rather than none at all. +@available(iOS 14, macOS 11, *) +private struct LegacyInteractiveDismissModifier: ViewModifier { + let isDismissDisabled: Bool + + func body(content: Content) -> some View { + content.background( + ModalInPresentationSetter(isModal: isDismissDisabled) + .frame(width: 0, height: 0) + ) + } +} + +@available(iOS 14, macOS 11, *) +private struct ModalInPresentationSetter: UIViewControllerRepresentable { + let isModal: Bool + + func makeUIViewController(context: Context) -> Controller { + Controller() + } + + func updateUIViewController(_ controller: Controller, context: Context) { + controller.isModal = isModal + } + + final class Controller: UIViewController { + var isModal = false { + didSet { applyToPresentedController() } + } + + override func didMove(toParent parent: UIViewController?) { + super.didMove(toParent: parent) + applyToPresentedController() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + applyToPresentedController() + } + + /// The swipe belongs to the controller that was actually presented, not to + /// this one: the representable sits in a background deep inside the sheet's + /// hosting controller, so walk up to the top of the containment chain. + private func applyToPresentedController() { + var controller: UIViewController = self + while let parent = controller.parent { + controller = parent + } + controller.isModalInPresentation = isModal + } + } +} + +#else + +@available(iOS 14, macOS 11, *) +private struct LegacyInteractiveDismissModifier: ViewModifier { + let isDismissDisabled: Bool + + func body(content: Content) -> some View { content } +} + +#endif + @available(iOS 14, macOS 11, *) public extension View { /// Sizes a `BottomSheet` (built with `fillsHeight: false`) to its content's natural height — @@ -339,4 +502,59 @@ 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: "Swipe blocked, close confirms", + showBackButton: .constant(false), + isDismissalEnabled: .constant(false), + onClose: { /* host shows a "discard changes?" alert */ }, + fillsHeight: false + ) { + Text("The swipe is blocked, but the close button still reaches the host.") + .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 diff --git a/Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift b/Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift new file mode 100644 index 0000000..bab21e4 --- /dev/null +++ b/Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift @@ -0,0 +1,109 @@ +// +// Copyright © 2026 Dash Core Group. All rights reserved. +// +// Licensed under the MIT License (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://opensource.org/licenses/MIT +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import XCTest +@testable import DashUIKit + +final class BottomSheetDismissalActionTests: XCTestCase { + + // MARK: - perform + + func testDisabledDismissalDoesNotDismissTheSheetItself() { + var didDismiss = false + + BottomSheetDismissalAction.perform( + isDismissalEnabled: false, + onClose: nil, + dismiss: { didDismiss = true } + ) + + XCTAssertFalse(didDismiss) + } + + /// Blocking dismissal takes away the sheet's own `dismiss()`, not the host's action: + /// this is the "swipe is blocked, closing asks for confirmation" configuration. + func testCustomCloseActionStillRunsWhileDismissalIsDisabled() { + var didClose = false + var didDismiss = false + + BottomSheetDismissalAction.perform( + isDismissalEnabled: false, + onClose: { didClose = true }, + dismiss: { didDismiss = true } + ) + + XCTAssertTrue(didClose) + XCTAssertFalse(didDismiss) + } + + func testCustomCloseActionOverridesDefaultDismissal() { + var didClose = false + var didDismiss = false + + BottomSheetDismissalAction.perform( + isDismissalEnabled: true, + onClose: { didClose = true }, + dismiss: { didDismiss = true } + ) + + XCTAssertTrue(didClose) + XCTAssertFalse(didDismiss) + } + + func testDefaultCloseActionDismissesPresentation() { + var didDismiss = false + + BottomSheetDismissalAction.perform( + isDismissalEnabled: true, + onClose: nil, + dismiss: { didDismiss = true } + ) + + XCTAssertTrue(didDismiss) + } + + // MARK: - isCloseButtonActive + + func testCloseButtonIsActiveWhileDismissalIsEnabled() { + XCTAssertTrue(BottomSheetDismissalAction.isCloseButtonActive( + isCloseButtonEnabled: true, + isDismissalEnabled: true, + hasCustomCloseAction: false)) + } + + /// Nothing left for a tap to do: the sheet may not dismiss itself and no host + /// action was supplied, so the button goes inert rather than lying about it. + func testCloseButtonIsInertWhenDismissalIsDisabledAndNoCustomAction() { + XCTAssertFalse(BottomSheetDismissalAction.isCloseButtonActive( + isCloseButtonEnabled: true, + isDismissalEnabled: false, + hasCustomCloseAction: false)) + } + + func testCloseButtonStaysActiveForACustomActionWhileDismissalIsDisabled() { + XCTAssertTrue(BottomSheetDismissalAction.isCloseButtonActive( + isCloseButtonEnabled: true, + isDismissalEnabled: false, + hasCustomCloseAction: true)) + } + + func testCloseButtonIsInertWhenDisabledOutright() { + XCTAssertFalse(BottomSheetDismissalAction.isCloseButtonActive( + isCloseButtonEnabled: false, + isDismissalEnabled: true, + hasCustomCloseAction: true)) + } +} diff --git a/docs/navigation-and-containers.md b/docs/navigation-and-containers.md index 961e6ef..eb2a68d 100644 --- a/docs/navigation-and-containers.md +++ b/docs/navigation-and-containers.md @@ -70,6 +70,9 @@ Sheet chrome to put **inside** a SwiftUI `.sheet { }`: a grabber, a `NavigationB title: "Details", showBackButton: $showBack, // Binding onBackButtonPressed: { /* pop */ }, + isDismissalEnabled: $canDismiss, // close + swipe; true by default + showsCloseButton: true, // true by default + onClose: { /* custom close action */ }, fillsHeight: true, // greedy: fills the sheet background: .dash.primaryBackground // fill behind grabber, header and content ) { @@ -83,6 +86,38 @@ Sheet chrome to put **inside** a SwiftUI `.sheet { }`: a grabber, a `NavigationB - **`fillsHeight: false`** — natural height; pair with `.selfSizingSheet(…)` so the sheet snaps to its content. +`isDismissalEnabled` says whether the sheet may dismiss **itself**: it blocks the +interactive swipe and the close button's default `dismiss()`. The binding is dynamic, so a +host can lock the sheet while signing or broadcasting and restore it afterward — and +because the flag is passed to the modifier rather than switching between two view trees, +flipping it leaves the content, and every piece of `@State` inside it, untouched. + +It does not silence `onClose`. A host that took the close action over keeps it, which is +how "the swipe is blocked, but closing asks for confirmation" is expressed: + +```swift +BottomSheet( + title: "Edit note", + showBackButton: $showBack, + isDismissalEnabled: .constant(false), // swipe is blocked + onClose: { showsDiscardAlert = true } // …the button still reaches the host +) { … } +``` + +The close button goes inert on its own when a tap would do nothing — dismissal disabled and +no `onClose` — and `isCloseButtonEnabled: false` takes it away outright. An inert button is +visibly dimmed and exposes the disabled accessibility trait. Use `showsCloseButton: false` +when the sheet should have no close affordance at all. + +Swipe blocking uses `interactiveDismissDisabled` on **iOS 15+** / **macOS 12+** and +`UIViewController.isModalInPresentation` below that, so an iOS 14 host is protected too. + +`onClose` covers the **close button only**: an interactive swipe dismisses the sheet +without calling it. A host that has to hear about every dismissal should also pass +`onDismiss:` to the presenting `.sheet`. + +All of these options preserve the existing behavior when omitted. + ### Self-sizing Prefer the `BottomSheet.selfSizing(…)` factory, which guarantees `fillsHeight: false` and