feat(typing): improve key hit accuracy and bump to 1.6.1
Add gap-filling hit regions, release-to-commit with slide-to-reselect, touch intent offset, and full-pinyin next-key bias; cut release 1.6.1 (build 45).
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
// TypingKeyTouchPad.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Grid-level UIKit touch tracking for the typing surface:
|
||||
// Down highlight → Move reselect → Up commit (letters / space / return),
|
||||
// delete repeats on down, Shift holds while the gesture owns it.
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import OSGKeyboardShared
|
||||
|
||||
struct TypingKeyTouchPad: UIViewRepresentable {
|
||||
var layout: TypingKeyLayout
|
||||
var hapticIntensity: KeyboardHapticIntensity
|
||||
var onHighlightChange: (String?) -> Void
|
||||
var onCommit: (TypingKeyHitTarget) -> Void
|
||||
var onDeleteFire: () -> Void
|
||||
var onShiftBegan: () -> Void
|
||||
var onShiftEnded: () -> Void
|
||||
|
||||
func makeUIView(context: Context) -> TypingKeyTouchPadUIView {
|
||||
let view = TypingKeyTouchPadUIView()
|
||||
view.coordinator = context.coordinator
|
||||
return view
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: TypingKeyTouchPadUIView, context: Context) {
|
||||
context.coordinator.parent = self
|
||||
uiView.layoutModel = layout
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(parent: self)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class Coordinator {
|
||||
var parent: TypingKeyTouchPad
|
||||
private var activeKeyID: String?
|
||||
private var gestureOwnsShift = false
|
||||
private var deleteRepeatTask: Task<Void, Never>?
|
||||
private var deleteRepeatStartedAt: Date?
|
||||
private var isDeleteRepeating = false
|
||||
|
||||
init(parent: TypingKeyTouchPad) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func handleBegan(at point: CGPoint) {
|
||||
resetDeleteRepeat()
|
||||
gestureOwnsShift = false
|
||||
guard let key = hit(at: point) else {
|
||||
setHighlight(nil)
|
||||
return
|
||||
}
|
||||
activate(key)
|
||||
}
|
||||
|
||||
func handleMoved(at point: CGPoint) {
|
||||
let key = hit(at: point)
|
||||
if key?.id == activeKeyID { return }
|
||||
|
||||
if activeBehavior == .deleteRepeat {
|
||||
stopDeleteRepeat()
|
||||
}
|
||||
|
||||
if let key {
|
||||
activate(key)
|
||||
} else {
|
||||
// Outside plane: clear highlight; Shift stays held until ended.
|
||||
setHighlight(nil)
|
||||
activeKeyID = nil
|
||||
}
|
||||
}
|
||||
|
||||
func handleEnded(at point: CGPoint) {
|
||||
// Outside the key plane → cancel (no commit), per accuracy plan.
|
||||
let key = hit(at: point)
|
||||
stopDeleteRepeat()
|
||||
|
||||
defer {
|
||||
setHighlight(nil)
|
||||
activeKeyID = nil
|
||||
finishShiftIfNeeded()
|
||||
}
|
||||
|
||||
guard let key else { return }
|
||||
|
||||
switch key.behavior {
|
||||
case .commitOnRelease:
|
||||
parent.onCommit(key)
|
||||
case .deleteRepeat:
|
||||
// Already fired on down / while held.
|
||||
break
|
||||
case .shiftHold:
|
||||
// endShiftHold decides tap vs hold-with-type.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func handleCancelled() {
|
||||
stopDeleteRepeat()
|
||||
setHighlight(nil)
|
||||
activeKeyID = nil
|
||||
finishShiftIfNeeded()
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private var activeBehavior: TypingKeyTouchBehavior? {
|
||||
guard let activeKeyID else { return nil }
|
||||
return parent.layout.key(id: activeKeyID)?.behavior
|
||||
}
|
||||
|
||||
private func hit(at point: CGPoint) -> TypingKeyHitTarget? {
|
||||
let layout = parent.layout
|
||||
return KeyHitTesting.hitTarget(
|
||||
rawTouch: point,
|
||||
targets: layout.keys,
|
||||
keyPlaneBounds: layout.keyPlaneBounds,
|
||||
horizontalGap: layout.horizontalGap,
|
||||
verticalGap: layout.verticalGap,
|
||||
hitWeights: layout.hitWeights
|
||||
)
|
||||
}
|
||||
|
||||
private func activate(_ key: TypingKeyHitTarget) {
|
||||
activeKeyID = key.id
|
||||
setHighlight(key.id)
|
||||
playFeedback(for: key)
|
||||
|
||||
switch key.behavior {
|
||||
case .commitOnRelease:
|
||||
break
|
||||
case .deleteRepeat:
|
||||
parent.onDeleteFire()
|
||||
startDeleteRepeat()
|
||||
case .shiftHold:
|
||||
if !gestureOwnsShift {
|
||||
gestureOwnsShift = true
|
||||
parent.onShiftBegan()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setHighlight(_ id: String?) {
|
||||
parent.onHighlightChange(id)
|
||||
}
|
||||
|
||||
private func playFeedback(for key: TypingKeyHitTarget) {
|
||||
let role: KeyboardHapticKeyRole
|
||||
let isDelete: Bool
|
||||
switch key.behavior {
|
||||
case .deleteRepeat:
|
||||
role = .delete
|
||||
isDelete = true
|
||||
case .shiftHold:
|
||||
role = .modifier
|
||||
isDelete = false
|
||||
case .commitOnRelease:
|
||||
isDelete = false
|
||||
if key.id.hasPrefix("bottom.") {
|
||||
role = key.id == TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue
|
||||
? .modifier
|
||||
: .action
|
||||
} else if ["123", "#+=", "ABC"].contains(key.label) {
|
||||
role = .modifier
|
||||
} else {
|
||||
role = .character
|
||||
}
|
||||
}
|
||||
TypingKeyFeedback.play(
|
||||
role: role,
|
||||
intensity: parent.hapticIntensity,
|
||||
isDelete: isDelete
|
||||
)
|
||||
}
|
||||
|
||||
private func startDeleteRepeat() {
|
||||
stopDeleteRepeat()
|
||||
isDeleteRepeating = true
|
||||
deleteRepeatStartedAt = Date()
|
||||
deleteRepeatTask = Task { @MainActor in
|
||||
try? await Task.sleep(
|
||||
nanoseconds: UInt64(RepeatingDeleteTiming.initialDelay * 1_000_000_000)
|
||||
)
|
||||
guard !Task.isCancelled, self.isDeleteRepeating else { return }
|
||||
let anchor = self.deleteRepeatStartedAt ?? Date()
|
||||
while !Task.isCancelled, self.isDeleteRepeating {
|
||||
// Match RepeatingPressButton: sound/haptic then action each tick.
|
||||
TypingKeyFeedback.play(
|
||||
role: .delete,
|
||||
intensity: self.parent.hapticIntensity,
|
||||
isDelete: true
|
||||
)
|
||||
self.parent.onDeleteFire()
|
||||
let elapsed = Date().timeIntervalSince(anchor)
|
||||
let wait = RepeatingDeleteTiming.interval(for: elapsed)
|
||||
try? await Task.sleep(nanoseconds: UInt64(wait * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopDeleteRepeat() {
|
||||
isDeleteRepeating = false
|
||||
deleteRepeatStartedAt = nil
|
||||
deleteRepeatTask?.cancel()
|
||||
deleteRepeatTask = nil
|
||||
}
|
||||
|
||||
private func resetDeleteRepeat() {
|
||||
stopDeleteRepeat()
|
||||
}
|
||||
|
||||
private func finishShiftIfNeeded() {
|
||||
guard gestureOwnsShift else { return }
|
||||
gestureOwnsShift = false
|
||||
parent.onShiftEnded()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class TypingKeyTouchPadUIView: UIView {
|
||||
weak var coordinator: TypingKeyTouchPad.Coordinator?
|
||||
var layoutModel = TypingKeyLayout(
|
||||
keys: [],
|
||||
keyPlaneBounds: .zero,
|
||||
horizontalGap: 6,
|
||||
verticalGap: 7,
|
||||
bottomRowMinY: 0
|
||||
)
|
||||
|
||||
/// Same trick as CursorDragPad: non-zero alpha so SwiftUI hosting does
|
||||
/// not treat the pad as pass-through.
|
||||
private static let padTint = UIColor { traits in
|
||||
UIColor.systemGray4.resolvedColor(with: traits).withAlphaComponent(0.02)
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = Self.padTint
|
||||
isMultipleTouchEnabled = false
|
||||
isExclusiveTouch = true
|
||||
isUserInteractionEnabled = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
coordinator?.handleBegan(at: touch.location(in: self))
|
||||
}
|
||||
|
||||
override func touchesMoved(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
coordinator?.handleMoved(at: touch.location(in: self))
|
||||
}
|
||||
|
||||
override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
guard let touch = touches.first else { return }
|
||||
coordinator?.handleEnded(at: touch.location(in: self))
|
||||
}
|
||||
|
||||
override func touchesCancelled(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
coordinator?.handleCancelled()
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,8 @@ struct TypingRootView: View {
|
||||
/// After the first expand, keep the panel tree mounted and only toggle
|
||||
/// opacity so subsequent ▼/▲ taps stay cheap.
|
||||
@State private var candidatePanelMounted = false
|
||||
/// Key currently under the finger (grid-level touch pad).
|
||||
@State private var highlightedKeyID: String?
|
||||
|
||||
static let totalHeight: CGFloat = TypingLayoutMetrics.totalHeight
|
||||
|
||||
@@ -58,17 +60,11 @@ struct TypingRootView: View {
|
||||
// Keep the QWERTY tree mounted; only toggle visibility so ▼/▲
|
||||
// does not destroy GeometryReader key rows every time.
|
||||
ZStack(alignment: .top) {
|
||||
VStack(spacing: 0) {
|
||||
keyGrid
|
||||
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
|
||||
|
||||
bottomRow
|
||||
.frame(height: TypingLayoutMetrics.bottomRowHeight)
|
||||
.padding(.top, TypingLayoutMetrics.keyRowSpacing)
|
||||
}
|
||||
.opacity(expanded ? 0 : 1)
|
||||
.allowsHitTesting(!expanded)
|
||||
.accessibilityHidden(expanded)
|
||||
typingKeySurface
|
||||
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
|
||||
.opacity(expanded ? 0 : 1)
|
||||
.allowsHitTesting(!expanded)
|
||||
.accessibilityHidden(expanded)
|
||||
|
||||
if expanded || candidatePanelMounted {
|
||||
expandedCandidatePanel
|
||||
@@ -277,242 +273,195 @@ struct TypingRootView: View {
|
||||
|
||||
// MARK: - Keys
|
||||
|
||||
private var keyGrid: some View {
|
||||
VStack(spacing: TypingLayoutMetrics.keyRowSpacing) {
|
||||
ForEach(Array(typing.keyRows.enumerated()), id: \.offset) { rowIndex, row in
|
||||
GeometryReader { proxy in
|
||||
let inset = rowIndex == 1 ? TypingLayoutMetrics.secondRowInset : 0
|
||||
let weights = row.enumerated().map {
|
||||
keyWeight(label: $0.element, index: $0.offset, rowIndex: rowIndex)
|
||||
}
|
||||
let spacing = TypingLayoutMetrics.keyHorizontalSpacing
|
||||
* CGFloat(max(0, row.count - 1))
|
||||
let availableWidth = proxy.size.width - inset * 2 - spacing
|
||||
let unitWidth = availableWidth / max(1, weights.reduce(0, +))
|
||||
/// Visual keys + grid-level touch pad (Phase 1 gap fill + Phase 2
|
||||
/// down → move → up). Geometry is shared via ``TypingKeyLayoutBuilder``.
|
||||
private var typingKeySurface: some View {
|
||||
GeometryReader { proxy in
|
||||
let layout = makeTypingKeyLayout(size: proxy.size)
|
||||
ZStack(alignment: .topLeading) {
|
||||
TypingKeyTouchPad(
|
||||
layout: layout,
|
||||
hapticIntensity: state.keyboardHapticIntensity,
|
||||
onHighlightChange: { highlightedKeyID = $0 },
|
||||
onCommit: { commitTypingKey($0) },
|
||||
onDeleteFire: { apply(typing.handleKey("⌫")) },
|
||||
onShiftBegan: { typing.beginShiftHold() },
|
||||
onShiftEnded: { typing.endShiftHold() }
|
||||
)
|
||||
|
||||
HStack(spacing: TypingLayoutMetrics.keyHorizontalSpacing) {
|
||||
ForEach(Array(row.enumerated()), id: \.offset) { keyIndex, key in
|
||||
keyButton(key)
|
||||
.frame(width: unitWidth * weights[keyIndex])
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, inset)
|
||||
ForEach(layout.keys) { key in
|
||||
visualTypingKey(key)
|
||||
.frame(width: key.visualFrame.width, height: key.visualFrame.height)
|
||||
.position(
|
||||
x: key.visualFrame.midX,
|
||||
y: key.visualFrame.midY
|
||||
)
|
||||
// Touches go to the UIKit pad; visuals stay for VoiceOver.
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.frame(height: TypingLayoutMetrics.keyRowHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeTypingKeyLayout(size: CGSize) -> TypingKeyLayout {
|
||||
let pageLabel = typing.page == .letters ? "123" : "ABC"
|
||||
let spaceLabel = typing.language == .chinese ? "空格" : "space"
|
||||
let returnLabel: String = {
|
||||
switch state.returnKeyRole {
|
||||
case .newline: return "return"
|
||||
case .send: return ExtL10n.string(state.returnKeyRole.titleKey)
|
||||
}
|
||||
}()
|
||||
|
||||
let layout = TypingKeyLayoutBuilder.build(
|
||||
size: size,
|
||||
letterRows: typing.keyRows,
|
||||
pageSwitchLabel: pageLabel,
|
||||
spaceLabel: spaceLabel,
|
||||
returnLabel: returnLabel,
|
||||
metrics: TypingKeyLayoutBuilder.Metrics(
|
||||
keyRowHeight: TypingLayoutMetrics.keyRowHeight,
|
||||
keyRowSpacing: TypingLayoutMetrics.keyRowSpacing,
|
||||
keyHorizontalSpacing: TypingLayoutMetrics.keyHorizontalSpacing,
|
||||
secondRowInset: TypingLayoutMetrics.secondRowInset,
|
||||
bottomRowHeight: TypingLayoutMetrics.bottomRowHeight,
|
||||
bottomActionSpacing: TypingLayoutMetrics.bottomActionSpacing,
|
||||
gridToBottomSpacing: TypingLayoutMetrics.keyRowSpacing
|
||||
),
|
||||
keyWeight: { label, index, rowIndex in
|
||||
keyWeight(label: label, index: index, rowIndex: rowIndex)
|
||||
}
|
||||
)
|
||||
let validNext = PinyinNextKeyResolver.validNextKeys(
|
||||
rawInput: typing.composition.rawInput,
|
||||
schema: typing.schema,
|
||||
language: typing.language,
|
||||
page: typing.page
|
||||
)
|
||||
return layout.withHitWeights(
|
||||
PinyinNextKeyResolver.hitWeights(for: layout.keys, validNext: validNext)
|
||||
)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func keyButton(_ label: String) -> some View {
|
||||
if label == "⌫" {
|
||||
typingDeleteKey()
|
||||
} else if label == "⇧" {
|
||||
typingShiftKey()
|
||||
} else {
|
||||
let isSpecial = ["123", "#+=", "ABC"].contains(label)
|
||||
let role: KeyboardHapticKeyRole = isSpecial ? .modifier : .character
|
||||
PressDownKeyButton {
|
||||
TypingKeyFeedback.play(
|
||||
role: role,
|
||||
intensity: state.keyboardHapticIntensity
|
||||
)
|
||||
apply(typing.handleKey(label))
|
||||
} label: { isPressed in
|
||||
NativeKeyboardKeySurface(
|
||||
isPressed: isPressed,
|
||||
fill: keyFill,
|
||||
pressedFill: keyPressedFill,
|
||||
border: palette.divider,
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius
|
||||
) {
|
||||
keyLabel(label, isSpecial: isSpecial)
|
||||
}
|
||||
private func visualTypingKey(_ key: TypingKeyHitTarget) -> some View {
|
||||
let pressed = highlightedKeyID == key.id
|
||||
let isBottom = key.id.hasPrefix("bottom.")
|
||||
let isShift = key.label == "⇧"
|
||||
let shiftLit = isShift && typing.isShiftEnabled
|
||||
let showPressed = pressed || shiftLit
|
||||
|
||||
Group {
|
||||
if key.label == "⌫" {
|
||||
Image(systemName: "delete.left")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(keyTextColor)
|
||||
} else if key.label == "⇧" {
|
||||
Image(systemName: typing.isShiftEnabled ? "shift.fill" : "shift")
|
||||
.font(.system(size: 19, weight: .medium))
|
||||
.foregroundStyle(keyTextColor)
|
||||
} else if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue {
|
||||
returnKeyLabel
|
||||
.foregroundStyle(returnKeyTextColor)
|
||||
} else if isBottom {
|
||||
let isSpecial = key.id == TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue
|
||||
Text(key.label)
|
||||
.font(
|
||||
.system(
|
||||
size: isSpecial ? 15 : 17,
|
||||
weight: isSpecial ? .semibold : .regular
|
||||
)
|
||||
)
|
||||
.foregroundStyle(keyTextColor)
|
||||
} else {
|
||||
let isSpecial = ["123", "#+=", "ABC"].contains(key.label)
|
||||
Text(key.label)
|
||||
.font(
|
||||
.system(
|
||||
size: isSpecial ? 15 : 22,
|
||||
weight: isSpecial ? .semibold : .regular
|
||||
)
|
||||
)
|
||||
.foregroundStyle(keyTextColor)
|
||||
}
|
||||
.accessibilityLabel(Text(label))
|
||||
}
|
||||
}
|
||||
|
||||
/// Hold = continuous uppercase while pressed; tap = one-shot / Caps Lock cycle.
|
||||
private func typingShiftKey() -> some View {
|
||||
let lit = typing.isShiftEnabled
|
||||
return keyLabel("⇧", isSpecial: true)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
.fill(lit ? keyPressedFill : keyFill)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
.shadow(
|
||||
color: Color.black.opacity(lit ? 0.04 : 0.13),
|
||||
radius: lit ? 0.5 : 1,
|
||||
y: lit ? 0 : 1
|
||||
)
|
||||
.contentShape(Rectangle())
|
||||
.gesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
if !typing.shiftHeld {
|
||||
TypingKeyFeedback.play(
|
||||
role: .modifier,
|
||||
intensity: state.keyboardHapticIntensity
|
||||
)
|
||||
typing.beginShiftHold()
|
||||
}
|
||||
}
|
||||
.onEnded { _ in
|
||||
typing.endShiftHold()
|
||||
}
|
||||
)
|
||||
.accessibilityLabel(Text("shift"))
|
||||
.accessibilityAddTraits(.isButton)
|
||||
}
|
||||
|
||||
/// Shares ``RepeatingPressButton`` with the voice toolbar delete key.
|
||||
private func typingDeleteKey() -> some View {
|
||||
RepeatingPressButton(hapticIntensity: state.keyboardHapticIntensity) {
|
||||
apply(typing.handleKey("⌫"))
|
||||
} label: { isPressed in
|
||||
Image(systemName: "delete.left")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(keyTextColor)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
.fill(isPressed ? keyPressedFill : keyFill)
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
.stroke(palette.divider, lineWidth: 0.5)
|
||||
)
|
||||
.shadow(
|
||||
color: Color.black.opacity(isPressed ? 0.04 : 0.13),
|
||||
radius: isPressed ? 0.5 : 1,
|
||||
y: isPressed ? 0 : 1
|
||||
)
|
||||
.scaleEffect(isPressed ? 0.98 : 1)
|
||||
.animation(.easeOut(duration: 0.08), value: isPressed)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func keyLabel(_ label: String, isSpecial: Bool) -> some View {
|
||||
switch label {
|
||||
case "⇧":
|
||||
Image(systemName: typing.isShiftEnabled ? "shift.fill" : "shift")
|
||||
.font(.system(size: 19, weight: .medium))
|
||||
.foregroundStyle(keyTextColor)
|
||||
default:
|
||||
Text(label)
|
||||
.font(
|
||||
.system(
|
||||
size: isSpecial ? 15 : 22,
|
||||
weight: isSpecial ? .semibold : .regular
|
||||
)
|
||||
)
|
||||
.foregroundStyle(keyTextColor)
|
||||
.background(
|
||||
RoundedRectangle(
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
.fill(visualKeyFill(for: key, pressed: showPressed))
|
||||
)
|
||||
.overlay(
|
||||
RoundedRectangle(
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius,
|
||||
style: .continuous
|
||||
)
|
||||
.stroke(visualKeyBorder(for: key), lineWidth: 0.5)
|
||||
)
|
||||
.shadow(
|
||||
color: Color.black.opacity(showPressed ? 0.04 : 0.13),
|
||||
radius: showPressed ? 0.5 : 1,
|
||||
y: showPressed ? 0 : 1
|
||||
)
|
||||
.scaleEffect(pressed ? 0.98 : 1)
|
||||
.animation(.easeOut(duration: 0.08), value: pressed)
|
||||
.accessibilityElement()
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityLabel(Text(accessibilityLabel(for: key)))
|
||||
.accessibilityAction {
|
||||
commitTypingKey(key)
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomRow: some View {
|
||||
GeometryReader { proxy in
|
||||
let widths = KeyboardChromeLayout.actionKeyWidths(
|
||||
availableWidth: proxy.size.width
|
||||
)
|
||||
private func visualKeyFill(for key: TypingKeyHitTarget, pressed: Bool) -> Color {
|
||||
if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue {
|
||||
return pressed ? returnKeyPressedFill : returnKeyFill
|
||||
}
|
||||
return pressed ? keyPressedFill : keyFill
|
||||
}
|
||||
|
||||
HStack(spacing: TypingLayoutMetrics.bottomActionSpacing) {
|
||||
PressDownKeyButton {
|
||||
TypingKeyFeedback.play(
|
||||
role: .modifier,
|
||||
intensity: state.keyboardHapticIntensity
|
||||
)
|
||||
typing.setPage(typing.page == .letters ? .numbers : .letters)
|
||||
} label: { isPressed in
|
||||
NativeKeyboardKeySurface(
|
||||
isPressed: isPressed,
|
||||
fill: keyFill,
|
||||
pressedFill: keyPressedFill,
|
||||
border: palette.divider,
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius
|
||||
) {
|
||||
Text(typing.page == .letters ? "123" : "ABC")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(keyTextColor)
|
||||
}
|
||||
.frame(
|
||||
width: widths.side,
|
||||
height: TypingLayoutMetrics.bottomRowHeight
|
||||
)
|
||||
}
|
||||
.accessibilityLabel(Text(typing.page == .letters ? "123" : "ABC"))
|
||||
private func visualKeyBorder(for key: TypingKeyHitTarget) -> Color {
|
||||
if key.id == TypingKeyLayoutBuilder.BottomKeyID.return.rawValue {
|
||||
return returnKeyBorder
|
||||
}
|
||||
return palette.divider
|
||||
}
|
||||
|
||||
PressDownKeyButton {
|
||||
TypingKeyFeedback.play(
|
||||
role: .action,
|
||||
intensity: state.keyboardHapticIntensity
|
||||
)
|
||||
apply(typing.handleSpace())
|
||||
} label: { isPressed in
|
||||
NativeKeyboardKeySurface(
|
||||
isPressed: isPressed,
|
||||
fill: keyFill,
|
||||
pressedFill: keyPressedFill,
|
||||
border: palette.divider,
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius
|
||||
) {
|
||||
Text(typing.language == .chinese ? "空格" : "space")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(keyTextColor)
|
||||
}
|
||||
.frame(
|
||||
width: widths.center,
|
||||
height: TypingLayoutMetrics.bottomRowHeight
|
||||
)
|
||||
}
|
||||
.accessibilityLabel(Text(typing.language == .chinese ? "空格" : "space"))
|
||||
private func accessibilityLabel(for key: TypingKeyHitTarget) -> String {
|
||||
switch key.id {
|
||||
case TypingKeyLayoutBuilder.BottomKeyID.return.rawValue:
|
||||
return returnKeyAccessibilityLabel
|
||||
case TypingKeyLayoutBuilder.BottomKeyID.space.rawValue:
|
||||
return key.label
|
||||
default:
|
||||
if key.label == "⇧" { return "shift" }
|
||||
if key.label == "⌫" { return "delete" }
|
||||
return key.label
|
||||
}
|
||||
}
|
||||
|
||||
PressDownKeyButton {
|
||||
TypingKeyFeedback.play(
|
||||
role: .action,
|
||||
intensity: state.keyboardHapticIntensity
|
||||
)
|
||||
apply(typing.handleReturn())
|
||||
} label: { isPressed in
|
||||
NativeKeyboardKeySurface(
|
||||
isPressed: isPressed,
|
||||
fill: returnKeyFill,
|
||||
pressedFill: returnKeyPressedFill,
|
||||
border: returnKeyBorder,
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius
|
||||
) {
|
||||
returnKeyLabel
|
||||
.foregroundStyle(returnKeyTextColor)
|
||||
}
|
||||
.frame(
|
||||
width: widths.side,
|
||||
height: TypingLayoutMetrics.bottomRowHeight
|
||||
)
|
||||
}
|
||||
.accessibilityLabel(Text(returnKeyAccessibilityLabel))
|
||||
private func commitTypingKey(_ key: TypingKeyHitTarget) {
|
||||
switch key.id {
|
||||
case TypingKeyLayoutBuilder.BottomKeyID.pageSwitch.rawValue:
|
||||
typing.setPage(typing.page == .letters ? .numbers : .letters)
|
||||
case TypingKeyLayoutBuilder.BottomKeyID.space.rawValue:
|
||||
apply(typing.handleSpace())
|
||||
case TypingKeyLayoutBuilder.BottomKeyID.return.rawValue:
|
||||
apply(typing.handleReturn())
|
||||
default:
|
||||
switch key.behavior {
|
||||
case .commitOnRelease:
|
||||
apply(typing.handleKey(key.label))
|
||||
case .deleteRepeat:
|
||||
apply(typing.handleKey("⌫"))
|
||||
case .shiftHold:
|
||||
// Mirror a completed Shift tap for VoiceOver / accessibility.
|
||||
typing.beginShiftHold()
|
||||
typing.endShiftHold()
|
||||
}
|
||||
}
|
||||
.frame(height: TypingLayoutMetrics.bottomRowHeight)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
Reference in New Issue
Block a user