Skip to content
Merged
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
149 changes: 149 additions & 0 deletions ios/CashuWallet/Views/Components/LiquidGlassModifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,59 @@
modifier(ScreenEntryFade())
}

/// Measures a content-fit sheet's body for
/// ``contentFitDetent(_:enabled:estimate:navigationBar:)``. Apply to the body
/// itself — inside the `NavigationStack`, for the sheets that host one.
///
/// The `ScrollView` is load-bearing, not decoration. A detent derived from a
/// measurement taken inside that same sheet is a feedback loop — measured
/// height → detent → sheet height → the height proposed back to the content.
/// Since the chrome allowance is close to the real chrome, the loop is
/// *neutrally stable*: it settles wherever the first layout pass left it,
/// which during the presentation transition is roughly full-screen, and then
/// never recovers. A `ScrollView` proposes `nil` height to its content, so
/// the measured view always reports its **ideal** size no matter how tall the
/// sheet currently is — which breaks the loop.
///
/// Never hang `.onGeometryChange` off a bare view to drive a detent.
func contentFitMeasured(_ onHeight: @escaping (CGFloat) -> Void) -> some View {
ScrollView {
self.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { newHeight in
onHeight(newHeight)
}
}
.scrollBounceBehavior(.basedOnSize)
}

/// Sizes a sheet to the height reported by ``contentFitMeasured(_:)``.
/// Apply on the sheet root, *outside* any `NavigationStack` — detents can't
/// be set from within the navigation content, which is why this is a pair.
///
/// - Parameters:
/// - contentHeight: the measured body height; `0` until geometry lands.
/// - enabled: `false` falls through to `.large`, for steps that need the
/// full sheet (Send's keypad/confirm, mint discovery's scrolling list).
/// - estimate: first-frame stand-in before the measurement arrives, so the
/// sheet doesn't open tiny and then jump.
/// - navigationBar: whether the sheet hosts a `NavigationStack` with an
/// inline title. Pass `false` for a bare sheet, or its detent carries
/// 44pt of chrome for a navigation bar that isn't there.
func contentFitDetent(
_ contentHeight: CGFloat,
enabled: Bool = true,
estimate: CGFloat = ContentFitSheetMetrics.bodyEstimate,

Check warning on line 128 in ios/CashuWallet/Views/Components/LiquidGlassModifiers.swift

View workflow job for this annotation

GitHub Actions / iOS Unit, Mint Integration & UI Tests

main actor-isolated static property 'bodyEstimate' can not be referenced from a nonisolated context; this is an error in the Swift 6 language mode

Check warning on line 128 in ios/CashuWallet/Views/Components/LiquidGlassModifiers.swift

View workflow job for this annotation

GitHub Actions / iOS Unit, Mint Integration & UI Tests

main actor-isolated static property 'bodyEstimate' can not be referenced from a nonisolated context; this is an error in the Swift 6 language mode

Check warning on line 128 in ios/CashuWallet/Views/Components/LiquidGlassModifiers.swift

View workflow job for this annotation

GitHub Actions / iOS Unit, Mint Integration & UI Tests

main actor-isolated static property 'bodyEstimate' can not be referenced from a nonisolated context; this is an error in the Swift 6 language mode

Check warning on line 128 in ios/CashuWallet/Views/Components/LiquidGlassModifiers.swift

View workflow job for this annotation

GitHub Actions / iOS Unit, Mint Integration & UI Tests

main actor-isolated static property 'bodyEstimate' can not be referenced from a nonisolated context; this is an error in the Swift 6 language mode
navigationBar: Bool = true
) -> some View {
modifier(ContentFitDetent(
contentHeight: contentHeight,
enabled: enabled,
estimate: estimate,
navigationBar: navigationBar
))
}

}

// MARK: - Sheet Close Button
Expand Down Expand Up @@ -161,7 +214,7 @@
func body(content: Content) -> some View {
content.presentationBackground {
Color(uiColor: UIColor.systemBackground.resolvedColor(
with: UITraitCollection(traitsFrom: [

Check warning on line 217 in ios/CashuWallet/Views/Components/LiquidGlassModifiers.swift

View workflow job for this annotation

GitHub Actions / iOS Unit, Mint Integration & UI Tests

'init(traitsFrom:)' was deprecated in iOS 17.0: Use UITraitCollection.init(mutations:) and UITraitCollection.modifyingTraits(_:) to create and modify trait collections
UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light),
UITraitCollection(userInterfaceLevel: .base),
])
Expand All @@ -171,6 +224,102 @@
}
}

// MARK: - Content-Fit Sheet Detent

/// Pins the sheet to the newest computed height.
///
/// Handing `presentationDetents` a fresh single-element set is not enough. The
/// body's height can be reported more than once as layout settles — a transient
/// value, then the real one, milliseconds apart. UIKit resolves the new set but
/// keeps the sheet on whichever detent it had already selected, so a transient
/// measurement wins and the final one is silently ignored. Measured on an
/// iPhone 17 Pro: 0 → 469 → 241pt in 70ms, leaving the sheet stuck at the 547pt
/// detent instead of settling on 319pt.
///
/// Binding the selection alongside the set removes the race — the sheet is told
/// which detent to be on, not merely which are available.
private struct ContentFitDetent: ViewModifier {
let contentHeight: CGFloat
let enabled: Bool
let estimate: CGFloat
let navigationBar: Bool

@State private var selection: PresentationDetent = .large

private var detent: PresentationDetent {
guard enabled else { return .large }
return .height(ContentFitSheetMetrics.detentHeight(
for: contentHeight,
estimate: estimate,
hasNavigationBar: navigationBar
))
}

func body(content: Content) -> some View {
content
.presentationDetents([detent], selection: $selection)
.onAppear { selection = detent }
.onChange(of: detent) { _, newDetent in selection = newDetent }
}
}

// MARK: - Content-Fit Sheet Metrics

/// The one place the content-fit sheet arithmetic lives. Every partial-height
/// sheet in the app — Send's compact input, Receive, Add Mint, connect-a-mint,
/// onboarding's "What is ecash?" — hugs its content through
/// ``View/contentFitMeasured(_:)`` +
/// ``View/contentFitDetent(_:enabled:estimate:navigationBar:)``.
@MainActor
enum ContentFitSheetMetrics {
/// Inline navigation bar — the only chrome that actually consumes layout
/// height above the body.
///
/// Notably absent: the drag indicator. `presentationDragIndicator` draws the
/// grabber as an *overlay* over the content's own top padding, so reserving
/// height for it doesn't move the content down — it just lands as dead space
/// at the bottom of the sheet. Same for any "breathing room" fudge: each
/// sheet's body already carries its own bottom padding, and the home
/// indicator gets the safe-area inset below that.
static let navigationBar: CGFloat = 44

/// Chrome above the measured body. The bottom safe area is *not* folded in
/// here — it's 34pt on Face ID devices and 0 on home-button ones, so it's
/// resolved per device in ``detentHeight(for:estimate:hasNavigationBar:)``.
static func chrome(hasNavigationBar: Bool) -> CGFloat {
hasNavigationBar ? navigationBar : 0
}

/// First-frame stand-in before the geometry measurement lands.
static let bodyEstimate: CGFloat = 220

/// Ceiling as a fraction of the screen. Past this the body scrolls inside
/// the sheet rather than the sheet growing — at accessibility text sizes an
/// unclamped detent would silently pin the sheet to full height.
static let maxScreenFraction: CGFloat = 0.9

static func detentHeight(
for contentHeight: CGFloat,
estimate: CGFloat = bodyEstimate,

Check warning on line 303 in ios/CashuWallet/Views/Components/LiquidGlassModifiers.swift

View workflow job for this annotation

GitHub Actions / iOS Unit, Mint Integration & UI Tests

main actor-isolated static property 'bodyEstimate' can not be referenced from a nonisolated context; this is an error in the Swift 6 language mode

Check warning on line 303 in ios/CashuWallet/Views/Components/LiquidGlassModifiers.swift

View workflow job for this annotation

GitHub Actions / iOS Unit, Mint Integration & UI Tests

main actor-isolated static property 'bodyEstimate' can not be referenced from a nonisolated context; this is an error in the Swift 6 language mode
hasNavigationBar: Bool = true
) -> CGFloat {
let body = contentHeight > 0 ? contentHeight : estimate
let window = activeWindow
let wanted = body + chrome(hasNavigationBar: hasNavigationBar) + (window?.safeAreaInsets.bottom ?? 0)
// Read the ceiling from the *screen*, never from the sheet's own
// geometry — the latter would reintroduce the feedback loop this whole
// mechanism exists to avoid.
guard let screenHeight = window?.screen.bounds.height, screenHeight > 0 else { return wanted }
return min(wanted, screenHeight * maxScreenFraction)
}

private static var activeWindow: UIWindow? {
let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
let scene = scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
return scene?.keyWindow ?? scene?.windows.first
}
}

// MARK: - Canvas Divider

/// Hairline divider used between rows on the single-canvas screens
Expand Down
18 changes: 7 additions & 11 deletions ios/CashuWallet/Views/Main/OnboardingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,8 @@ struct OnboardingView: View {
// First-mint state (create path)
@State private var showConceptSheet = false
/// Measured height of `conceptSheet`, so the sheet hugs its content instead
/// of sitting at a fixed `.medium` detent. Seeded with a plausible value so
/// the first layout pass is close; corrected on measure.
@State private var conceptSheetHeight: CGFloat = 360
/// of sitting at a fixed `.medium` detent.
@State private var conceptSheetHeight: CGFloat = 0
@State private var selectedMintUrls: Set<String> = []
@State private var customMintUrls: [String] = []
@State private var showCustomMintInput = false
Expand Down Expand Up @@ -311,14 +310,11 @@ struct OnboardingView: View {
// (~107pt on iPhone 17e, ~134pt on iPhone 11) rather than a designed
// value. Measuring keeps the copy-to-button gap a constant 20pt and
// matches Android, whose sheet already hugs its content.
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { height in
conceptSheetHeight = height
}
// `.large` stays available so very large accessibility text can still
// expand past the measured height rather than clip.
.presentationDetents([.height(conceptSheetHeight), .large])
.contentFitMeasured { conceptSheetHeight = $0 }
// No `NavigationStack` here, so the detent must not reserve nav-bar
// chrome. Very large accessibility text scrolls inside the clamped
// sheet — the same contract as every other content-fit sheet.
.contentFitDetent(conceptSheetHeight, estimate: 360, navigationBar: false)
.presentationDragIndicator(.visible)
}

Expand Down
13 changes: 3 additions & 10 deletions ios/CashuWallet/Views/Mints/AddMintSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,7 @@ struct AddMintFormView: View {
.padding(.horizontal, 20)
.padding(.top, 8)
.padding(.bottom, 16)
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { newHeight in
onHeightChange(newHeight)
}
.contentFitMeasured { onHeightChange($0) }
.navigationTitle("Add Mint")
.navigationBarTitleDisplayMode(.inline)
// Sheet, not fullScreenCover — the one presentation kind every
Expand Down Expand Up @@ -195,10 +191,7 @@ struct AddMintFormView: View {
struct AddMintSheet: View {
@Environment(\.dismiss) private var dismiss

@State private var contentHeight: CGFloat = 260

/// Matches the connect-a-mint sheet's chrome allowance.
private let sheetChrome: CGFloat = 108
@State private var contentHeight: CGFloat = 0

var body: some View {
NavigationStack {
Expand All @@ -208,7 +201,7 @@ struct AddMintSheet: View {
)
}
// Hugs the form, like every other content-fit sheet in the app.
.presentationDetents([.height(contentHeight + sheetChrome)])
.contentFitDetent(contentHeight, estimate: 260)
.presentationDragIndicator(.visible)
}
}
Expand Down
92 changes: 40 additions & 52 deletions ios/CashuWallet/Views/Mints/ConnectMintView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,60 +66,53 @@ struct ConnectMintPicker: View {
var onHeightChange: (CGFloat) -> Void = { _ in }

var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
if context.showsHeadline {
Text(ConnectMintContext.headline)
.font(.title3.weight(.medium))
.padding(.bottom, ConnectMintMetrics.headlineToSubtitle)
}
VStack(alignment: .leading, spacing: 0) {
if context.showsHeadline {
Text(ConnectMintContext.headline)
.font(.title3.weight(.medium))
.padding(.bottom, ConnectMintMetrics.headlineToSubtitle)
}

Text(ConnectMintContext.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
Text(ConnectMintContext.subtitle)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)

SuggestedMintsSection(existingURLs: existingURLs, onAdd: onAdd)
SuggestedMintsSection(existingURLs: existingURLs, onAdd: onAdd)

if let errorMessage {
InlineNotice(message: errorMessage, severity: .error)
.padding(.top, ConnectMintMetrics.footerSpacing)
}
if let errorMessage {
InlineNotice(message: errorMessage, severity: .error)
.padding(.top, ConnectMintMetrics.footerSpacing)
}

// Spacing lives in each label's vertical padding so the links keep
// 44pt hit targets; stacking them with plain spacing would leave
// ~20pt-tall taps.
VStack(spacing: 0) {
// Spacing lives in each label's vertical padding so the links keep
// 44pt hit targets; stacking them with plain spacing would leave
// ~20pt-tall taps.
VStack(spacing: 0) {
footerLink(
title: "Add custom mint URL",
systemImage: "plus",
route: .addCustom
)
// Discovery rides Nostr relays over WebSockets. With the
// setting off it can only show a "turn this on" dead end, so
// it isn't offered at all.
if discoveryAvailable {
footerLink(
title: "Add custom mint URL",
systemImage: "plus",
route: .addCustom
title: "Discover mints",
systemImage: "magnifyingglass",
route: .discover
)
// Discovery rides Nostr relays over WebSockets. With the
// setting off it can only show a "turn this on" dead end, so
// it isn't offered at all.
if discoveryAvailable {
footerLink(
title: "Discover mints",
systemImage: "magnifyingglass",
route: .discover
)
}
}
// The first link carries 12pt of its own padding; net gap is the
// designed 20pt.
.padding(.top, ConnectMintMetrics.rowsToFooter - ConnectMintMetrics.footerSpacing)
}
.padding(.horizontal, ConnectMintMetrics.gutter)
.padding(.top, 8)
.padding(.bottom, 16)
.onGeometryChange(for: CGFloat.self) { proxy in
proxy.size.height
} action: { newHeight in
onHeightChange(newHeight)
}
// The first link carries 12pt of its own padding; net gap is the
// designed 20pt.
.padding(.top, ConnectMintMetrics.rowsToFooter - ConnectMintMetrics.footerSpacing)
}
.scrollBounceBehavior(.basedOnSize)
.padding(.horizontal, ConnectMintMetrics.gutter)
.padding(.top, 8)
.padding(.bottom, 16)
.contentFitMeasured { onHeightChange($0) }
}

@ViewBuilder
Expand Down Expand Up @@ -261,12 +254,9 @@ struct ConnectMintSheet: View {
@ObservedObject private var settings = SettingsManager.shared

@State private var route: ConnectMintRoute?
@State private var contentHeight: CGFloat = 220
@State private var contentHeight: CGFloat = 0
@State private var addMintError: String?

/// Matches the Send sheet's compact-detent chrome allowance.
private let sheetChrome: CGFloat = 108

var body: some View {
NavigationStack {
ConnectMintPicker(
Expand Down Expand Up @@ -296,9 +286,7 @@ struct ConnectMintSheet: View {
// Both the shortlist and the pushed URL step hug their content, matching
// Android. Only discovery fills the sheet — it hosts a scrolling list and
// needs bounded height.
.presentationDetents(
route == .discover ? [.large] : [.height(contentHeight + sheetChrome)]
)
.contentFitDetent(contentHeight, enabled: route != .discover)
.presentationDragIndicator(.visible)
// Hugging the shortlist, this floats over the canvas and keeps the
// system's elevated background; only the pushed full-height steps adopt
Expand Down
Loading
Loading