crossmate

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

commit 17d54c154f1549bb2295713752933ef6da59bfea
parent 6ecdf25204cf657953f53fd4bd87a7c039ad4057
Author: Michael Camilleri <[email protected]>
Date:   Sat,  6 Jun 2026 05:50:43 +0900

Keep puzzle grid layers aligned on iPad

The peer cursor tint split moved the high-frequency remote selection
state into a backing Canvas, but that Canvas was attached with
.background. On iPad-sized layouts SwiftUI can hand a background a
different drawing rect from the custom cell layout, so both layers ran
the same PuzzleGridGeometry math against different sizes. The error is
subtle on iPhone because the proposals are tight, but on iPad the peer
tint cells visibly drift from the letters above them.

This commit wraps the backing Canvases and the cell layout in a single
custom layout so they share one measured surface. The static backdrop
and remote tint layers draw from that surface, while the cell grid is
placed on the exact grid rect derived from it. The tint Canvas remains
isolated from the 441-cell ForEach, preserving the performance win from
the original layering change.

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

Diffstat:
MCrossmate/Views/GridView.swift | 160++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
1 file changed, 110 insertions(+), 50 deletions(-)

diff --git a/Crossmate/Views/GridView.swift b/Crossmate/Views/GridView.swift @@ -53,60 +53,55 @@ struct GridView: View { col: session.selectedCol, direction: session.direction ).map { GridPosition(row: $0.row, col: $0.col) }) - PuzzleGridLayout(columns: width, rows: height, spacing: spacing) { - ForEach(0..<(width * height), id: \.self) { index in - let r = index / width - let c = index % width - let pos = GridPosition(row: r, col: c) - let square = session.game.squares[r][c] - // During replay the cell's letter/mark/author come from the - // reconstructed history, not the live square. - let replayCell = replayCells?[pos] - let entry = isReplaying ? (replayCell?.letter ?? "") : square.entry - let mark = isReplaying ? (replayCell?.mark ?? .none) : square.mark - let letterAuthorID = isReplaying ? replayCell?.cellAuthorID : square.letterAuthorID - CellView( - cell: session.puzzle.cells[r][c], - entry: entry, - mark: mark, - isSelected: isReplaying - ? replayCursor == pos - : (session.selectedRow == r && session.selectedCol == c), - isHighlighted: currentWordCells.contains(pos), - crossRefPattern: cellGroups[pos].map { - patternPalette[$0 % patternPalette.count] - }, - gridRow: r, - gridCol: c, - gridSpacing: spacing, - isRelatedToFocus: relatedCells.contains(pos), - authorTint: entry.isEmpty - ? nil - : letterAuthorID.flatMap { authorTintByID[$0] }, - selectionTint: replayCursor == pos ? playheadTint : nil - ) - .equatable() - .onTapGesture { - guard !isReplaying else { return } - session.select(row: r, col: c) - } - } - } - // Layered behind the cells, back to front: black (shows through the - // inter-cell gaps and behind blocks) → white cell backdrop → peer - // cursor tints. The cells draw with a clear base so these show through. - // Each layer reads its own observable state in its own body, so peer - // cursor moves repaint only the lightweight `RemoteCursorTints` Canvas - // and never invalidate the cell `ForEach` above. - .background(alignment: .center) { + // Layered back to front: black (shows through the inter-cell gaps and + // behind blocks) -> white cell backdrop -> peer cursor tints -> cells. + // Keep these as siblings in one layout so iPad-sized proposals cannot + // give the backing Canvases a different drawing rect than the cells. + PuzzleGridLayerLayout(columns: width, rows: height, spacing: spacing) { + GridBackdrop(puzzle: session.puzzle, spacing: spacing) if showsRemoteTints { RemoteCursorTints(roster: roster, puzzle: session.puzzle, spacing: spacing) } + PuzzleGridLayout(columns: width, rows: height, spacing: spacing) { + ForEach(0..<(width * height), id: \.self) { index in + let r = index / width + let c = index % width + let pos = GridPosition(row: r, col: c) + let square = session.game.squares[r][c] + // During replay the cell's letter/mark/author come from the + // reconstructed history, not the live square. + let replayCell = replayCells?[pos] + let entry = isReplaying ? (replayCell?.letter ?? "") : square.entry + let mark = isReplaying ? (replayCell?.mark ?? .none) : square.mark + let letterAuthorID = isReplaying ? replayCell?.cellAuthorID : square.letterAuthorID + CellView( + cell: session.puzzle.cells[r][c], + entry: entry, + mark: mark, + isSelected: isReplaying + ? replayCursor == pos + : (session.selectedRow == r && session.selectedCol == c), + isHighlighted: currentWordCells.contains(pos), + crossRefPattern: cellGroups[pos].map { + patternPalette[$0 % patternPalette.count] + }, + gridRow: r, + gridCol: c, + gridSpacing: spacing, + isRelatedToFocus: relatedCells.contains(pos), + authorTint: entry.isEmpty + ? nil + : letterAuthorID.flatMap { authorTintByID[$0] }, + selectionTint: replayCursor == pos ? playheadTint : nil + ) + .equatable() + .onTapGesture { + guard !isReplaying else { return } + session.select(row: r, col: c) + } + } + } } - .background(alignment: .center) { - GridBackdrop(puzzle: session.puzzle, spacing: spacing) - } - .background(Color.black) } } @@ -204,6 +199,64 @@ private struct PuzzleGridLayout: Layout { } } +/// Stacks the grid's backing layers and cell layout into one measured surface. +/// Using `.background` for the Canvases can let SwiftUI hand those layers a +/// different size from the custom cell layout on iPad, which makes their +/// derived rects drift. This layout makes the Canvases draw from the same +/// surface size and places the cell grid at the exact rect derived from it. +private struct PuzzleGridLayerLayout: Layout { + let columns: Int + let rows: Int + let spacing: CGFloat + + func sizeThatFits( + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) -> CGSize { + let cellSize = PuzzleGridMetrics.cellSize( + for: proposal, + columns: columns, + rows: rows, + spacing: spacing + ) + let width = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1) + let height = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1) + return CGSize(width: width, height: height) + } + + func placeSubviews( + in bounds: CGRect, + proposal: ProposedViewSize, + subviews: Subviews, + cache: inout () + ) { + guard let cellGrid = subviews.last else { return } + let geometry = PuzzleGridGeometry( + size: bounds.size, + columns: columns, + rows: rows, + spacing: spacing + ) + let layerProposal = ProposedViewSize(width: bounds.width, height: bounds.height) + for subview in subviews.dropLast() { + subview.place( + at: CGPoint(x: bounds.minX, y: bounds.minY), + anchor: .topLeading, + proposal: layerProposal + ) + } + // The view builder must emit backing layers first and the cell grid + // last; the cell grid is the only subview placed on the tight grid rect. + let cellGridRect = geometry.gridRect.offsetBy(dx: bounds.minX, dy: bounds.minY) + cellGrid.place( + at: cellGridRect.origin, + anchor: .topLeading, + proposal: ProposedViewSize(width: cellGridRect.width, height: cellGridRect.height) + ) + } +} + /// Shared cell geometry for the puzzle grid: given a container `size`, computes /// the uniform cell size (via `PuzzleGridMetrics`) and the centred origin, then /// hands back the frame of any `(row, col)`. `PuzzleGridLayout` and the backing @@ -212,6 +265,7 @@ private struct PuzzleGridLayout: Layout { private struct PuzzleGridGeometry { let cellSize: CGFloat let spacing: CGFloat + let gridSize: CGSize private let originX: CGFloat private let originY: CGFloat @@ -226,10 +280,15 @@ private struct PuzzleGridGeometry { let gridHeight = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1) self.cellSize = cellSize self.spacing = spacing + self.gridSize = CGSize(width: gridWidth, height: gridHeight) self.originX = (size.width - gridWidth) / 2 self.originY = (size.height - gridHeight) / 2 } + var gridRect: CGRect { + CGRect(origin: CGPoint(x: originX, y: originY), size: gridSize) + } + func cellRect(row: Int, col: Int) -> CGRect { CGRect( x: originX + spacing + CGFloat(col) * (cellSize + spacing), @@ -258,6 +317,7 @@ private struct GridBackdrop: View { rows: puzzle.height, spacing: spacing ) + context.fill(Path(geometry.gridRect), with: .color(.black)) for r in 0..<puzzle.height { for c in 0..<puzzle.width where !puzzle.cells[r][c].isBlock { context.fill(Path(geometry.cellRect(row: r, col: c)), with: .color(.white))