crossmate

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

ClueList.swift (7524B)


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