Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 124 additions & 22 deletions Sources/DashUIKit/Components/BottomSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onClose is 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 that onClose covers the close button and nothing else.

isEnabled: isDismissalEnabled,
onClose: onClose,
dismiss: { presentationMode.wrappedValue.dismiss() }
)
}
.disabled(!isDismissalEnabled)
.opacity(isDismissalEnabled ? 1 : 0.35)
}
}
)
}
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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 }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guard plus the .disabled(!isDismissalEnabled) on the button ties two separate concerns to one flag, and it leaves a common case unreachable: "block the swipe, but ask for confirmation when the user closes".

To keep onClose firing you have to leave isDismissalEnabled: true, which re-enables the swipe — and the user then bypasses the confirmation by swiping. Set it to false and the button is dead, so there is nothing to confirm. A sheet with a "discard changes?" prompt has no working configuration.

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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocker: flipping this flag tears down the sheet's content.

The if isEnabled branch is inside a @ViewBuilder, so this returns _ConditionalContent<A, B> and the two branches are structurally different views. When a host toggles isDismissalEnabled at runtime — the case the docs recommend, disabling while signing or broadcasting and restoring afterwards — SwiftUI treats the new branch as a different view, tears down the old one and builds the other from scratch. content() goes with it: a draft in a TextField, scroll position, expanded rows, focus and the keyboard, any in-flight animation. On the fillsHeight: false path the GeometryReader re-measures too, so the sheet visibly jumps.

Applying the modifier unconditionally and passing the value keeps one stable identity — the remaining branch is on #available, which never flips at runtime:

@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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 isDismissalEnabled: false provides no protection at all: the user can swipe the sheet away mid-broadcast. The parameter name reads as a guarantee, and the API doc comment mentions the version gap only for interactive dismissal, so a host has to read the source to find out.

UIViewController.isModalInPresentation is iOS 13+ and covers this with a small UIViewControllerRepresentable in the background, so it is fixable rather than a platform limit. If it stays a no-op, please put the gap in the doc comment next to the parameter, not only in docs/.

content
}
}
}

@available(iOS 14, macOS 11, *)
public extension View {
/// Sizes a `BottomSheet` (built with `fillsHeight: false`) to its content's natural height —
Expand Down Expand Up @@ -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
44 changes: 44 additions & 0 deletions Tests/DashUIKitTests/BottomSheetDismissalActionTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import XCTest

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 NumericKeyboardLocaleSupportTests.swift.

@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)
}
}
13 changes: 13 additions & 0 deletions docs/navigation-and-containers.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ Sheet chrome to put **inside** a SwiftUI `.sheet { }`: a grabber, a `NavigationB
title: "Details",
showBackButton: $showBack, // Binding<Bool>
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
) {
Expand All @@ -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
Expand Down
Loading