From fa367b85dd5b3cc0dc96791b19fef2306a4ceb57 Mon Sep 17 00:00:00 2001 From: Austin Smith Date: Tue, 14 Jul 2026 11:45:22 -0700 Subject: [PATCH] fade game picker edges and open scrolled to the current game ten families overflow a phone screen, and the picker's scroll fallback clipped mid-row with no cue that more games sat below the fold. fade whichever edges hide more content and land on the current game when the picker opens, so the overflow reads as scrollable instead of broken. --- .../Views/Shared/GameModePickerView.swift | 67 +++++++++++++++++-- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/ComputerSolitaire/Views/Shared/GameModePickerView.swift b/ComputerSolitaire/Views/Shared/GameModePickerView.swift index 2e40325..3f20529 100644 --- a/ComputerSolitaire/Views/Shared/GameModePickerView.swift +++ b/ComputerSolitaire/Views/Shared/GameModePickerView.swift @@ -258,6 +258,16 @@ struct GameModePickerOverlay: View { @FocusState private var isPickerFocused: Bool @AccessibilityFocusState private var isPickerAccessibilityFocused: Bool + /// Which panel edges have more content scrolled beyond them, driving the + /// edge fades. The bottom starts true because the scrolling fallback only + /// exists when the content overflows. + @State private var overflowingEdges = OverflowingEdges(top: false, bottom: true) + + private struct OverflowingEdges: Equatable { + var top: Bool + var bottom: Bool + } + var body: some View { ZStack { // The scrim is the picker's cancel button: clicking outside the @@ -270,15 +280,14 @@ struct GameModePickerOverlay: View { .buttonStyle(.plain) .accessibilityLabel("Dismiss game picker") - // Short windows (macOS near its minimum size, phone landscape, - // large accessibility text) can't fit the whole picker; fall back - // to scrolling the same content rather than clipping it. + // Short windows (a ten-family gallery on a phone, macOS near its + // minimum size, large accessibility text) can't fit the whole + // picker; fall back to scrolling the same content, opened at the + // current game and faded at whichever edges hide more of it. ViewThatFits(in: .vertical) { picker - ScrollView { - picker - } + scrollingPicker } .frame(maxWidth: 360) .background( @@ -318,6 +327,52 @@ struct GameModePickerOverlay: View { onSelect: onSelect ) } + + private var scrollingPicker: some View { + ScrollViewReader { proxy in + ScrollView { + picker + } + .onScrollGeometryChange(for: OverflowingEdges.self) { geometry in + OverflowingEdges( + top: geometry.contentOffset.y > 1, + bottom: geometry.contentOffset.y + geometry.containerSize.height + < geometry.contentSize.height - 1 + ) + } action: { _, edges in + overflowingEdges = edges + } + .mask(edgeFadeMask) + .onAppear { + // Land on the current game: a below-the-fold selection scrolls + // just into view, a visible one stays put. + proxy.scrollTo(currentMode.variant) + } + } + } + + /// Fades the content at each edge that hides more of it, so the fold + /// reads as "scroll for more" instead of an accidental clip. + private var edgeFadeMask: some View { + VStack(spacing: 0) { + LinearGradient( + colors: [.black.opacity(overflowingEdges.top ? 0 : 1), .black], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 24) + + Color.black + + LinearGradient( + colors: [.black, .black.opacity(overflowingEdges.bottom ? 0 : 1)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 24) + } + .animation(.easeInOut(duration: 0.15), value: overflowingEdges) + } } /// A miniature schematic of a variant's opening layout, drawn with tiny card