diff --git a/Sources/DashUIKit/Components/AddressFieldView.swift b/Sources/DashUIKit/Components/AddressFieldView.swift index 0278960..4e55a32 100644 --- a/Sources/DashUIKit/Components/AddressFieldView.swift +++ b/Sources/DashUIKit/Components/AddressFieldView.swift @@ -17,17 +17,19 @@ #if canImport(UIKit) import SwiftUI +/// Outside the view, not nested in it: `AddressFieldView` is generic over its +/// accessory, and a generic type cannot hold static stored properties. +private enum Layout { + static let hSpacing: CGFloat = 20 + static let lPadding: CGFloat = 20 + static let tPadding: CGFloat = 10 + static let iconSize: CGFloat = 17 + static let cornerRadius: CGFloat = 16 + static let actionTapArea: CGFloat = 40 +} + @available(iOS 15, macOS 12, *) -public struct AddressFieldView: View { - - private enum Layout { - static let hSpacing: CGFloat = 20 - static let lPadding: CGFloat = 20 - static let tPadding: CGFloat = 10 - static let iconSize: CGFloat = 17 - static let cornerRadius: CGFloat = 16 - static let actionTapArea: CGFloat = 40 - } +public struct AddressFieldView: View { @Binding private var text: String private let label: String @@ -37,6 +39,11 @@ public struct AddressFieldView: View { private var isDisabled: Bool private var onScanQR: (() -> Void)? private var onPaste: (() -> Void)? + /// Trailing content on the label row — a badge naming what the entered + /// address turned out to be, say. Sits opposite `label`, so it is for + /// something that describes the field rather than acts on it; the + /// controls that act live inside the field itself. + private let accessory: Accessory @FocusState private var isTextFieldFocused: Bool @@ -48,7 +55,8 @@ public struct AddressFieldView: View { errorText: String? = nil, isDisabled: Bool = false, onScanQR: (() -> Void)? = nil, - onPaste: (() -> Void)? = nil + onPaste: (() -> Void)? = nil, + @ViewBuilder accessory: () -> Accessory ) { self._text = text self.label = label @@ -58,14 +66,21 @@ public struct AddressFieldView: View { self.isDisabled = isDisabled self.onScanQR = onScanQR self.onPaste = onPaste + self.accessory = accessory() } public var body: some View { VStack(alignment: .leading, spacing: 10) { - Text(label) - .dashFont(.footnote) - .foregroundStyle(Color.dash.gray500) - .frame(maxWidth: .infinity, alignment: .leading) + HStack(spacing: 8) { + Text(label) + .dashFont(.footnote) + .foregroundStyle(Color.dash.gray500) + + Spacer(minLength: 0) + + accessory + } + .frame(maxWidth: .infinity, alignment: .leading) HStack(alignment: .center, spacing: Layout.hSpacing) { textField @@ -101,6 +116,37 @@ public struct AddressFieldView: View { } } +} + +@available(iOS 15, macOS 12, *) +public extension AddressFieldView where Accessory == EmptyView { + /// No label accessory — the original shape, unchanged for callers that + /// have nothing to put there. + init( + text: Binding, + label: String, + placeholder: String, + hasError: Bool, + errorText: String? = nil, + isDisabled: Bool = false, + onScanQR: (() -> Void)? = nil, + onPaste: (() -> Void)? = nil + ) { + self.init( + text: text, + label: label, + placeholder: placeholder, + hasError: hasError, + errorText: errorText, + isDisabled: isDisabled, + onScanQR: onScanQR, + onPaste: onPaste, + accessory: { EmptyView() }) + } +} + +@available(iOS 15, macOS 12, *) +extension AddressFieldView { // MARK: - Subviews private var showsPasteButton: Bool { @@ -292,5 +338,31 @@ public struct AddressFieldView: View { .padding() } +@available(iOS 17, macOS 14, *) +#Preview("Label accessory") { + AddressFieldView( + text: .constant("yV1D1ivvSUyKPJnbFmzSTVh1MyZ3JbeVkY"), + label: "Address", + placeholder: "Dash address", + hasError: false + ) { + // What the host puts here is its own: a badge naming the kind of + // address that was entered, decided by the host's own decoder. + HStack(spacing: 4) { + Image(systemName: "d.circle.fill") + .font(.system(size: 10, weight: .semibold)) + Text("Transparent address") + .dashFont(.caption2) + } + .foregroundStyle(Color.dash.blueText) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.dash.blueAlpha10) + .clipShape(Capsule()) + } + .padding() + .background(Color.dash.primaryBackground) +} + #endif #endif // canImport(UIKit) diff --git a/Sources/DashUIKit/Components/BottomSheet.swift b/Sources/DashUIKit/Components/BottomSheet.swift index 1c96b2b..e5b992d 100644 --- a/Sources/DashUIKit/Components/BottomSheet.swift +++ b/Sources/DashUIKit/Components/BottomSheet.swift @@ -17,6 +17,11 @@ public struct BottomSheet: View { /// when natural sizing is needed — it guarantees `fillsHeight: false` and the modifier are /// always applied together. public var fillsHeight: Bool = true + /// Fill behind the whole sheet — grabber, header and content alike. Also + /// used as the presentation background so the home-indicator inset the + /// detent adds matches; a host that only restyles its own content would + /// otherwise get a strip of this colour along the bottom edge. + public var background: Color = .dash.primaryBackground @ViewBuilder public var content: () -> Content public init( @@ -24,12 +29,14 @@ public struct BottomSheet: View { showBackButton: Binding, onBackButtonPressed: (() -> Void)? = nil, fillsHeight: Bool = true, + background: Color = .dash.primaryBackground, @ViewBuilder content: @escaping () -> Content ) { self.title = title self._showBackButton = showBackButton self.onBackButtonPressed = onBackButtonPressed self.fillsHeight = fillsHeight + self.background = background self.content = content } @@ -42,7 +49,7 @@ public struct BottomSheet: View { contentSection } - .background(Color.dash.primaryBackground) + .background(background) if fillsHeight { sheet.edgesIgnoringSafeArea(.bottom) @@ -103,13 +110,13 @@ public struct BottomSheet: View { .navigationBarHidden(true) #endif .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.dash.primaryBackground) + .background(background) } } else { // Natural height — no greedy NavigationView / maxHeight so the sheet can self-size. content() .frame(maxWidth: .infinity) - .background(Color.dash.primaryBackground) + .background(background) } } } @@ -134,6 +141,7 @@ public extension BottomSheet { onBackButtonPressed: (() -> Void)? = nil, fallback: CGFloat = 0, maxHeightFraction: CGFloat = 0.95, + background: Color = .dash.primaryBackground, cornerRadius: CGFloat? = nil, @ViewBuilder content: @escaping () -> Content ) -> some View { @@ -142,9 +150,14 @@ public extension BottomSheet { showBackButton: showBackButton, onBackButtonPressed: onBackButtonPressed, fillsHeight: false, + background: background, content: content ) - .selfSizingSheet(fallback: fallback, maxHeightFraction: maxHeightFraction, cornerRadius: cornerRadius) + .selfSizingSheet( + fallback: fallback, + maxHeightFraction: maxHeightFraction, + background: background, + cornerRadius: cornerRadius) } } @@ -163,28 +176,34 @@ public extension View { /// - fallback: Height used before the first measurement (avoids a `.medium` flash). /// - maxHeightFraction: Caps the sheet at this fraction of the window height; taller content /// is clipped, so wrap it in a `ScrollView`. + /// - background: Fill for the sheet and its presentation, so the bottom + /// safe-area strip matches the content. Defaults to the sheet's own. /// - cornerRadius: Optional corner radius applied via `presentationCornerRadius` on - /// iOS 16.4..<26 (iOS 26+ keeps the system corner styling). When provided, the sheet - /// background is also filled so the bottom safe-area strip matches the content. + /// iOS 16.4..<26 (iOS 26+ keeps the system corner styling). @ViewBuilder func selfSizingSheet( fallback: CGFloat = 0, maxHeightFraction: CGFloat = 0.95, + background: Color = .dash.primaryBackground, cornerRadius: CGFloat? = nil ) -> some View { if #available(iOS 16.0, macOS 13.0, *) { let modified = modifier(SelfSizingSheetModifier(fallback: fallback, maxHeightFraction: maxHeightFraction)) #if os(iOS) - if #available(iOS 16.4, *), let cornerRadius { - if #unavailable(iOS 26.0) { - // iOS 16.4..<26: apply the custom corner radius + fill the sheet background. + if #available(iOS 16.4, *) { + // The background is filled whatever the corner radius: the + // measured height excludes the home-indicator inset that + // `.presentationDetents([.height])` adds back, so that strip + // sits outside the sheet's own `VStack` and shows the system + // background unless this fills it. + if #unavailable(iOS 26.0), let cornerRadius { modified .presentationCornerRadius(cornerRadius) - .presentationBackground(Color.dash.primaryBackground) + .presentationBackground(background) } else { - // iOS 26+: keep the system corner styling, just fill the background. + // iOS 26+ keeps the system corner styling. modified - .presentationBackground(Color.dash.primaryBackground) + .presentationBackground(background) } } else { modified @@ -283,3 +302,25 @@ private struct SelfSizingSheetModifier: ViewModifier { .padding() } } + +@available(iOS 17, macOS 14, *) +#Preview("BottomSheet Custom Background") { + BottomSheet( + title: "Bottom Sheet", + showBackButton: .constant(false), + fillsHeight: false, + background: .dash.secondaryBackground + ) { + VStack(alignment: .leading, spacing: 12) { + Text("Cards on a tinted sheet") + .dashFont(.calloutMedium) + .foregroundColor(.dash.primaryText) + + Text("The host picks the fill; cards drawn on top keep their own.") + .dashFont(.body) + .foregroundColor(.dash.secondaryText) + .modifier(MenuViewModifier()) + } + .padding() + } +} diff --git a/docs/navigation-and-containers.md b/docs/navigation-and-containers.md index 6b71640..4d01b03 100644 --- a/docs/navigation-and-containers.md +++ b/docs/navigation-and-containers.md @@ -70,7 +70,8 @@ Sheet chrome to put **inside** a SwiftUI `.sheet { }`: a grabber, a `NavigationB title: "Details", showBackButton: $showBack, // Binding onBackButtonPressed: { /* pop */ }, - fillsHeight: true // greedy: fills the sheet + fillsHeight: true, // greedy: fills the sheet + background: .dash.primaryBackground // fill behind grabber, header and content ) { MyContent() } @@ -94,6 +95,7 @@ the modifier are applied together: showBackButton: .constant(false), fallback: 240, // height before first measurement (avoids .medium flash) maxHeightFraction: 0.95, // cap at 95% of window height (clip taller → use ScrollView) + background: .dash.secondaryBackground, // also fills the home-indicator strip cornerRadius: 24 // iOS 16.4..<26; iOS 26+ keeps system corners ) { MyContent() @@ -107,6 +109,11 @@ a **no-op below iOS 16**. The measured content must have a finite intrinsic heig greedy `Spacer`/`maxHeight: .infinity`), or the measurement is wrong. `BottomSheetHeightPreferenceKey` is exposed for advanced cases. +`background` fills the sheet **and** its presentation. The measured height excludes the +home-indicator inset that `presentationDetents([.height])` adds back, so that strip lies +outside the sheet's own stack — without the presentation fill it shows the system +background as a pale band along the bottom edge, whatever the content is styled with. + --- ## MenuViewModifier