crossmate

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

KeyboardView.swift (12253B)


      1 import SwiftUI
      2 
      3 /// Custom on-screen keyboard. We use a hand-rolled keyboard rather than the
      4 /// system keyboard because crossword input is single-character and needs to
      5 /// stay glued to the bottom of the screen alongside the grid.
      6 struct KeyboardView: View {
      7     @Bindable var session: PlayerSession
      8     var showsNavigationKeys = false
      9     @State private var showingOverflow = false
     10 
     11     private let topRow = Array("QWERTYUIOP").map(String.init)
     12     private let middleRow = Array("ASDFGHJKL").map(String.init)
     13     private let bottomLetters = Array("ZXCVBNM").map(String.init)
     14 
     15     private let spacing: CGFloat = 6
     16     private let keyHeight: CGFloat = 46
     17     private let metaKeyWidthMultiplier: CGFloat = 1.5
     18 
     19     static let standardHeight: CGFloat = 170
     20 
     21     var body: some View {
     22         VStack(spacing: spacing) {
     23             KeyboardRow(
     24                 referenceColumns: showsNavigationKeys ? 12 : 10,
     25                 spacing: spacing,
     26                 keyHeight: keyHeight
     27             ) {
     28                 if showsNavigationKeys {
     29                     actionKey(systemImage: "chevron.left", accessibilityLabel: "Previous Word") {
     30                         session.goToPreviousWord()
     31                     }
     32                     .fillsExtraKeyboardSpace()
     33                 }
     34                 ForEach(topRow, id: \.self) { letter in
     35                     letterKey(letter)
     36                 }
     37                 if showsNavigationKeys {
     38                     actionKey(systemImage: "chevron.right", accessibilityLabel: "Next Word") {
     39                         session.goToNextWord()
     40                     }
     41                     .fillsExtraKeyboardSpace()
     42                 }
     43             }
     44             KeyboardRow(
     45                 referenceColumns: showsNavigationKeys ? 12 : 10,
     46                 spacing: spacing,
     47                 keyHeight: keyHeight
     48             ) {
     49                 if showsNavigationKeys {
     50                     actionKey(systemImage: "arrowtriangle.left.fill", accessibilityLabel: "Previous Letter") {
     51                         session.goToPreviousLetter()
     52                     }
     53                     .fillsExtraKeyboardSpace()
     54                 }
     55                 ForEach(middleRow, id: \.self) { letter in
     56                     letterKey(letter)
     57                 }
     58                 if showsNavigationKeys {
     59                     actionKey(systemImage: "arrowtriangle.right.fill", accessibilityLabel: "Next Letter") {
     60                         session.goToNextLetter()
     61                     }
     62                     .fillsExtraKeyboardSpace()
     63                 }
     64             }
     65             KeyboardRow(
     66                 referenceColumns: showsNavigationKeys ? 12 : 10,
     67                 spacing: spacing,
     68                 keyHeight: keyHeight
     69             ) {
     70                 if showsNavigationKeys {
     71                     rebusKey
     72                         .disablesKeyboardMetaAnimations()
     73                         .fillsExtraKeyboardSpace()
     74 
     75                     actionKey(
     76                         systemImage: "pencil",
     77                         accessibilityLabel: session.isPencilMode ? "Turn Off Draft" : "Turn On Draft",
     78                         background: session.isPencilMode ? .blue : Color(.systemFill),
     79                         foreground: session.isPencilMode ? .white : .primary
     80                     ) {
     81                         session.togglePencil()
     82                     }
     83                     .disablesKeyboardMetaAnimations()
     84                     .fillsExtraKeyboardSpace()
     85                 } else {
     86                     if session.isRebusActive {
     87                         actionKey(text: "Done", background: .blue, foreground: .white) {
     88                             session.commitRebus()
     89                         }
     90                         .keyWidthMultiplier(metaKeyWidthMultiplier)
     91                     } else {
     92                         overflowKey
     93                     }
     94                 }
     95 
     96                 ForEach(bottomLetters, id: \.self) { letter in
     97                     letterKey(letter)
     98                 }
     99 
    100                 if showsNavigationKeys {
    101                     actionKey(
    102                         systemImage: "arrow.2.squarepath",
    103                         accessibilityLabel: "Switch Direction"
    104                     ) {
    105                         session.toggleDirection()
    106                     }
    107                     .disablesKeyboardMetaAnimations()
    108                     .fillsExtraKeyboardSpace()
    109                 }
    110 
    111                 actionKey(systemImage: "delete.left") {
    112                     if session.isRebusActive {
    113                         session.deleteRebusLetter()
    114                     } else {
    115                         session.deleteBackward()
    116                     }
    117                 }
    118                 .disablesKeyboardMetaAnimations()
    119                 .keyWidthMultiplier(showsNavigationKeys ? 1 : metaKeyWidthMultiplier)
    120                 .fillsExtraKeyboardSpace(showsNavigationKeys)
    121             }
    122         }
    123         .padding(.horizontal, 4)
    124         .padding(.top, 12)
    125         .padding(.bottom, 8)
    126     }
    127 
    128     private func letterKey(_ letter: String) -> some View {
    129         Button {
    130             if session.isRebusActive {
    131                 session.appendRebusLetter(letter)
    132             } else {
    133                 session.enter(letter)
    134             }
    135         } label: {
    136             Text(letter)
    137                 .font(.system(size: 22, weight: .medium, design: .rounded))
    138                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    139                 .background(Color(.tertiarySystemBackground))
    140                 .foregroundStyle(.primary)
    141                 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
    142         }
    143         .buttonStyle(.plain)
    144     }
    145 
    146     private var rebusKey: some View {
    147         Group {
    148             if session.isRebusActive {
    149                 actionKey(text: "Done", background: .blue, foreground: .white) {
    150                     session.commitRebus()
    151                 }
    152             } else {
    153                 actionKey(text: "Rebus") {
    154                     session.startRebus()
    155                 }
    156             }
    157         }
    158     }
    159 
    160     private var overflowKey: some View {
    161         actionKey(systemImage: "ellipsis") {
    162             showingOverflow = true
    163         }
    164         .keyWidthMultiplier(metaKeyWidthMultiplier)
    165         .popover(isPresented: $showingOverflow) {
    166             VStack(alignment: .leading, spacing: 0) {
    167                 overflowItem("Undo Move", systemImage: "arrow.uturn.backward", isEnabled: session.canUndo) {
    168                     session.undo()
    169                 }
    170 
    171                 overflowItem("Redo Move", systemImage: "arrow.uturn.forward", isEnabled: session.canRedo) {
    172                     session.redo()
    173                 }
    174 
    175                 Divider()
    176 
    177                 overflowItem("Toggle Draft", systemImage: "pencil") {
    178                     session.togglePencil()
    179                 }
    180 
    181                 overflowItem("Enter Rebus", systemImage: "text.cursor") {
    182                     session.startRebus()
    183                 }
    184             }
    185             .frame(minWidth: 160)
    186             .presentationCompactAdaptation(.popover)
    187             .presentationBackground(.thinMaterial)
    188         }
    189     }
    190 
    191     /// One row in the overflow popover. Dismisses the popover, then runs the
    192     /// action — disabled rows (e.g. Undo with nothing to undo) are greyed out.
    193     private func overflowItem(
    194         _ title: String,
    195         systemImage: String,
    196         isEnabled: Bool = true,
    197         action: @escaping () -> Void
    198     ) -> some View {
    199         Button {
    200             showingOverflow = false
    201             action()
    202         } label: {
    203             Label(title, systemImage: systemImage)
    204                 .frame(maxWidth: .infinity, alignment: .leading)
    205                 .padding(.horizontal, 16)
    206                 .padding(.vertical, 12)
    207         }
    208         .buttonStyle(.plain)
    209         .disabled(!isEnabled)
    210     }
    211 
    212     private func actionKey(
    213         systemImage: String,
    214         accessibilityLabel: String? = nil,
    215         background: Color = Color(.systemFill),
    216         foreground: Color = .primary,
    217         action: @escaping () -> Void
    218     ) -> some View {
    219         Button(action: action) {
    220             Image(systemName: systemImage)
    221                 .font(.system(size: 18, weight: .medium))
    222                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    223                 .background(background)
    224                 .foregroundStyle(foreground)
    225                 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
    226         }
    227         .buttonStyle(.plain)
    228         .accessibilityLabel(accessibilityLabel ?? systemImage)
    229     }
    230 
    231     private func actionKey(
    232         text: String,
    233         background: Color = Color(.systemFill),
    234         foreground: Color = .primary,
    235         action: @escaping () -> Void
    236     ) -> some View {
    237         Button(action: action) {
    238             Text(text)
    239                 .font(.system(size: 16, weight: .semibold, design: .rounded))
    240                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    241                 .background(background)
    242                 .foregroundStyle(foreground)
    243                 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
    244         }
    245         .buttonStyle(.plain)
    246     }
    247 
    248 }
    249 
    250 // MARK: - Layout
    251 
    252 /// Lays out a row of keys at a fixed key height. Every key is sized as a
    253 /// fraction of `referenceColumns`, so a row containing fewer keys ends up
    254 /// narrower than the reference row and is centered. Action keys can opt into
    255 /// a wider width via `keyWidthMultiplier`, and selected keys can split any
    256 /// leftover row width via `fillsExtraKeyboardSpace`.
    257 private struct KeyboardRow: Layout {
    258     let referenceColumns: Int
    259     let spacing: CGFloat
    260     let keyHeight: CGFloat
    261 
    262     func sizeThatFits(
    263         proposal: ProposedViewSize,
    264         subviews: Subviews,
    265         cache: inout ()
    266     ) -> CGSize {
    267         CGSize(width: proposal.width ?? 0, height: keyHeight)
    268     }
    269 
    270     func placeSubviews(
    271         in bounds: CGRect,
    272         proposal: ProposedViewSize,
    273         subviews: Subviews,
    274         cache: inout ()
    275     ) {
    276         let containerWidth = bounds.width
    277         let columns = CGFloat(referenceColumns)
    278         let baseKeyWidth = (containerWidth - spacing * (columns - 1)) / columns
    279 
    280         let widths = measuredWidths(for: subviews, baseKeyWidth: baseKeyWidth, containerWidth: containerWidth)
    281         let totalWidth = widths.reduce(0, +) + spacing * CGFloat(max(0, subviews.count - 1))
    282 
    283         var x = bounds.minX + (containerWidth - totalWidth) / 2
    284         for (index, subview) in subviews.enumerated() {
    285             let width = widths[index]
    286             subview.place(
    287                 at: CGPoint(x: x, y: bounds.minY),
    288                 anchor: .topLeading,
    289                 proposal: ProposedViewSize(width: width, height: keyHeight)
    290             )
    291             x += width + spacing
    292         }
    293     }
    294 
    295     private func measuredWidths(
    296         for subviews: Subviews,
    297         baseKeyWidth: CGFloat,
    298         containerWidth: CGFloat
    299     ) -> [CGFloat] {
    300         var widths = subviews.map { baseKeyWidth * $0[KeyWidthMultiplier.self] }
    301         let fixedSpacing = spacing * CGFloat(max(0, subviews.count - 1))
    302         let totalWidth = widths.reduce(0, +) + fixedSpacing
    303         let flexibleIndexes = subviews.indices.filter { subviews[$0][FillsExtraKeyboardSpace.self] }
    304         guard totalWidth < containerWidth, !flexibleIndexes.isEmpty else {
    305             return widths
    306         }
    307 
    308         let extraWidth = (containerWidth - totalWidth) / CGFloat(flexibleIndexes.count)
    309         for index in flexibleIndexes {
    310             widths[index] += extraWidth
    311         }
    312         return widths
    313     }
    314 }
    315 
    316 private struct KeyWidthMultiplier: LayoutValueKey {
    317     static let defaultValue: CGFloat = 1.0
    318 }
    319 
    320 private struct FillsExtraKeyboardSpace: LayoutValueKey {
    321     static let defaultValue = false
    322 }
    323 
    324 private extension View {
    325     func keyWidthMultiplier(_ multiplier: CGFloat) -> some View {
    326         layoutValue(key: KeyWidthMultiplier.self, value: multiplier)
    327     }
    328 
    329     func fillsExtraKeyboardSpace(_ fills: Bool = true) -> some View {
    330         layoutValue(key: FillsExtraKeyboardSpace.self, value: fills)
    331     }
    332 
    333     func disablesKeyboardMetaAnimations() -> some View {
    334         transaction { transaction in
    335             transaction.animation = nil
    336             transaction.disablesAnimations = true
    337         }
    338     }
    339 }