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 }