crossmate

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

commit bf4f669959850159855f88515cdda2d066548b0d
parent c4e6d5121eec126b9e109ddde7e630a0a6c9e98d
Author: Michael Camilleri <[email protected]>
Date:   Sat,  4 Jul 2026 10:06:25 +0900

Preserve the Clue List position across app switches

On iPad, returning to Crossmate from another app could move the Clue
List even though the cursor and selected clue had not changed. The
previous path tried to recover by rebuilding the sidebar's scroll
position from the current clue, but that still fought the user's actual
scroll offset and could produce visible movement on foregrounding.

This commit keeps the puzzle layout at its last active size while the
scene is inactive or backgrounded, so app-switcher snapshot geometry
cannot resize the Clue List underneath SwiftUI's scroll view. The
sidebar returns to the simpler ScrollViewReader path for first appear
and clue changes; ordinary foreground scrolling is left alone, so the
list comes back where the user left it.

Co-Authored-By: Codex GPT 5.5 <[email protected]>

Diffstat:
MCrossmate/Views/Puzzle/ClueList.swift | 61++++++++++++++++++++++++-------------------------------------
MCrossmate/Views/Puzzle/PuzzleView.swift | 4++++
2 files changed, 28 insertions(+), 37 deletions(-)

diff --git a/Crossmate/Views/Puzzle/ClueList.swift b/Crossmate/Views/Puzzle/ClueList.swift @@ -4,10 +4,6 @@ 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 @@ -17,19 +13,6 @@ 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 } @@ -133,29 +116,33 @@ struct ClueList: View { currentDirection: Puzzle.Direction, currentID: String? ) -> some View { - 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)) - } + ScrollViewReader { proxy in + ScrollView { + VStack(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)) + 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)) + } } + .padding(.vertical, 10) } - .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 + .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) + } } } } diff --git a/Crossmate/Views/Puzzle/PuzzleView.swift b/Crossmate/Views/Puzzle/PuzzleView.swift @@ -217,6 +217,10 @@ struct PuzzleView: View { isConfirmingReveal = true } )) + .frame( + width: scenePhase == .active || lastActiveSize == .zero ? nil : lastActiveSize.width, + height: scenePhase == .active || lastActiveSize == .zero ? nil : lastActiveSize.height + ) .onGeometryChange(for: CGSize.self) { proxy in proxy.size } action: { newSize in