crossmate

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

ClueBar.swift (6742B)


      1 import SwiftUI
      2 
      3 private struct ClueKey: Hashable {
      4     let direction: Puzzle.Direction
      5     let number: Int
      6 }
      7 
      8 private struct ReplayClueTarget {
      9     let position: GridPosition
     10     let direction: Puzzle.Direction?
     11 }
     12 
     13 struct ClueBarSlot: View {
     14     @Bindable var session: PlayerSession
     15     let replayFrame: ReplayFrame?
     16 
     17     private var replayClueTarget: ReplayClueTarget? {
     18         guard let cursor = replayFrame?.cursor else { return nil }
     19         return ReplayClueTarget(position: cursor, direction: replayFrame?.cursorDirection)
     20     }
     21 
     22     var body: some View {
     23         ZStack(alignment: .bottom) {
     24             ClueBarReservation()
     25 
     26             ClueBar(session: session, replayClueTarget: replayClueTarget)
     27         }
     28     }
     29 }
     30 
     31 private struct ClueBarReservation: View {
     32     var body: some View {
     33         ClueBarContent(
     34             label: "99 Across",
     35             clueText: AttributedString("Clue reservation"),
     36             reservesClueSpace: true
     37         )
     38             .opacity(0)
     39             .accessibilityHidden(true)
     40             .allowsHitTesting(false)
     41     }
     42 }
     43 
     44 private struct ClueBarContent: View {
     45     let label: String
     46     let clueText: AttributedString
     47     var reservesClueSpace = false
     48     var currentKey: ClueKey?
     49     var slideEdge: Edge = .trailing
     50     var onPrevious: (() -> Void)?
     51     var onNext: (() -> Void)?
     52     var onClueTap: (() -> Void)?
     53     var onLabelTap: (() -> Void)?
     54 
     55     var body: some View {
     56         HStack(alignment: .clueCenter, spacing: 8) {
     57             ClueBarIcon(systemName: "chevron.left", action: onPrevious)
     58 
     59             VStack(alignment: .leading, spacing: 4) {
     60                 Text(label)
     61                     .font(.caption)
     62                     .textCase(.uppercase)
     63                     .foregroundStyle(.secondary)
     64                     .contentShape(Rectangle())
     65                     .highPriorityGesture(
     66                         TapGesture()
     67                             .onEnded {
     68                                 onLabelTap?()
     69                             }
     70                     )
     71                 ZStack(alignment: .leading) {
     72                     clueTextView
     73                 }
     74                 .alignmentGuide(.clueCenter) { d in d[VerticalAlignment.center] }
     75                 .frame(maxWidth: .infinity, alignment: .leading)
     76                 .clipped()
     77             }
     78             .contentShape(Rectangle())
     79             .onTapGesture {
     80                 onClueTap?()
     81             }
     82 
     83             ClueBarIcon(systemName: "chevron.right", action: onNext)
     84         }
     85         .padding(.horizontal, 8)
     86         .padding(.top, 12)
     87         .padding(.bottom, 6)
     88     }
     89 
     90     @ViewBuilder
     91     private var clueTextView: some View {
     92         baseClueText
     93             .id(currentKey)
     94             .transition(.asymmetric(
     95                 insertion: .move(edge: slideEdge),
     96                 removal: .move(edge: slideEdge == .trailing ? .leading : .trailing)
     97             ))
     98     }
     99 
    100     private var baseClueText: some View {
    101         Text(clueText)
    102             .font(.headline)
    103             .lineLimit(2, reservesSpace: reservesClueSpace)
    104             .multilineTextAlignment(.leading)
    105             .frame(maxWidth: .infinity, alignment: .leading)
    106     }
    107 }
    108 
    109 private struct ClueBarIcon: View {
    110     let systemName: String
    111     var action: (() -> Void)?
    112 
    113     var body: some View {
    114         if let action {
    115             Button(action: action) {
    116                 icon
    117             }
    118             .buttonStyle(.plain)
    119         } else {
    120             icon
    121         }
    122     }
    123 
    124     private var icon: some View {
    125         Image(systemName: systemName)
    126             .font(.title3.weight(.semibold))
    127             .frame(width: 44, height: 44)
    128             .contentShape(Rectangle())
    129     }
    130 }
    131 
    132 private struct ClueBar: View {
    133     @Bindable var session: PlayerSession
    134     let replayClueTarget: ReplayClueTarget?
    135     @Environment(PlayerPreferences.self) private var preferences
    136     @Environment(\.colorScheme) private var colorScheme
    137     @State private var slideEdge: Edge = .trailing
    138     @State private var isShowingClueList = false
    139 
    140     private var backgroundColor: Color {
    141         preferences.color.clueBarFill(dark: colorScheme == .dark)
    142     }
    143 
    144     var body: some View {
    145         let display = replayClueDisplay ?? liveClueDisplay
    146         let isShowingReplayClue = replayClueDisplay != nil
    147 
    148         ClueBarContent(
    149             label: label(for: display.clue, direction: display.direction),
    150             clueText: display.clue?.attributedText ?? AttributedString("—"),
    151             currentKey: display.currentKey,
    152             slideEdge: slideEdge,
    153             onPrevious: isShowingReplayClue ? nil : {
    154                 slideEdge = .leading
    155                 session.goToPreviousClue()
    156             },
    157             onNext: isShowingReplayClue ? nil : {
    158                 slideEdge = .trailing
    159                 session.goToNextClue()
    160             },
    161             onClueTap: isShowingReplayClue ? nil : {
    162                 isShowingClueList = true
    163             },
    164             onLabelTap: isShowingReplayClue ? nil : {
    165                 session.toggleDirection()
    166             }
    167         )
    168         .background(backgroundColor)
    169         .animation(
    170             isShowingReplayClue ? nil : .smooth(duration: 0.22),
    171             value: display.currentKey
    172         )
    173         .sheet(isPresented: $isShowingClueList) {
    174             ClueList(session: session)
    175                 .presentationDetents([.medium, .large])
    176                 .presentationDragIndicator(.visible)
    177         }
    178     }
    179 
    180     private var liveClueDisplay: ClueDisplay {
    181         let clue = session.currentClue()
    182         return ClueDisplay(clue: clue, direction: session.direction)
    183     }
    184 
    185     private var replayClueDisplay: ClueDisplay? {
    186         guard let replayClueTarget else { return nil }
    187         let position = replayClueTarget.position
    188         guard let direction = replayClueTarget.direction else { return nil }
    189         return ClueDisplay(
    190             clue: session.puzzle.clue(atRow: position.row, col: position.col, direction: direction),
    191             direction: direction
    192         )
    193     }
    194 
    195     private struct ClueDisplay {
    196         let clue: Puzzle.Clue?
    197         let direction: Puzzle.Direction
    198 
    199         var currentKey: ClueKey? {
    200             clue.map { ClueKey(direction: direction, number: $0.number) }
    201         }
    202     }
    203 
    204     private func label(for clue: Puzzle.Clue?, direction: Puzzle.Direction) -> String {
    205         let direction = direction == .across ? "Across" : "Down"
    206         if let clue {
    207             return "\(clue.number) \(direction)"
    208         }
    209         return direction
    210     }
    211 }
    212 
    213 private extension VerticalAlignment {
    214     enum ClueCenterID: AlignmentID {
    215         static func defaultValue(in d: ViewDimensions) -> CGFloat { d[VerticalAlignment.center] }
    216     }
    217     static let clueCenter = VerticalAlignment(ClueCenterID.self)
    218 }