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 }