crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

commit 778814baa99dd52eb942f2a63b1d2cd36667f49f
parent 6ea98d378d7dcb99deb2edb1b98b415c53778cf9
Author: Michael Camilleri <[email protected]>
Date:   Sat,  4 Jul 2026 06:31:44 +0900

Position the Clue List sidebar correctly on its first frame

On iPad, returning to Crossmate from another app drew the Clue List
sidebar scrolled to the top for a frame before it jumped to the clue
under the cursor. The sidebar only positioned itself after appearing —
a ScrollViewReader scrolled to the current clue from onAppear and
scene-phase changes, by which time the top-scrolled frame was already
on screen.

This commit replaces the ScrollViewReader with a scrollPosition(id:)
binding whose backing state is seeded in ClueList's initialiser with
the current clue's row ID. When the sidebar is recreated — the pad
layout is rebuilt around app switches — SwiftUI resolves that position
during the initial layout pass, so the first visible frame is already
centred on the current clue. Selecting another clue still animates the
list to it as before. The sheet presentation keeps the ScrollViewReader
approach because List does not support scrollPosition(id:).

Co-Authored-By: Claude Fable 5 <[email protected]>

Diffstat:
MCrossmate/Views/Puzzle/ClueList.swift | 88++++++++++++++++++++++++++++++++++++++++++-------------------------------------
1 file changed, 47 insertions(+), 41 deletions(-)

diff --git a/Crossmate/Views/Puzzle/ClueList.swift b/Crossmate/Views/Puzzle/ClueList.swift @@ -4,6 +4,10 @@ struct ClueList: View { @Bindable var session: PlayerSession var presentation: Presentation = .sheet var replayFrame: ReplayFrame? + // Seeded with the current clue so the sidebar's first layout pass already + // has the right scroll position; scrolling to it after the fact draws one + // frame at the top and visibly jumps. + @State private var sidebarPosition: String? @Environment(PlayerPreferences.self) private var preferences @Environment(\.dismiss) private var dismiss @Environment(\.scenePhase) private var scenePhase @@ -13,6 +17,19 @@ struct ClueList: View { case sidebar } + init( + session: PlayerSession, + presentation: Presentation = .sheet, + replayFrame: ReplayFrame? = nil + ) { + self.session = session + self.presentation = presentation + self.replayFrame = replayFrame + _sidebarPosition = State( + initialValue: Self.currentDisplay(session: session, replayFrame: replayFrame).currentID + ) + } + private var currentClueBackground: Color { preferences.color.authorTintFill } @@ -42,7 +59,7 @@ struct ClueList: View { @ViewBuilder private var clueList: some View { - let currentDisplay = replayClueDisplay ?? liveClueDisplay + let currentDisplay = Self.currentDisplay(session: session, replayFrame: replayFrame) let current = currentDisplay.clue let currentDirection = currentDisplay.direction let currentID = currentDisplay.currentID @@ -116,37 +133,29 @@ struct ClueList: View { currentDirection: Puzzle.Direction, currentID: String? ) -> some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(alignment: .leading, spacing: 0) { - sidebarSectionHeader("Across") - ForEach(session.puzzle.acrossClues) { clue in - sidebarRow(for: clue, direction: .across, current: current, currentDirection: currentDirection) - .id(rowID(direction: .across, number: clue.number)) - } - - sidebarSectionHeader("Down") - .padding(.top, 12) - ForEach(session.puzzle.downClues) { clue in - sidebarRow(for: clue, direction: .down, current: current, currentDirection: currentDirection) - .id(rowID(direction: .down, number: clue.number)) - } + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + sidebarSectionHeader("Across") + ForEach(session.puzzle.acrossClues) { clue in + sidebarRow(for: clue, direction: .across, current: current, currentDirection: currentDirection) + .id(rowID(direction: .across, number: clue.number)) } - .padding(.vertical, 10) - } - .onAppear { - guard let currentID else { return } - proxy.scrollTo(currentID, anchor: .center) - } - .onChange(of: currentID) { _, newID in - guard let newID else { return } - withAnimation(.easeInOut(duration: 0.2)) { - proxy.scrollTo(newID, anchor: .center) + + sidebarSectionHeader("Down") + .padding(.top, 12) + ForEach(session.puzzle.downClues) { clue in + sidebarRow(for: clue, direction: .down, current: current, currentDirection: currentDirection) + .id(rowID(direction: .down, number: clue.number)) } } - .onChange(of: scenePhase) { _, newPhase in - guard newPhase == .active, let currentID else { return } - proxy.scrollTo(currentID, anchor: .center) + .scrollTargetLayout() + .padding(.vertical, 10) + } + .scrollPosition(id: $sidebarPosition, anchor: .center) + .onChange(of: currentID) { _, newID in + guard let newID else { return } + withAnimation(.easeInOut(duration: 0.2)) { + sidebarPosition = newID } } } @@ -193,18 +202,15 @@ struct ClueList: View { "\(direction == .across ? "A" : "D")-\(number)" } - private var liveClueDisplay: ClueDisplay { - ClueDisplay(clue: session.currentClue(), direction: session.direction) - } - - private var replayClueDisplay: ClueDisplay? { - guard let position = replayFrame?.cursor, - let direction = replayFrame?.cursorDirection - else { return nil } - return ClueDisplay( - clue: session.puzzle.clue(atRow: position.row, col: position.col, direction: direction), - direction: direction - ) + private static func currentDisplay(session: PlayerSession, replayFrame: ReplayFrame?) -> ClueDisplay { + if let position = replayFrame?.cursor, + let direction = replayFrame?.cursorDirection { + return ClueDisplay( + clue: session.puzzle.clue(atRow: position.row, col: position.col, direction: direction), + direction: direction + ) + } + return ClueDisplay(clue: session.currentClue(), direction: session.direction) } private struct ClueDisplay {