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 }