crossmate

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

ClueList.swift (8808B)


      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 = Self.currentDisplay(session: session, replayFrame: replayFrame)
     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                 ClueSectionHeader(title: "Across", presentation: .sheet)
     74                 ForEach(session.puzzle.acrossClues) { clue in
     75                     rowButton(for: clue, direction: .across, current: current, currentDirection: currentDirection)
     76                         .id(rowID(direction: .across, number: clue.number))
     77                 }
     78 
     79                 ClueSectionHeader(title: "Down", presentation: .sheet)
     80                 ForEach(session.puzzle.downClues) { clue in
     81                     rowButton(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 sidebarList(
    103         current: Puzzle.Clue?,
    104         currentDirection: Puzzle.Direction,
    105         currentID: String?
    106     ) -> some View {
    107         ScrollViewReader { proxy in
    108             ScrollView {
    109                 VStack(alignment: .leading, spacing: 0) {
    110                     ClueSectionHeader(title: "Across", presentation: .sidebar)
    111                     ForEach(session.puzzle.acrossClues) { clue in
    112                         rowButton(for: clue, direction: .across, current: current, currentDirection: currentDirection)
    113                             .id(rowID(direction: .across, number: clue.number))
    114                     }
    115 
    116                     ClueSectionHeader(title: "Down", presentation: .sidebar)
    117                         .padding(.top, 12)
    118                     ForEach(session.puzzle.downClues) { clue in
    119                         rowButton(for: clue, direction: .down, current: current, currentDirection: currentDirection)
    120                             .id(rowID(direction: .down, number: clue.number))
    121                     }
    122                 }
    123                 .padding(.vertical, 10)
    124             }
    125             .onAppear {
    126                 guard let currentID else { return }
    127                 proxy.scrollTo(currentID, anchor: .center)
    128             }
    129             .onChange(of: currentID) { _, newID in
    130                 guard let newID else { return }
    131                 withAnimation(.easeInOut(duration: 0.2)) {
    132                     proxy.scrollTo(newID, anchor: .center)
    133                 }
    134             }
    135         }
    136     }
    137 
    138     private func rowID(direction: Puzzle.Direction, number: Int) -> String {
    139         "\(direction == .across ? "A" : "D")-\(number)"
    140     }
    141 
    142     private static func currentDisplay(session: PlayerSession, replayFrame: ReplayFrame?) -> ClueDisplay {
    143         if let position = replayFrame?.cursor,
    144            let direction = replayFrame?.cursorDirection {
    145             return ClueDisplay(
    146                 clue: session.puzzle.clue(atRow: position.row, col: position.col, direction: direction),
    147                 direction: direction
    148             )
    149         }
    150         return ClueDisplay(clue: session.currentClue(), direction: session.direction)
    151     }
    152 
    153     private struct ClueDisplay {
    154         let clue: Puzzle.Clue?
    155         let direction: Puzzle.Direction
    156 
    157         var currentID: String? {
    158             clue.map { "\(direction == .across ? "A" : "D")-\($0.number)" }
    159         }
    160     }
    161 
    162     @ViewBuilder
    163     private func rowButton(
    164         for clue: Puzzle.Clue,
    165         direction: Puzzle.Direction,
    166         current: Puzzle.Clue?,
    167         currentDirection: Puzzle.Direction
    168     ) -> some View {
    169         let isCurrent = current?.number == clue.number && currentDirection == direction
    170         Button {
    171             session.selectClue(direction: direction, number: clue.number)
    172             if presentation == .sheet {
    173                 dismiss()
    174             }
    175         } label: {
    176             ClueRow(
    177                 number: clue.number,
    178                 text: clue.attributedText,
    179                 isCurrent: isCurrent,
    180                 currentBackground: currentClueBackground,
    181                 presentation: presentation
    182             )
    183         }
    184         .buttonStyle(.plain)
    185         .modifier(SheetClueListRowChrome(
    186             isActive: presentation == .sheet,
    187             background: isCurrent ? currentClueBackground : .clear
    188         ))
    189     }
    190 }
    191 
    192 private struct ClueSectionHeader: View {
    193     let title: String
    194     let presentation: ClueList.Presentation
    195 
    196     var body: some View {
    197         Text(title)
    198             .font(.footnote.weight(.semibold))
    199             .foregroundStyle(.secondary)
    200             .textCase(.uppercase)
    201             .frame(maxWidth: .infinity, alignment: .leading)
    202             .padding(.leading, presentation == .sheet ? 12 : 18)
    203             .padding(.trailing, presentation == .sheet ? 0 : 18)
    204             .padding(.vertical, 6)
    205             .modifier(SheetClueListRowChrome(isActive: presentation == .sheet, background: .clear))
    206     }
    207 }
    208 
    209 private struct ClueRow: View {
    210     let number: Int
    211     let text: AttributedString
    212     let isCurrent: Bool
    213     let currentBackground: Color
    214     let presentation: ClueList.Presentation
    215 
    216     private var background: Color {
    217         isCurrent ? currentBackground : .clear
    218     }
    219 
    220     var body: some View {
    221         HStack(alignment: .firstTextBaseline, spacing: 10) {
    222             Text("\(number)")
    223                 .font(.subheadline.weight(.semibold))
    224                 .foregroundStyle(.secondary)
    225                 .frame(minWidth: 28, alignment: .trailing)
    226             Text(text)
    227                 .font(.body)
    228                 .foregroundStyle(.primary)
    229                 .frame(maxWidth: .infinity, alignment: .leading)
    230         }
    231         .padding(.leading, presentation == .sheet ? 12 : 18)
    232         .padding(.trailing, presentation == .sheet ? 0 : 18)
    233         .padding(.vertical, presentation == .sheet ? 10 : 8)
    234         .contentShape(Rectangle())
    235         .background(presentation == .sidebar ? background : .clear)
    236     }
    237 }
    238 
    239 private struct SheetClueListRowChrome: ViewModifier {
    240     let isActive: Bool
    241     let background: Color
    242 
    243     func body(content: Content) -> some View {
    244         if isActive {
    245             content
    246                 .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
    247                 .listRowBackground(background)
    248         } else {
    249             content
    250         }
    251     }
    252 }