Skip to content

Commit 1682524

Browse files
authored
Merge pull request #11 from dashpay/feat/bottom-sheet-background
feat: let the host set the sheet background and badge the address field
2 parents 6f2e419 + fc5dbe5 commit 1682524

3 files changed

Lines changed: 167 additions & 28 deletions

File tree

Sources/DashUIKit/Components/AddressFieldView.swift

Lines changed: 90 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,17 +17,19 @@
1717
#if canImport(UIKit)
1818
import SwiftUI
1919

20+
/// Outside the view, not nested in it: `AddressFieldView` is generic over its
21+
/// accessory, and a generic type cannot hold static stored properties.
22+
private enum Layout {
23+
static let hSpacing: CGFloat = 20
24+
static let lPadding: CGFloat = 20
25+
static let tPadding: CGFloat = 10
26+
static let iconSize: CGFloat = 17
27+
static let cornerRadius: CGFloat = 16
28+
static let actionTapArea: CGFloat = 40
29+
}
30+
2031
@available(iOS 15, macOS 12, *)
21-
public struct AddressFieldView: View {
22-
23-
private enum Layout {
24-
static let hSpacing: CGFloat = 20
25-
static let lPadding: CGFloat = 20
26-
static let tPadding: CGFloat = 10
27-
static let iconSize: CGFloat = 17
28-
static let cornerRadius: CGFloat = 16
29-
static let actionTapArea: CGFloat = 40
30-
}
32+
public struct AddressFieldView<Accessory: View>: View {
3133

3234
@Binding private var text: String
3335
private let label: String
@@ -37,6 +39,11 @@ public struct AddressFieldView: View {
3739
private var isDisabled: Bool
3840
private var onScanQR: (() -> Void)?
3941
private var onPaste: (() -> Void)?
42+
/// Trailing content on the label row — a badge naming what the entered
43+
/// address turned out to be, say. Sits opposite `label`, so it is for
44+
/// something that describes the field rather than acts on it; the
45+
/// controls that act live inside the field itself.
46+
private let accessory: Accessory
4047

4148
@FocusState private var isTextFieldFocused: Bool
4249

@@ -48,7 +55,8 @@ public struct AddressFieldView: View {
4855
errorText: String? = nil,
4956
isDisabled: Bool = false,
5057
onScanQR: (() -> Void)? = nil,
51-
onPaste: (() -> Void)? = nil
58+
onPaste: (() -> Void)? = nil,
59+
@ViewBuilder accessory: () -> Accessory
5260
) {
5361
self._text = text
5462
self.label = label
@@ -58,14 +66,21 @@ public struct AddressFieldView: View {
5866
self.isDisabled = isDisabled
5967
self.onScanQR = onScanQR
6068
self.onPaste = onPaste
69+
self.accessory = accessory()
6170
}
6271

6372
public var body: some View {
6473
VStack(alignment: .leading, spacing: 10) {
65-
Text(label)
66-
.dashFont(.footnote)
67-
.foregroundStyle(Color.dash.gray500)
68-
.frame(maxWidth: .infinity, alignment: .leading)
74+
HStack(spacing: 8) {
75+
Text(label)
76+
.dashFont(.footnote)
77+
.foregroundStyle(Color.dash.gray500)
78+
79+
Spacer(minLength: 0)
80+
81+
accessory
82+
}
83+
.frame(maxWidth: .infinity, alignment: .leading)
6984

7085
HStack(alignment: .center, spacing: Layout.hSpacing) {
7186
textField
@@ -101,6 +116,37 @@ public struct AddressFieldView: View {
101116
}
102117
}
103118

119+
}
120+
121+
@available(iOS 15, macOS 12, *)
122+
public extension AddressFieldView where Accessory == EmptyView {
123+
/// No label accessory — the original shape, unchanged for callers that
124+
/// have nothing to put there.
125+
init(
126+
text: Binding<String>,
127+
label: String,
128+
placeholder: String,
129+
hasError: Bool,
130+
errorText: String? = nil,
131+
isDisabled: Bool = false,
132+
onScanQR: (() -> Void)? = nil,
133+
onPaste: (() -> Void)? = nil
134+
) {
135+
self.init(
136+
text: text,
137+
label: label,
138+
placeholder: placeholder,
139+
hasError: hasError,
140+
errorText: errorText,
141+
isDisabled: isDisabled,
142+
onScanQR: onScanQR,
143+
onPaste: onPaste,
144+
accessory: { EmptyView() })
145+
}
146+
}
147+
148+
@available(iOS 15, macOS 12, *)
149+
extension AddressFieldView {
104150
// MARK: - Subviews
105151

106152
private var showsPasteButton: Bool {
@@ -292,5 +338,34 @@ public struct AddressFieldView: View {
292338
.padding()
293339
}
294340

341+
@available(iOS 17, macOS 14, *)
342+
#Preview("Label accessory") {
343+
AddressFieldView(
344+
text: .constant("yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY"),
345+
label: "Address",
346+
placeholder: "Dash address",
347+
hasError: false
348+
) {
349+
// What the host puts here is its own: a badge naming the kind of
350+
// address that was entered, decided by the host's own decoder.
351+
HStack(spacing: 4) {
352+
DashIcon.Common.iconDashCurrency.image
353+
.renderingMode(.template)
354+
.resizable()
355+
.scaledToFit()
356+
.frame(width: 10, height: 10)
357+
Text("Transparent address")
358+
.dashFont(.caption2)
359+
}
360+
.foregroundStyle(Color.dash.blueText)
361+
.padding(.horizontal, 8)
362+
.padding(.vertical, 3)
363+
.background(Color.dash.blueAlpha10)
364+
.clipShape(Capsule())
365+
}
366+
.padding()
367+
.background(Color.dash.primaryBackground)
368+
}
369+
295370
#endif
296371
#endif // canImport(UIKit)

Sources/DashUIKit/Components/BottomSheet.swift

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,26 @@ public struct BottomSheet<Content: View>: View {
1717
/// when natural sizing is needed — it guarantees `fillsHeight: false` and the modifier are
1818
/// always applied together.
1919
public var fillsHeight: Bool = true
20+
/// Fill behind the whole sheet — grabber, header and content alike. Also
21+
/// used as the presentation background so the home-indicator inset the
22+
/// detent adds matches; a host that only restyles its own content would
23+
/// otherwise get a strip of this colour along the bottom edge.
24+
public var background: Color = .dash.primaryBackground
2025
@ViewBuilder public var content: () -> Content
2126

2227
public init(
2328
title: String = "",
2429
showBackButton: Binding<Bool>,
2530
onBackButtonPressed: (() -> Void)? = nil,
2631
fillsHeight: Bool = true,
32+
background: Color = .dash.primaryBackground,
2733
@ViewBuilder content: @escaping () -> Content
2834
) {
2935
self.title = title
3036
self._showBackButton = showBackButton
3137
self.onBackButtonPressed = onBackButtonPressed
3238
self.fillsHeight = fillsHeight
39+
self.background = background
3340
self.content = content
3441
}
3542

@@ -42,7 +49,7 @@ public struct BottomSheet<Content: View>: View {
4249

4350
contentSection
4451
}
45-
.background(Color.dash.primaryBackground)
52+
.background(background)
4653

4754
if fillsHeight {
4855
sheet.edgesIgnoringSafeArea(.bottom)
@@ -103,13 +110,13 @@ public struct BottomSheet<Content: View>: View {
103110
.navigationBarHidden(true)
104111
#endif
105112
.frame(maxWidth: .infinity, maxHeight: .infinity)
106-
.background(Color.dash.primaryBackground)
113+
.background(background)
107114
}
108115
} else {
109116
// Natural height — no greedy NavigationView / maxHeight so the sheet can self-size.
110117
content()
111118
.frame(maxWidth: .infinity)
112-
.background(Color.dash.primaryBackground)
119+
.background(background)
113120
}
114121
}
115122
}
@@ -134,6 +141,7 @@ public extension BottomSheet {
134141
onBackButtonPressed: (() -> Void)? = nil,
135142
fallback: CGFloat = 0,
136143
maxHeightFraction: CGFloat = 0.95,
144+
background: Color = .dash.primaryBackground,
137145
cornerRadius: CGFloat? = nil,
138146
@ViewBuilder content: @escaping () -> Content
139147
) -> some View {
@@ -142,9 +150,14 @@ public extension BottomSheet {
142150
showBackButton: showBackButton,
143151
onBackButtonPressed: onBackButtonPressed,
144152
fillsHeight: false,
153+
background: background,
145154
content: content
146155
)
147-
.selfSizingSheet(fallback: fallback, maxHeightFraction: maxHeightFraction, cornerRadius: cornerRadius)
156+
.selfSizingSheet(
157+
fallback: fallback,
158+
maxHeightFraction: maxHeightFraction,
159+
background: background,
160+
cornerRadius: cornerRadius)
148161
}
149162
}
150163

@@ -163,32 +176,50 @@ public extension View {
163176
/// - fallback: Height used before the first measurement (avoids a `.medium` flash).
164177
/// - maxHeightFraction: Caps the sheet at this fraction of the window height; taller content
165178
/// is clipped, so wrap it in a `ScrollView`.
179+
/// - background: Fill for the sheet and its presentation, so the bottom
180+
/// safe-area strip matches the content. This modifier cannot see the
181+
/// colour the wrapped `BottomSheet` was built with, so a custom one has
182+
/// to be passed here too — or use `BottomSheet.selfSizing(...)`, which
183+
/// forwards a single `background` to both.
166184
/// - cornerRadius: Optional corner radius applied via `presentationCornerRadius` on
167-
/// iOS 16.4..<26 (iOS 26+ keeps the system corner styling). When provided, the sheet
168-
/// background is also filled so the bottom safe-area strip matches the content.
185+
/// iOS 16.4..<26 (iOS 26+ keeps the system corner styling).
169186
@ViewBuilder
170187
func selfSizingSheet(
171188
fallback: CGFloat = 0,
172189
maxHeightFraction: CGFloat = 0.95,
190+
background: Color = .dash.primaryBackground,
173191
cornerRadius: CGFloat? = nil
174192
) -> some View {
175193
if #available(iOS 16.0, macOS 13.0, *) {
176194
let modified = modifier(SelfSizingSheetModifier(fallback: fallback, maxHeightFraction: maxHeightFraction))
177195
#if os(iOS)
178-
if #available(iOS 16.4, *), let cornerRadius {
179-
if #unavailable(iOS 26.0) {
180-
// iOS 16.4..<26: apply the custom corner radius + fill the sheet background.
196+
if #available(iOS 16.4, *) {
197+
// The background is filled whatever the corner radius: the
198+
// measured height excludes the home-indicator inset that
199+
// `.presentationDetents([.height])` adds back, so that strip
200+
// sits outside the sheet's own `VStack` and shows the system
201+
// background unless this fills it.
202+
if #unavailable(iOS 26.0), let cornerRadius {
181203
modified
182204
.presentationCornerRadius(cornerRadius)
183-
.presentationBackground(Color.dash.primaryBackground)
205+
.presentationBackground(background)
184206
} else {
185-
// iOS 26+: keep the system corner styling, just fill the background.
207+
// iOS 26+ keeps the system corner styling.
186208
modified
187-
.presentationBackground(Color.dash.primaryBackground)
209+
.presentationBackground(background)
188210
}
189211
} else {
190212
modified
191213
}
214+
#elseif os(macOS)
215+
// `presentationCornerRadius` is iOS-only, but the presentation
216+
// background lands on macOS 13.3 — apply it there too so the
217+
// parameter is not silently ignored.
218+
if #available(macOS 13.3, *) {
219+
modified.presentationBackground(background)
220+
} else {
221+
modified
222+
}
192223
#else
193224
modified
194225
#endif
@@ -245,6 +276,8 @@ private struct SelfSizingSheetModifier: ViewModifier {
245276
}
246277
}
247278

279+
#if DEBUG
280+
248281
@available(iOS 17, macOS 14, *)
249282
#Preview("BottomSheet Filled Height") {
250283
BottomSheet(
@@ -283,3 +316,27 @@ private struct SelfSizingSheetModifier: ViewModifier {
283316
.padding()
284317
}
285318
}
319+
320+
@available(iOS 17, macOS 14, *)
321+
#Preview("BottomSheet Custom Background") {
322+
BottomSheet(
323+
title: "Bottom Sheet",
324+
showBackButton: .constant(false),
325+
fillsHeight: false,
326+
background: .dash.secondaryBackground
327+
) {
328+
VStack(alignment: .leading, spacing: 12) {
329+
Text("Cards on a tinted sheet")
330+
.dashFont(.calloutMedium)
331+
.foregroundColor(.dash.primaryText)
332+
333+
Text("The host picks the fill; cards drawn on top keep their own.")
334+
.dashFont(.body)
335+
.foregroundColor(.dash.secondaryText)
336+
.modifier(MenuViewModifier())
337+
}
338+
.padding()
339+
}
340+
}
341+
342+
#endif

docs/navigation-and-containers.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,8 @@ Sheet chrome to put **inside** a SwiftUI `.sheet { }`: a grabber, a `NavigationB
7070
title: "Details",
7171
showBackButton: $showBack, // Binding<Bool>
7272
onBackButtonPressed: { /* pop */ },
73-
fillsHeight: true // greedy: fills the sheet
73+
fillsHeight: true, // greedy: fills the sheet
74+
background: .dash.primaryBackground // fill behind grabber, header and content
7475
) {
7576
MyContent()
7677
}
@@ -94,6 +95,7 @@ the modifier are applied together:
9495
showBackButton: .constant(false),
9596
fallback: 240, // height before first measurement (avoids .medium flash)
9697
maxHeightFraction: 0.95, // cap at 95% of window height (clip taller → use ScrollView)
98+
background: .dash.secondaryBackground, // also fills the home-indicator strip
9799
cornerRadius: 24 // iOS 16.4..<26; iOS 26+ keeps system corners
98100
) {
99101
MyContent()
@@ -107,6 +109,11 @@ a **no-op below iOS 16**. The measured content must have a finite intrinsic heig
107109
greedy `Spacer`/`maxHeight: .infinity`), or the measurement is wrong.
108110
`BottomSheetHeightPreferenceKey` is exposed for advanced cases.
109111

112+
`background` fills the sheet **and** its presentation. The measured height excludes the
113+
home-indicator inset that `presentationDetents([.height])` adds back, so that strip lies
114+
outside the sheet's own stack — without the presentation fill it shows the system
115+
background as a pale band along the bottom edge, whatever the content is styled with.
116+
110117
---
111118

112119
## MenuViewModifier

0 commit comments

Comments
 (0)