GridView.swift (6087B)
1 import SwiftUI 2 3 struct GridView: View { 4 @Bindable var session: PlayerSession 5 let roster: PlayerRoster 6 let showsSharedAnnotations: Bool 7 8 private let spacing: CGFloat = 1 9 10 var body: some View { 11 let width = session.puzzle.width 12 let height = session.puzzle.height 13 let (outlineByCell, tintByCell) = showsSharedAnnotations ? remoteOverlays() : ([:], [:]) 14 let authorTintByID: [String: Color] = showsSharedAnnotations 15 ? Dictionary( 16 uniqueKeysWithValues: roster.entries.map { ($0.authorID, $0.color.tint) } 17 ) 18 : [:] 19 let relatedCells = session.puzzle.relatedCells( 20 atRow: session.selectedRow, 21 col: session.selectedCol, 22 direction: session.direction 23 ) 24 let currentWordCells = Set(session.puzzle.wordCells( 25 atRow: session.selectedRow, 26 col: session.selectedCol, 27 direction: session.direction 28 ).map { GridPosition(row: $0.row, col: $0.col) }) 29 PuzzleGridLayout(columns: width, rows: height, spacing: spacing) { 30 ForEach(0..<(width * height), id: \.self) { index in 31 let r = index / width 32 let c = index % width 33 let pos = GridPosition(row: r, col: c) 34 let square = session.game.squares[r][c] 35 CellView( 36 cell: session.puzzle.cells[r][c], 37 entry: square.entry, 38 mark: square.mark, 39 isSelected: session.selectedRow == r && session.selectedCol == c, 40 isHighlighted: currentWordCells.contains(pos), 41 isRelatedToFocus: relatedCells.contains(pos), 42 specialKind: session.puzzle.specialKind, 43 remoteWordTint: tintByCell[pos], 44 remoteOutline: outlineByCell[pos], 45 authorTint: square.entry.isEmpty 46 ? nil 47 : square.letterAuthorID.flatMap { authorTintByID[$0] } 48 ) 49 .equatable() 50 .onTapGesture { 51 session.select(row: r, col: c) 52 } 53 } 54 } 55 .background(Color.black) 56 } 57 58 /// Builds the focused-cell outline map and the word-tint map from each 59 /// peer's selection. Conflicts (two peers on the same cell or word) are 60 /// resolved by keeping the most recent `updatedAt`. 61 private func remoteOverlays() -> ( 62 outline: [GridPosition: Color], 63 tint: [GridPosition: Color] 64 ) { 65 var outline: [GridPosition: (Date, Color)] = [:] 66 var tint: [GridPosition: (Date, Color)] = [:] 67 for (_, sel) in roster.remoteSelections { 68 let focused = GridPosition(row: sel.row, col: sel.col) 69 if outline[focused].map({ $0.0 < sel.updatedAt }) ?? true { 70 outline[focused] = (sel.updatedAt, sel.color.selectionFill) 71 } 72 for cell in session.puzzle.wordCells( 73 atRow: sel.row, col: sel.col, direction: sel.direction 74 ) { 75 let pos = GridPosition(row: cell.row, col: cell.col) 76 if tint[pos].map({ $0.0 < sel.updatedAt }) ?? true { 77 tint[pos] = (sel.updatedAt, sel.color.highlightFill) 78 } 79 } 80 } 81 return (outline.mapValues { $0.1 }, tint.mapValues { $0.1 }) 82 } 83 84 } 85 86 // MARK: - Layout 87 88 /// Lays out puzzle cells in a `rows × columns` grid with a uniform square 89 /// cell size. The cell size is chosen to fit the proposed container, with 90 /// `spacing` points between every cell and around the outer edge (the black 91 /// grid border shows through the gaps). The laid-out grid reports its tight 92 /// size via `sizeThatFits`, so the parent view sizes us to exactly the grid 93 /// dimensions — no `.aspectRatio` modifier needed. 94 private struct PuzzleGridLayout: Layout { 95 let columns: Int 96 let rows: Int 97 let spacing: CGFloat 98 99 func sizeThatFits( 100 proposal: ProposedViewSize, 101 subviews: Subviews, 102 cache: inout () 103 ) -> CGSize { 104 let cellSize = cellSize(for: proposal) 105 let width = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1) 106 let height = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1) 107 return CGSize(width: width, height: height) 108 } 109 110 func placeSubviews( 111 in bounds: CGRect, 112 proposal: ProposedViewSize, 113 subviews: Subviews, 114 cache: inout () 115 ) { 116 let cellSize = cellSize(for: ProposedViewSize(width: bounds.width, height: bounds.height)) 117 let gridWidth = cellSize * CGFloat(columns) + spacing * CGFloat(columns + 1) 118 let gridHeight = cellSize * CGFloat(rows) + spacing * CGFloat(rows + 1) 119 let originX = bounds.minX + (bounds.width - gridWidth) / 2 120 let originY = bounds.minY + (bounds.height - gridHeight) / 2 121 122 let cellProposal = ProposedViewSize(width: cellSize, height: cellSize) 123 for (index, subview) in subviews.enumerated() { 124 let r = index / columns 125 let c = index % columns 126 let x = originX + spacing + CGFloat(c) * (cellSize + spacing) 127 let y = originY + spacing + CGFloat(r) * (cellSize + spacing) 128 subview.place( 129 at: CGPoint(x: x, y: y), 130 anchor: .topLeading, 131 proposal: cellProposal 132 ) 133 } 134 } 135 136 private func cellSize(for proposal: ProposedViewSize) -> CGFloat { 137 let cols = CGFloat(columns) 138 let rs = CGFloat(rows) 139 let availableWidth = proposal.width ?? .infinity 140 let availableHeight = proposal.height ?? .infinity 141 let widthBased = availableWidth.isFinite 142 ? (availableWidth - spacing * (cols + 1)) / cols 143 : .infinity 144 let heightBased = availableHeight.isFinite 145 ? (availableHeight - spacing * (rs + 1)) / rs 146 : .infinity 147 return max(0, min(widthBased, heightBased)) 148 } 149 }