crossmate

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

ClueList.swift (9077B)


      1 import SwiftUI
      2 
      3 struct ClueList: View {
      4     @Bindable var session: PlayerSession
      5     var presentation: Presentation = .sheet
      6     var replayFrame: ReplayFrame?
      7     @Environment(PlayerPreferences.self) private var preferences
      8     @Environment(\.dismiss) private var dismiss
      9     @Environment(\.scenePhase) private var scenePhase
     10 
     11     enum Presentation {
     12         case sheet
     13         case sidebar
     14     }
     15 
     16     private var currentClueBackground: Color {
     17         preferences.color.authorTintFill
     18     }
     19 
     20     var body: some View {
     21         switch presentation {
     22         case .sheet:
     23             NavigationStack {
     24                 clueList
     25                     .navigationTitle("Clues")
     26                     .navigationBarTitleDisplayMode(.inline)
     27                     .toolbar {
     28                         ToolbarItem(placement: .cancellationAction) {
     29                             Button {
     30                                 dismiss()
     31                             } label: {
     32                                 Image(systemName: "xmark")
     33                             }
     34                             .accessibilityLabel("Cancel")
     35                         }
     36                     }
     37             }
     38         case .sidebar:
     39             clueList
     40         }
     41     }
     42 
     43     @ViewBuilder
     44     private var clueList: some View {
     45         let currentDisplay = replayClueDisplay ?? liveClueDisplay
     46         let current = currentDisplay.clue
     47         let currentDirection = currentDisplay.direction
     48         let currentID = currentDisplay.currentID
     49 
     50         if presentation == .sheet {
     51             list(
     52                 current: current,
     53                 currentDirection: currentDirection,
     54                 currentID: currentID
     55             )
     56             .listStyle(.plain)
     57         } else {
     58             sidebarList(
     59                 current: current,
     60                 currentDirection: currentDirection,
     61                 currentID: currentID
     62             )
     63         }
     64     }
     65 
     66     private func list(
     67         current: Puzzle.Clue?,
     68         currentDirection: Puzzle.Direction,
     69         currentID: String?
     70     ) -> some View {
     71         ScrollViewReader { proxy in
     72             List {
     73                 headingRow("Across")
     74                 ForEach(session.puzzle.acrossClues) { clue in
     75                     row(for: clue, direction: .across, current: current, currentDirection: currentDirection)
     76                         .id(rowID(direction: .across, number: clue.number))
     77                 }
     78 
     79                 headingRow("Down")
     80                 ForEach(session.puzzle.downClues) { clue in
     81                     row(for: clue, direction: .down, current: current, currentDirection: currentDirection)
     82                         .id(rowID(direction: .down, number: clue.number))
     83                 }
     84             }
     85             .onAppear {
     86                 guard let currentID else { return }
     87                 proxy.scrollTo(currentID, anchor: .center)
     88             }
     89             .onChange(of: currentID) { _, newID in
     90                 guard let newID else { return }
     91                 withAnimation(.easeInOut(duration: 0.2)) {
     92                     proxy.scrollTo(newID, anchor: .center)
     93                 }
     94             }
     95             .onChange(of: scenePhase) { _, newPhase in
     96                 guard newPhase == .active, let currentID else { return }
     97                 proxy.scrollTo(currentID, anchor: .center)
     98             }
     99         }
    100     }
    101 
    102     private func headingRow(_ title: String) -> some View {
    103         Text(title)
    104             .font(.footnote.weight(.semibold))
    105             .foregroundStyle(.secondary)
    106             .textCase(.uppercase)
    107             .frame(maxWidth: .infinity, alignment: .leading)
    108             .padding(.leading, 12)
    109             .padding(.vertical, 6)
    110             .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
    111             .listRowBackground(Color.clear)
    112     }
    113 
    114     private func sidebarList(
    115         current: Puzzle.Clue?,
    116         currentDirection: Puzzle.Direction,
    117         currentID: String?
    118     ) -> some View {
    119         ScrollViewReader { proxy in
    120             ScrollView {
    121                 LazyVStack(alignment: .leading, spacing: 0) {
    122                     sidebarSectionHeader("Across")
    123                     ForEach(session.puzzle.acrossClues) { clue in
    124                         sidebarRow(for: clue, direction: .across, current: current, currentDirection: currentDirection)
    125                             .id(rowID(direction: .across, number: clue.number))
    126                     }
    127 
    128                     sidebarSectionHeader("Down")
    129                         .padding(.top, 12)
    130                     ForEach(session.puzzle.downClues) { clue in
    131                         sidebarRow(for: clue, direction: .down, current: current, currentDirection: currentDirection)
    132                             .id(rowID(direction: .down, number: clue.number))
    133                     }
    134                 }
    135                 .padding(.vertical, 10)
    136             }
    137             .onAppear {
    138                 guard let currentID else { return }
    139                 proxy.scrollTo(currentID, anchor: .center)
    140             }
    141             .onChange(of: currentID) { _, newID in
    142                 guard let newID else { return }
    143                 withAnimation(.easeInOut(duration: 0.2)) {
    144                     proxy.scrollTo(newID, anchor: .center)
    145                 }
    146             }
    147             .onChange(of: scenePhase) { _, newPhase in
    148                 guard newPhase == .active, let currentID else { return }
    149                 proxy.scrollTo(currentID, anchor: .center)
    150             }
    151         }
    152     }
    153 
    154     private func sidebarSectionHeader(_ title: String) -> some View {
    155         Text(title)
    156             .font(.footnote.weight(.semibold))
    157             .foregroundStyle(.secondary)
    158             .textCase(.uppercase)
    159             .frame(maxWidth: .infinity, alignment: .leading)
    160             .padding(.horizontal, 18)
    161             .padding(.vertical, 6)
    162     }
    163 
    164     private func sidebarRow(
    165         for clue: Puzzle.Clue,
    166         direction: Puzzle.Direction,
    167         current: Puzzle.Clue?,
    168         currentDirection: Puzzle.Direction
    169     ) -> some View {
    170         let isCurrent = current?.number == clue.number && currentDirection == direction
    171         return Button {
    172             session.selectClue(direction: direction, number: clue.number)
    173         } label: {
    174             HStack(alignment: .firstTextBaseline, spacing: 10) {
    175                 Text("\(clue.number)")
    176                     .font(.subheadline.weight(.semibold))
    177                     .foregroundStyle(.secondary)
    178                     .frame(minWidth: 28, alignment: .trailing)
    179                 Text(clue.attributedText)
    180                     .font(.body)
    181                     .foregroundStyle(.primary)
    182                     .frame(maxWidth: .infinity, alignment: .leading)
    183             }
    184             .padding(.horizontal, 18)
    185             .padding(.vertical, 8)
    186             .contentShape(Rectangle())
    187         }
    188         .buttonStyle(.plain)
    189         .background(isCurrent ? currentClueBackground : Color.clear)
    190     }
    191 
    192     private func rowID(direction: Puzzle.Direction, number: Int) -> String {
    193         "\(direction == .across ? "A" : "D")-\(number)"
    194     }
    195 
    196     private var liveClueDisplay: ClueDisplay {
    197         ClueDisplay(clue: session.currentClue(), direction: session.direction)
    198     }
    199 
    200     private var replayClueDisplay: ClueDisplay? {
    201         guard let position = replayFrame?.cursor,
    202               let direction = replayFrame?.cursorDirection
    203         else { return nil }
    204         return ClueDisplay(
    205             clue: session.puzzle.clue(atRow: position.row, col: position.col, direction: direction),
    206             direction: direction
    207         )
    208     }
    209 
    210     private struct ClueDisplay {
    211         let clue: Puzzle.Clue?
    212         let direction: Puzzle.Direction
    213 
    214         var currentID: String? {
    215             clue.map { "\(direction == .across ? "A" : "D")-\($0.number)" }
    216         }
    217     }
    218 
    219     @ViewBuilder
    220     private func row(
    221         for clue: Puzzle.Clue,
    222         direction: Puzzle.Direction,
    223         current: Puzzle.Clue?,
    224         currentDirection: Puzzle.Direction
    225     ) -> some View {
    226         let isCurrent = current?.number == clue.number && currentDirection == direction
    227         Button {
    228             session.selectClue(direction: direction, number: clue.number)
    229             if presentation == .sheet {
    230                 dismiss()
    231             }
    232         } label: {
    233             HStack(alignment: .firstTextBaseline, spacing: 10) {
    234                 Text("\(clue.number)")
    235                     .font(.subheadline.weight(.semibold))
    236                     .foregroundStyle(.secondary)
    237                     .frame(minWidth: 28, alignment: .trailing)
    238                 Text(clue.attributedText)
    239                     .font(.body)
    240                     .foregroundStyle(.primary)
    241                     .frame(maxWidth: .infinity, alignment: .leading)
    242             }
    243             .padding(.leading, 12)
    244             .padding(.vertical, 10)
    245             .contentShape(Rectangle())
    246         }
    247         .buttonStyle(.plain)
    248         .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
    249         .listRowBackground(isCurrent ? currentClueBackground : Color.clear)
    250     }
    251 }