From e8d92434bfc28fbf933b896cd40a01dd61835b5f Mon Sep 17 00:00:00 2001 From: pasta Date: Wed, 26 Aug 2026 18:36:49 +0200 Subject: [PATCH 1/6] feat(bottom-sheet): add dismissal controls --- .../DashUIKit/Components/BottomSheet.swift | 146 +++++++++++++++--- .../BottomSheetDismissalActionTests.swift | 44 ++++++ docs/navigation-and-containers.md | 13 ++ 3 files changed, 181 insertions(+), 22 deletions(-) create mode 100644 Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift diff --git a/Sources/DashUIKit/Components/BottomSheet.swift b/Sources/DashUIKit/Components/BottomSheet.swift index cc0cdc9..e5bcb8a 100644 --- a/Sources/DashUIKit/Components/BottomSheet.swift +++ b/Sources/DashUIKit/Components/BottomSheet.swift @@ -11,6 +11,12 @@ public struct BottomSheet: 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,6 +34,9 @@ public struct BottomSheet: View { title: String = "", showBackButton: Binding, onBackButtonPressed: (() -> Void)? = nil, + isDismissalEnabled: Binding = .constant(true), + showsCloseButton: Bool = true, + onClose: (() -> Void)? = nil, fillsHeight: Bool = true, background: Color = .dash.primaryBackground, @ViewBuilder content: @escaping () -> Content @@ -35,6 +44,9 @@ public struct BottomSheet: View { 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: 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: 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, onBackButtonPressed: (() -> Void)? = nil, + isDismissalEnabled: Binding = .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 } + + 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 { + content + } else if #available(iOS 15, macOS 12, *) { + content.interactiveDismissDisabled() + } else { + 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 diff --git a/Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift b/Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift new file mode 100644 index 0000000..c29899c --- /dev/null +++ b/Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift @@ -0,0 +1,44 @@ +import XCTest +@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) + } +} diff --git a/docs/navigation-and-containers.md b/docs/navigation-and-containers.md index 4d01b03..a184213 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,16 @@ 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` controls the close button and interactive swipe dismissal together. +The binding is dynamic, so a host can disable both while signing or broadcasting and +restore them afterward. The close button becomes visibly disabled and exposes the disabled +accessibility trait. Interactive-dismiss blocking uses the system API on **iOS 15+** / +**macOS 12+**; older supported systems retain the close-button protection. + +Set `showsCloseButton: false` when the sheet has no close affordance. Pass `onClose` to +override the default presentation dismissal; the callback is then responsible for actually +dismissing the sheet. All three options preserve the existing behavior when omitted. + ### Self-sizing Prefer the `BottomSheet.selfSizing(…)` factory, which guarantees `fillsHeight: false` and From 8097d16129a312d3bb08420716c1a867061b6b3e Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 27 Aug 2026 16:50:27 +0300 Subject: [PATCH 2/6] fix(bottom-sheet): keep the sheet's identity when dismissal flips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BottomSheetDismissalModifier` branched on `isEnabled` inside a `@ViewBuilder`, so the modifier returned `_ConditionalContent` and the two branches were structurally different views. Flipping `isDismissalEnabled` at runtime — the use the API is built for, locking the sheet while signing or broadcasting and unlocking it afterwards — swapped branches, and SwiftUI answers that by tearing the subtree down and building the other one from scratch. `content()` went with it: a half-typed field, the scroll position, expanded rows, focus and the keyboard, any in-flight animation. On the `fillsHeight: false` path the `GeometryReader` re-measured as well, so the sheet visibly jumped. Passing the value to `interactiveDismissDisabled(_:)` and branching only on `#available` leaves one view identity: an availability check cannot change while the app runs. Co-Authored-By: Claude Opus 5 --- Sources/DashUIKit/Components/BottomSheet.swift | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/Sources/DashUIKit/Components/BottomSheet.swift b/Sources/DashUIKit/Components/BottomSheet.swift index e5bcb8a..c30ff33 100644 --- a/Sources/DashUIKit/Components/BottomSheet.swift +++ b/Sources/DashUIKit/Components/BottomSheet.swift @@ -209,12 +209,17 @@ enum BottomSheetDismissalAction { 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 isEnabled { - content - } else if #available(iOS 15, macOS 12, *) { - content.interactiveDismissDisabled() + if #available(iOS 15, macOS 12, *) { + content.interactiveDismissDisabled(!isEnabled) } else { content } From ab9eac170ee0cadbafb90ea2ee8c18f920d9fa20 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 27 Aug 2026 16:50:44 +0300 Subject: [PATCH 3/6] feat(bottom-sheet): block the swipe on iOS 14 too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `interactiveDismissDisabled` is iOS 15, so below that the modifier did nothing at all: `isDismissalEnabled: false` left the user free to swipe the sheet away mid-broadcast, on a deployment target the package treats as non-negotiable. The parameter name promised a guarantee it could not keep, and only the docs page said so. The flag that API sets underneath — `UIViewController.isModalInPresentation` — has been available since iOS 13. A zero-sized `UIViewControllerRepresentable` in the sheet's background reaches it: the representable is a child controller deep inside the presented hosting controller, so it walks up the containment chain and marks the controller that actually owns the swipe. Non-UIKit platforms keep the previous no-op. Verified in the simulator: the flag lands on the top controller of the chain, not on the child that carries the representable. Co-Authored-By: Claude Opus 5 --- .../DashUIKit/Components/BottomSheet.swift | 70 ++++++++++++++++++- 1 file changed, 69 insertions(+), 1 deletion(-) diff --git a/Sources/DashUIKit/Components/BottomSheet.swift b/Sources/DashUIKit/Components/BottomSheet.swift index c30ff33..0c4be24 100644 --- a/Sources/DashUIKit/Components/BottomSheet.swift +++ b/Sources/DashUIKit/Components/BottomSheet.swift @@ -221,11 +221,79 @@ private struct BottomSheetDismissalModifier: ViewModifier { if #available(iOS 15, macOS 12, *) { content.interactiveDismissDisabled(!isEnabled) } else { - content + 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 — From b7463ba089fc50c6ec0c4009edb63bb03656007f Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 27 Aug 2026 16:50:55 +0300 Subject: [PATCH 4/6] chore(bottom-sheet): give the dismissal tests the licence header Every other file in the repository carries it, the sibling `NumericKeyboardLocaleSupportTests` included. Co-Authored-By: Claude Opus 5 --- .../BottomSheetDismissalActionTests.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift b/Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift index c29899c..1506ad7 100644 --- a/Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift +++ b/Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift @@ -1,3 +1,19 @@ +// +// 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 From b8f3159d91341839c5cc64cc1d80840754c52086 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 27 Aug 2026 16:51:33 +0300 Subject: [PATCH 5/6] feat(bottom-sheet): separate the close button from interactive dismissal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One flag drove two different things — whether the sheet may dismiss itself, and whether its close button accepts taps — and that made a common configuration unreachable: "block the swipe, but ask for confirmation when the user closes". Keeping `onClose` alive required `isDismissalEnabled: true`, which handed the swipe back, and the user could then walk past the confirmation by swiping. `isDismissalEnabled` now means only what the sheet itself owns: the swipe, and the `dismiss()` the close button falls back on. A host that supplied `onClose` keeps its action either way, so a locked sheet can still answer its close button with a "discard changes?" alert. The button is inert when a tap would genuinely do nothing — dismissal disabled and no `onClose` — and the new `isCloseButtonEnabled` takes it away outright for a host that wants the sheet fully sealed. Both paths dim it and expose the disabled trait, as before. `BottomSheetDismissalAction` grows `isCloseButtonActive` so the rule is stated once and tested rather than spelled out at the call site. Its `perform(isEnabled:)` parameter is renamed to `isDismissalEnabled` to match what it now gates. The existing test that asserted a disabled sheet swallows `onClose` is replaced: that is the behaviour this commit deliberately changes. Co-Authored-By: Claude Opus 5 --- .../DashUIKit/Components/BottomSheet.swift | 61 ++++++++++++++++--- .../BottomSheetDismissalActionTests.swift | 59 ++++++++++++++++-- 2 files changed, 106 insertions(+), 14 deletions(-) diff --git a/Sources/DashUIKit/Components/BottomSheet.swift b/Sources/DashUIKit/Components/BottomSheet.swift index 0c4be24..3a6a3e0 100644 --- a/Sources/DashUIKit/Components/BottomSheet.swift +++ b/Sources/DashUIKit/Components/BottomSheet.swift @@ -11,11 +11,19 @@ public struct BottomSheet: 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+. + /// 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 - /// Overrides the close button action. The callback is responsible for dismissing the sheet. + /// 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 @@ -36,6 +44,7 @@ public struct BottomSheet: View { onBackButtonPressed: (() -> Void)? = nil, isDismissalEnabled: Binding = .constant(true), showsCloseButton: Bool = true, + isCloseButtonEnabled: Bool = true, onClose: (() -> Void)? = nil, fillsHeight: Bool = true, background: Color = .dash.primaryBackground, @@ -46,6 +55,7 @@ public struct BottomSheet: View { self.onBackButtonPressed = onBackButtonPressed self._isDismissalEnabled = isDismissalEnabled self.showsCloseButton = showsCloseButton + self.isCloseButtonEnabled = isCloseButtonEnabled self.onClose = onClose self.fillsHeight = fillsHeight self.background = background @@ -98,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: { @@ -114,13 +131,13 @@ public struct BottomSheet: View { if showsCloseButton { NavigationBarElement.close.button { BottomSheetDismissalAction.perform( - isEnabled: isDismissalEnabled, + isDismissalEnabled: isDismissalEnabled, onClose: onClose, dismiss: { presentationMode.wrappedValue.dismiss() } ) } - .disabled(!isDismissalEnabled) - .opacity(isDismissalEnabled ? 1 : 0.35) + .disabled(!isCloseButtonActive) + .opacity(isCloseButtonActive ? 1 : 0.35) } } ) @@ -166,6 +183,7 @@ public extension BottomSheet { onBackButtonPressed: (() -> Void)? = nil, isDismissalEnabled: Binding = .constant(true), showsCloseButton: Bool = true, + isCloseButtonEnabled: Bool = true, onClose: (() -> Void)? = nil, fallback: CGFloat = 0, maxHeightFraction: CGFloat = 0.95, @@ -179,6 +197,7 @@ public extension BottomSheet { onBackButtonPressed: onBackButtonPressed, isDismissalEnabled: isDismissalEnabled, showsCloseButton: showsCloseButton, + isCloseButtonEnabled: isCloseButtonEnabled, onClose: onClose, fillsHeight: false, background: background, @@ -194,12 +213,23 @@ public extension BottomSheet { @available(iOS 14, macOS 11, *) enum BottomSheetDismissalAction { - static func perform(isEnabled: Bool, onClose: (() -> Void)?, dismiss: () -> Void) { - guard isEnabled else { return } + /// 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 { + } else if isDismissalEnabled { dismiss() } } @@ -499,6 +529,19 @@ private struct SelfSizingSheetModifier: ViewModifier { .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), diff --git a/Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift b/Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift index 1506ad7..bab21e4 100644 --- a/Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift +++ b/Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift @@ -18,17 +18,34 @@ import XCTest @testable import DashUIKit final class BottomSheetDismissalActionTests: XCTestCase { - func testDisabledDismissalDoesNotInvokeAnyAction() { + + // 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( - isEnabled: false, + isDismissalEnabled: false, onClose: { didClose = true }, dismiss: { didDismiss = true } ) - XCTAssertFalse(didClose) + XCTAssertTrue(didClose) XCTAssertFalse(didDismiss) } @@ -37,7 +54,7 @@ final class BottomSheetDismissalActionTests: XCTestCase { var didDismiss = false BottomSheetDismissalAction.perform( - isEnabled: true, + isDismissalEnabled: true, onClose: { didClose = true }, dismiss: { didDismiss = true } ) @@ -50,11 +67,43 @@ final class BottomSheetDismissalActionTests: XCTestCase { var didDismiss = false BottomSheetDismissalAction.perform( - isEnabled: true, + 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)) + } } From bad1b2ddf1137ab832040e05af1a52afe1c932a3 Mon Sep 17 00:00:00 2001 From: Roman Date: Thu, 27 Aug 2026 16:51:33 +0300 Subject: [PATCH 6/6] docs(bottom-sheet): rewrite the dismissal section for the split controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Says what each flag owns, shows the swipe-blocked-with-confirmation case that prompted the split, records that the swipe is blocked on iOS 14 as well, and warns that `onClose` covers the close button only — an interactive swipe dismisses the sheet without it, so a host that must hear about every dismissal also needs `onDismiss:` on the presenting `.sheet`. Co-Authored-By: Claude Opus 5 --- docs/navigation-and-containers.md | 40 ++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/docs/navigation-and-containers.md b/docs/navigation-and-containers.md index a184213..4ed3cb4 100644 --- a/docs/navigation-and-containers.md +++ b/docs/navigation-and-containers.md @@ -86,15 +86,37 @@ 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` controls the close button and interactive swipe dismissal together. -The binding is dynamic, so a host can disable both while signing or broadcasting and -restore them afterward. The close button becomes visibly disabled and exposes the disabled -accessibility trait. Interactive-dismiss blocking uses the system API on **iOS 15+** / -**macOS 12+**; older supported systems retain the close-button protection. - -Set `showsCloseButton: false` when the sheet has no close affordance. Pass `onClose` to -override the default presentation dismissal; the callback is then responsible for actually -dismissing the sheet. All three options preserve the existing behavior when omitted. +`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