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 }