diff --git a/Bedtime/Bedtime/Constants.swift b/Bedtime/Bedtime/Constants.swift index 358a686..529362e 100644 --- a/Bedtime/Bedtime/Constants.swift +++ b/Bedtime/Bedtime/Constants.swift @@ -61,4 +61,10 @@ class Constants { static func sleepDurationColor(hours: Double, goal: Double, graceColor: Color) -> Color { sleepGoalColor(difference: hours - goal, graceColor: graceColor) } + + /// Named coordinate space for the app's main scroll view. Descendants can measure their + /// position relative to it (`.named(mainScrollCoordinateSpaceName)`) to reliably track how + /// far they are from the top/bottom of the visible viewport as the user scrolls—unlike + /// `.global` frames, which don't update consistently during interactive scrolling. + static let mainScrollCoordinateSpaceName = "mainScrollView" } diff --git a/Bedtime/Bedtime/ContentView.swift b/Bedtime/Bedtime/ContentView.swift index c3044d4..4f756d6 100644 --- a/Bedtime/Bedtime/ContentView.swift +++ b/Bedtime/Bedtime/ContentView.swift @@ -18,6 +18,7 @@ struct ContentView: View { @State private var showingSettings = false @State private var showingError = false @State private var error: Error? + @State private var scrollViewportSize: CGSize = .zero init() { let sourcePrefs = SourcePreferences() @@ -187,6 +188,13 @@ struct ContentView: View { .frame(maxWidth: 600) .frame(maxWidth: .infinity) } + .coordinateSpace(name: Constants.mainScrollCoordinateSpaceName) + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { _, newValue in + scrollViewportSize = newValue + } + .environment(\.scrollViewportSize, scrollViewportSize) .environment(\.durationDisplayStyle, userPreferences.durationDisplayStyle) .background(Color.backgroundBehindCards) .navigationTitle("Bedger") diff --git a/Bedtime/Bedtime/Views/SleepBankCard.swift b/Bedtime/Bedtime/Views/SleepBankCard.swift index 034daa1..212c236 100644 --- a/Bedtime/Bedtime/Views/SleepBankCard.swift +++ b/Bedtime/Bedtime/Views/SleepBankCard.swift @@ -7,6 +7,20 @@ import SwiftUI +private struct ScrollViewportSizeKey: EnvironmentKey { + static let defaultValue: CGSize = .zero +} + +extension EnvironmentValues { + /// Size of the main scroll view's viewport, published by `ContentView` via + /// `onGeometryChange`. Lets a card reason about how much room is above/below it on + /// screen without relying on `.global` frames, which don't track reliably during scroll. + var scrollViewportSize: CGSize { + get { self[ScrollViewportSizeKey.self] } + set { self[ScrollViewportSizeKey.self] = newValue } + } +} + struct SleepBankCard: View { /// Balance over the selected range, which the Average / Status / Goal columns report. let sleepBank: SleepBank @@ -17,8 +31,18 @@ struct SleepBankCard: View { /// When provided, the goal is tappable and edits this value directly. var sleepGoalHours: Binding? = nil @Environment(\.durationDisplayStyle) private var durationStyle + @Environment(\.scrollViewportSize) private var scrollViewportSize @State private var isEditingGoal = false + /// The goal button's frame relative to the main scroll view's viewport, kept up to date + /// by `onGeometryChange` (including while the user is actively scrolling). + @State private var goalButtonFrame: CGRect = .zero + + /// Rough on-screen height of the goal popover's wheel picker plus its popover chrome, + /// used to decide whether there's enough room above the button to open upward. The wheel + /// picker itself is a fixed system height (~190pt even after the editor's negative + /// padding), so this pads that out a bit for the popover's own arrow/margins. + private static let goalPopoverEstimatedHeight: CGFloat = 210 private var formattedGoal: String { TimeFormatter.formatHours( @@ -125,7 +149,15 @@ struct SleepBankCard: View { .accessibilityLabel("Sleep goal") .accessibilityValue(formattedGoal) .accessibilityHint("Adjusts your nightly sleep goal") - .popover(isPresented: $isEditingGoal, arrowEdge: .bottom) { + .onGeometryChange(for: CGRect.self) { proxy in + proxy.frame(in: .named(Constants.mainScrollCoordinateSpaceName)) + } action: { _, newValue in + goalButtonFrame = newValue + } + // Prefers opening above (arrowEdge .bottom) so it never covers the chart below, + // falling back to opening below only when there truly isn't room above—e.g. when + // this card is scrolled near the very top of the screen. + .popover(isPresented: $isEditingGoal, arrowEdge: goalPopoverArrowEdge) { SleepGoalEditor(goalHours: sleepGoalHours) .frame(maxWidth: 150) .presentationCompactAdaptation(.popover) @@ -135,6 +167,18 @@ struct SleepBankCard: View { } } + private var goalPopoverArrowEdge: Edge { + guard scrollViewportSize != .zero, goalButtonFrame != .zero else { return .bottom } + + let spaceAbove = goalButtonFrame.minY + let spaceBelow = scrollViewportSize.height - goalButtonFrame.maxY + + if spaceAbove < Self.goalPopoverEstimatedHeight && spaceBelow > spaceAbove { + return .top + } + return .bottom + } + private func goalLabel(showsEditAffordance: Bool) -> some View { VStack(alignment: .trailing, spacing: 2) { Text("Goal") @@ -169,6 +213,7 @@ private struct SleepBankCardPreview: View { @State private var days = 7 @State private var goalHours = 8.0 + @State private var scrollViewportSize: CGSize = .zero private var allNights: [NightSummary] { let calendar = Calendar.current @@ -204,6 +249,13 @@ private struct SleepBankCardPreview: View { ) .padding() } + .coordinateSpace(name: Constants.mainScrollCoordinateSpaceName) + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { _, newValue in + scrollViewportSize = newValue + } + .environment(\.scrollViewportSize, scrollViewportSize) .background(Color.backgroundBehindCards) } }