crossmate

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

ClueBar.swift (6603B)


      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(.push(from: slideEdge))
     95     }
     96 
     97     private var baseClueText: some View {
     98         Text(clueText)
     99             .font(.headline)
    100             .lineLimit(2, reservesSpace: reservesClueSpace)
    101             .multilineTextAlignment(.leading)
    102             .frame(maxWidth: .infinity, alignment: .leading)
    103     }
    104 }
    105 
    106 private struct ClueBarIcon: View {
    107     let systemName: String
    108     var action: (() -> Void)?
    109 
    110     var body: some View {
    111         if let action {
    112             Button(action: action) {
    113                 icon
    114             }
    115             .buttonStyle(.plain)
    116         } else {
    117             icon
    118         }
    119     }
    120 
    121     private var icon: some View {
    122         Image(systemName: systemName)
    123             .font(.title3.weight(.semibold))
    124             .frame(width: 44, height: 44)
    125             .contentShape(Rectangle())
    126     }
    127 }
    128 
    129 private struct ClueBar: View {
    130     @Bindable var session: PlayerSession
    131     let replayClueTarget: ReplayClueTarget?
    132     @Environment(PlayerPreferences.self) private var preferences
    133     @Environment(\.colorScheme) private var colorScheme
    134     @State private var slideEdge: Edge = .trailing
    135     @State private var isShowingClueList = false
    136 
    137     private var backgroundColor: Color {
    138         preferences.color.clueBarFill(dark: colorScheme == .dark)
    139     }
    140 
    141     var body: some View {
    142         let display = replayClueDisplay ?? liveClueDisplay
    143         let isShowingReplayClue = replayClueDisplay != nil
    144 
    145         ClueBarContent(
    146             label: label(for: display.clue, direction: display.direction),
    147             clueText: display.clue?.attributedText ?? AttributedString("—"),
    148             currentKey: display.currentKey,
    149             slideEdge: slideEdge,
    150             onPrevious: isShowingReplayClue ? nil : {
    151                 slideEdge = .leading
    152                 session.goToPreviousClue()
    153             },
    154             onNext: isShowingReplayClue ? nil : {
    155                 slideEdge = .trailing
    156                 session.goToNextClue()
    157             },
    158             onClueTap: isShowingReplayClue ? nil : {
    159                 isShowingClueList = true
    160             },
    161             onLabelTap: isShowingReplayClue ? nil : {
    162                 session.toggleDirection()
    163             }
    164         )
    165         .background(backgroundColor)
    166         .animation(
    167             isShowingReplayClue ? nil : .smooth(duration: 0.22),
    168             value: display.currentKey
    169         )
    170         .sheet(isPresented: $isShowingClueList) {
    171             ClueList(session: session)
    172                 .presentationDetents([.medium, .large])
    173                 .presentationDragIndicator(.visible)
    174         }
    175     }
    176 
    177     private var liveClueDisplay: ClueDisplay {
    178         let clue = session.currentClue()
    179         return ClueDisplay(clue: clue, direction: session.direction)
    180     }
    181 
    182     private var replayClueDisplay: ClueDisplay? {
    183         guard let replayClueTarget else { return nil }
    184         let position = replayClueTarget.position
    185         guard let direction = replayClueTarget.direction else { return nil }
    186         return ClueDisplay(
    187             clue: session.puzzle.clue(atRow: position.row, col: position.col, direction: direction),
    188             direction: direction
    189         )
    190     }
    191 
    192     private struct ClueDisplay {
    193         let clue: Puzzle.Clue?
    194         let direction: Puzzle.Direction
    195 
    196         var currentKey: ClueKey? {
    197             clue.map { ClueKey(direction: direction, number: $0.number) }
    198         }
    199     }
    200 
    201     private func label(for clue: Puzzle.Clue?, direction: Puzzle.Direction) -> String {
    202         let direction = direction == .across ? "Across" : "Down"
    203         if let clue {
    204             return "\(clue.number) \(direction)"
    205         }
    206         return direction
    207     }
    208 }
    209 
    210 private extension VerticalAlignment {
    211     enum ClueCenterID: AlignmentID {
    212         static func defaultValue(in d: ViewDimensions) -> CGFloat { d[VerticalAlignment.center] }
    213     }
    214     static let clueCenter = VerticalAlignment(ClueCenterID.self)
    215 }