crossmate

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

KeyboardView.swift (11744B)


      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                 Button {
    168                     showingOverflow = false
    169                     session.togglePencil()
    170                 } label: {
    171                     Text("Toggle Draft")
    172                         .frame(maxWidth: .infinity, alignment: .leading)
    173                         .padding(.horizontal, 16)
    174                         .padding(.vertical, 12)
    175                 }
    176                 .buttonStyle(.plain)
    177 
    178                 Button {
    179                     showingOverflow = false
    180                     session.startRebus()
    181                 } label: {
    182                     Text("Enter Rebus")
    183                         .frame(maxWidth: .infinity, alignment: .leading)
    184                         .padding(.horizontal, 16)
    185                         .padding(.vertical, 12)
    186                 }
    187                 .buttonStyle(.plain)
    188             }
    189             .frame(minWidth: 160)
    190             .presentationCompactAdaptation(.popover)
    191             .presentationBackground(.thinMaterial)
    192         }
    193     }
    194 
    195     private func actionKey(
    196         systemImage: String,
    197         accessibilityLabel: String? = nil,
    198         background: Color = Color(.systemFill),
    199         foreground: Color = .primary,
    200         action: @escaping () -> Void
    201     ) -> some View {
    202         Button(action: action) {
    203             Image(systemName: systemImage)
    204                 .font(.system(size: 18, weight: .medium))
    205                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    206                 .background(background)
    207                 .foregroundStyle(foreground)
    208                 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
    209         }
    210         .buttonStyle(.plain)
    211         .accessibilityLabel(accessibilityLabel ?? systemImage)
    212     }
    213 
    214     private func actionKey(
    215         text: String,
    216         background: Color = Color(.systemFill),
    217         foreground: Color = .primary,
    218         action: @escaping () -> Void
    219     ) -> some View {
    220         Button(action: action) {
    221             Text(text)
    222                 .font(.system(size: 16, weight: .semibold, design: .rounded))
    223                 .frame(maxWidth: .infinity, maxHeight: .infinity)
    224                 .background(background)
    225                 .foregroundStyle(foreground)
    226                 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
    227         }
    228         .buttonStyle(.plain)
    229     }
    230 
    231 }
    232 
    233 // MARK: - Layout
    234 
    235 /// Lays out a row of keys at a fixed key height. Every key is sized as a
    236 /// fraction of `referenceColumns`, so a row containing fewer keys ends up
    237 /// narrower than the reference row and is centered. Action keys can opt into
    238 /// a wider width via `keyWidthMultiplier`, and selected keys can split any
    239 /// leftover row width via `fillsExtraKeyboardSpace`.
    240 private struct KeyboardRow: Layout {
    241     let referenceColumns: Int
    242     let spacing: CGFloat
    243     let keyHeight: CGFloat
    244 
    245     func sizeThatFits(
    246         proposal: ProposedViewSize,
    247         subviews: Subviews,
    248         cache: inout ()
    249     ) -> CGSize {
    250         CGSize(width: proposal.width ?? 0, height: keyHeight)
    251     }
    252 
    253     func placeSubviews(
    254         in bounds: CGRect,
    255         proposal: ProposedViewSize,
    256         subviews: Subviews,
    257         cache: inout ()
    258     ) {
    259         let containerWidth = bounds.width
    260         let columns = CGFloat(referenceColumns)
    261         let baseKeyWidth = (containerWidth - spacing * (columns - 1)) / columns
    262 
    263         let widths = measuredWidths(for: subviews, baseKeyWidth: baseKeyWidth, containerWidth: containerWidth)
    264         let totalWidth = widths.reduce(0, +) + spacing * CGFloat(max(0, subviews.count - 1))
    265 
    266         var x = bounds.minX + (containerWidth - totalWidth) / 2
    267         for (index, subview) in subviews.enumerated() {
    268             let width = widths[index]
    269             subview.place(
    270                 at: CGPoint(x: x, y: bounds.minY),
    271                 anchor: .topLeading,
    272                 proposal: ProposedViewSize(width: width, height: keyHeight)
    273             )
    274             x += width + spacing
    275         }
    276     }
    277 
    278     private func measuredWidths(
    279         for subviews: Subviews,
    280         baseKeyWidth: CGFloat,
    281         containerWidth: CGFloat
    282     ) -> [CGFloat] {
    283         var widths = subviews.map { baseKeyWidth * $0[KeyWidthMultiplier.self] }
    284         let fixedSpacing = spacing * CGFloat(max(0, subviews.count - 1))
    285         let totalWidth = widths.reduce(0, +) + fixedSpacing
    286         let flexibleIndexes = subviews.indices.filter { subviews[$0][FillsExtraKeyboardSpace.self] }
    287         guard totalWidth < containerWidth, !flexibleIndexes.isEmpty else {
    288             return widths
    289         }
    290 
    291         let extraWidth = (containerWidth - totalWidth) / CGFloat(flexibleIndexes.count)
    292         for index in flexibleIndexes {
    293             widths[index] += extraWidth
    294         }
    295         return widths
    296     }
    297 }
    298 
    299 private struct KeyWidthMultiplier: LayoutValueKey {
    300     static let defaultValue: CGFloat = 1.0
    301 }
    302 
    303 private struct FillsExtraKeyboardSpace: LayoutValueKey {
    304     static let defaultValue = false
    305 }
    306 
    307 private extension View {
    308     func keyWidthMultiplier(_ multiplier: CGFloat) -> some View {
    309         layoutValue(key: KeyWidthMultiplier.self, value: multiplier)
    310     }
    311 
    312     func fillsExtraKeyboardSpace(_ fills: Bool = true) -> some View {
    313         layoutValue(key: FillsExtraKeyboardSpace.self, value: fills)
    314     }
    315 
    316     func disablesKeyboardMetaAnimations() -> some View {
    317         transaction { transaction in
    318             transaction.animation = nil
    319             transaction.disablesAnimations = true
    320         }
    321     }
    322 }