crossmate

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

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 }