Files
mobile-ios/Rosetta/DesignSystem/Components/KeyboardTracker.swift

475 lines
19 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import Combine
import UIKit
/// Drives keyboard-related positioning for the chat composer.
///
/// Published property:
/// - `keyboardPadding`: bottom padding to apply when keyboard is visible
///
/// Animation strategy:
/// - Notification (show/hide): A hidden UIView is animated with the keyboard's
/// exact `UIViewAnimationCurve` (rawValue 7) inside the same Core Animation
/// transaction. CADisplayLink samples the presentation layer at 60fps,
/// giving pixel-perfect curve sync. Cubic bezier fallback if no window.
/// - KVO (interactive dismiss): raw assignment at 30fps via coalescing.
/// - NO `withAnimation` / `.animation()` these cause LazyVStack cell recycling gaps.
@MainActor
final class KeyboardTracker: ObservableObject {
static let shared = KeyboardTracker()
/// Bottom padding updated incrementally at display refresh rate.
@Published private(set) var keyboardPadding: CGFloat = 0
private var isAnimating = false
/// Public flag for BubbleContextMenuOverlay to skip updateUIView during animation.
/// NOT @Published read directly from UIViewRepresentable, no observation.
private(set) var isAnimatingKeyboard = false
private let bottomInset: CGFloat
private var pendingResetTask: Task<Void, Never>?
private var cancellables = Set<AnyCancellable>()
private var lastNotificationPadding: CGFloat = 0
// CADisplayLink-based animation state (notification-driven show/hide)
private var displayLinkProxy: DisplayLinkProxy?
private var animStartPadding: CGFloat = 0
private var animTargetPadding: CGFloat = 0
private var animStartTime: CFTimeInterval = 0
private var animDuration: CFTimeInterval = 0.25
private var animTickCount = 0
private var animationNumber = 0
private var lastTickTime: CFTimeInterval = 0
/// Monotonic guard sync view can give non-monotonic eased values
/// in the first 2 ticks while Core Animation commits the animation.
private var lastEased: CGFloat = 0
// Cubic bezier control points deterministic keyboard curve approximation.
private var bezierP1x: CGFloat = 0.25
private var bezierP1y: CGFloat = 0.1
private var bezierP2x: CGFloat = 0.25
private var bezierP2y: CGFloat = 1.0
// KVO coalescing buffers rapid KVO updates and applies at 30fps
// instead of immediately on every callback (~60fps). Halves body evaluations.
private var kvoDisplayLink: DisplayLinkProxy?
private var pendingKVOPadding: CGFloat?
/// Spring kept for potential future use (e.g., composer-only animation).
static let keyboardSpring = Animation.spring(duration: 0.25, bounce: 0)
private init() {
if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene,
let window = scene.keyWindow ?? scene.windows.first {
let bottom = window.safeAreaInsets.bottom
bottomInset = bottom < 50 ? bottom : 34
} else {
bottomInset = 34
}
// iOS 26+ handles keyboard natively no custom tracking needed.
if #available(iOS 26, *) { return }
NotificationCenter.default
.publisher(for: UIResponder.keyboardWillChangeFrameNotification)
.sink { [weak self] in self?.handleNotification($0) }
.store(in: &cancellables)
}
/// Called from KVO pixel-perfect interactive dismiss.
/// Buffers values and applies at 30fps via CADisplayLink coalescing
/// to reduce ChatDetailView.body evaluations during swipe-to-dismiss.
func updateFromKVO(keyboardHeight: CGFloat) {
if #available(iOS 26, *) { return }
PerformanceLogger.shared.track("keyboard.kvo")
guard !isAnimating else { return }
#if DEBUG
let rawPad = max(0, keyboardHeight - bottomInset)
if abs(rawPad - keyboardPadding) > 4 {
print("⌨️ 👆 KVO | height=\(Int(keyboardHeight)) → pad=\(Int(rawPad)) | current=\(Int(keyboardPadding))")
}
#endif
if keyboardHeight <= 0 {
// Flush any pending KVO value and stop coalescing
flushPendingKVO()
stopKVOCoalescing()
if keyboardPadding != 0 {
if pendingResetTask == nil {
pendingResetTask = Task { @MainActor [weak self] in
try? await Task.sleep(for: .milliseconds(150))
guard let self, !Task.isCancelled else { return }
if self.keyboardPadding != 0 {
self.keyboardPadding = 0
}
}
}
}
return
}
pendingResetTask?.cancel()
pendingResetTask = nil
// Round to nearest 2pt sub-point changes are invisible but still
// trigger full ChatDetailView.body evaluations.
let rawPadding = max(0, keyboardHeight - bottomInset)
let newPadding = round(rawPadding / 2) * 2
// Only track decreasing padding (swipe-to-dismiss)
let current = pendingKVOPadding ?? keyboardPadding
guard newPadding < current else { return }
// Buffer the value will be applied by kvoDisplayLink at 30fps
pendingKVOPadding = newPadding
// Start coalescing display link if not running
if kvoDisplayLink == nil {
kvoDisplayLink = DisplayLinkProxy(maxFPS: 30) { [weak self] in
self?.applyPendingKVO()
}
}
}
/// Called by kvoDisplayLink at 30fps applies buffered KVO value.
private func applyPendingKVO() {
guard let pending = pendingKVOPadding else {
// No pending value stop the display link
stopKVOCoalescing()
return
}
pendingKVOPadding = nil
// Guard: KVO can produce NaN during edge cases (view hierarchy changes)
guard pending.isFinite, pending >= 0 else { return }
guard pending != keyboardPadding else { return }
keyboardPadding = pending
}
/// Immediately applies any buffered KVO value (used when KVO stops).
private func flushPendingKVO() {
guard let pending = pendingKVOPadding else { return }
pendingKVOPadding = nil
if pending != keyboardPadding {
keyboardPadding = pending
}
}
private func stopKVOCoalescing() {
kvoDisplayLink?.stop()
kvoDisplayLink = nil
}
private func handleNotification(_ notification: Notification) {
guard let info = notification.userInfo,
let endFrame = info[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect
else { return }
isAnimating = true
// Stop KVO coalescing notification animation takes over
flushPendingKVO()
stopKVOCoalescing()
let screenHeight = UIScreen.main.bounds.height
let keyboardTop = endFrame.origin.y
let isVisible = keyboardTop < screenHeight
let endHeight = isVisible ? (screenHeight - keyboardTop) : 0
pendingResetTask?.cancel()
pendingResetTask = nil
let targetPadding = isVisible ? max(0, endHeight - bottomInset) : 0
let duration = info[UIResponder.keyboardAnimationDurationUserInfoKey] as? TimeInterval ?? 0.25
let curveRaw = info[UIResponder.keyboardAnimationCurveUserInfoKey] as? Int ?? 0
let delta = targetPadding - lastNotificationPadding
lastNotificationPadding = targetPadding
PerformanceLogger.shared.track("keyboard.notification")
#if DEBUG
let direction = targetPadding > keyboardPadding ? "⬆️ SHOW" : "⬇️ HIDE"
print("⌨️ \(direction) | current=\(Int(keyboardPadding)) → target=\(Int(targetPadding)) | delta=\(Int(delta)) | duration=\(String(format: "%.3f", duration))s | curve=\(curveRaw)")
#endif
// Filter spurious notifications (delta=0, duration=0) keyboard frame
// didn't actually change. Happens on some iOS versions during text input.
guard abs(delta) > 1 || targetPadding != keyboardPadding else { return }
// Animate with sync view (keyboard's exact CA curve) no pre-apply.
// Pre-apply caused a visible "jump" because input moved 16pt instantly
// before the keyboard had started moving. Without pre-apply, there may
// be ~1 frame of slight overlap (keyboard ahead of input) but it's
// imperceptible at 60Hz (16ms) and provides a much smoother feel.
if abs(delta) > 1, targetPadding != keyboardPadding {
isAnimatingKeyboard = true
startPaddingAnimation(to: targetPadding, duration: duration, curveRaw: curveRaw)
} else if keyboardPadding != targetPadding {
keyboardPadding = targetPadding
}
// Unblock KVO after keyboard settles.
let unblockDelay = max(duration, 0.05) + 0.15
Task { @MainActor [weak self] in
try? await Task.sleep(for: .seconds(unblockDelay))
self?.isAnimating = false
}
}
// MARK: - Stepped Animation (4 keyframes)
private var steppedAnimationTask: Task<Void, Never>?
/// Animates keyboardPadding in 4 steps over ~duration.
/// Each step triggers 1 layout pass. Total = 4 passes instead of 15.
/// Visually smooth enough for 250ms keyboard animation.
private func startSteppedAnimation(to target: CGFloat, duration: CFTimeInterval) {
steppedAnimationTask?.cancel()
let start = keyboardPadding
let safeDuration = max(duration, 0.1)
let steps = 4
let stepDelay = safeDuration / Double(steps)
steppedAnimationTask = Task { @MainActor [weak self] in
for i in 1...steps {
guard !Task.isCancelled, let self else { return }
let fraction = Double(i) / Double(steps)
// Ease-out curve: fast start, slow end
let eased = 1 - pow(1 - fraction, 2)
let value = max(0, round(start + (target - start) * eased))
if value != self.keyboardPadding {
PerformanceLogger.shared.track("keyboard.step")
self.keyboardPadding = value
}
if i < steps {
try? await Task.sleep(for: .milliseconds(Int(stepDelay * 1000)))
}
}
// Ensure we hit the exact target
guard let self, !Task.isCancelled else { return }
if self.keyboardPadding != target {
self.keyboardPadding = max(0, target)
}
}
}
// MARK: - CADisplayLink animation (sync view + bezier fallback)
// Sync view: hidden UIView animated with keyboard's exact curve.
// Reading its presentation layer gives the real easing value no guessing.
private var syncView: UIView?
private func startPaddingAnimation(to target: CGFloat, duration: CFTimeInterval, curveRaw: Int) {
animationNumber += 1
animStartPadding = keyboardPadding
animTargetPadding = target
animStartTime = CACurrentMediaTime()
animDuration = max(duration, 0.05)
animTickCount = 0
lastEased = 0
// Primary: sync view matches keyboard's exact curve (same CA transaction).
// No lead, no pre-apply the natural ~1 frame SwiftUI processing delay
// creates the "keyboard pushes input" effect (like Telegram).
let syncOK = setupSyncAnimation(duration: duration, curveRaw: curveRaw)
// Fallback: cubic bezier (only if sync view can't be created).
if !syncOK {
configureBezier(curveRaw: curveRaw)
}
// Reuse existing display link to preserve vsync phase alignment.
if let proxy = displayLinkProxy {
proxy.isPaused = false
} else {
displayLinkProxy = DisplayLinkProxy { [weak self] in
self?.animationTick()
}
}
}
/// Creates a hidden UIView animated with the keyboard's exact curve.
private func setupSyncAnimation(duration: CFTimeInterval, curveRaw: Int) -> Bool {
guard let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene }).first?.keyWindow else {
return false
}
syncView?.layer.removeAllAnimations()
syncView?.removeFromSuperview()
let view = UIView(frame: CGRect(x: -10, y: -10, width: 1, height: 1))
view.alpha = 0
window.addSubview(view)
syncView = view
let options = UIView.AnimationOptions(rawValue: UInt(curveRaw) << 16)
UIView.animate(withDuration: duration, delay: 0, options: [options]) {
view.alpha = 1
}
return true
}
private func animationTick() {
PerformanceLogger.shared.track("keyboard.animTick")
animTickCount += 1
let now = CACurrentMediaTime()
lastTickTime = now
let elapsed = now - animStartTime
let timeComplete = elapsed >= animDuration
// Read eased fraction: sync view (exact) or bezier (fallback).
var eased: CGFloat
if let presentation = syncView?.layer.presentation() {
eased = min(max(CGFloat(presentation.opacity), 0), 1)
} else {
let t = min(elapsed / animDuration, 1.0)
eased = cubicBezierEase(t)
}
// Enforce monotonic progress sync view's presentation layer can give
// non-monotonic values in the first 2 ticks while Core Animation commits
// the animation to the render server. Without this guard, padding oscillates
// (e.g. 312 306 310 302) causing a visible "jerk".
if eased < lastEased {
eased = lastEased
}
lastEased = eased
// SHOW only: lead by ~1 frame to prevent keyboard overlapping input.
// The value we publish NOW is rendered by SwiftUI NEXT frame.
// Without lead, rendered input position is 1 frame behind keyboard overlap.
// 0.065 1 frame at 60Hz (16.6ms / 250ms). Continuous, not a jump.
// HIDE doesn't need lead natural 1-frame delay creates acceptable gap.
if animTargetPadding > animStartPadding {
eased = min(eased + 0.065, 1.0)
}
guard eased.isFinite else {
displayLinkProxy?.isPaused = true
lastTickTime = 0
isAnimatingKeyboard = false
return
}
let raw = animStartPadding + (animTargetPadding - animStartPadding) * eased
let rounded = max(0, round(raw))
if timeComplete || animTickCount > 30 {
let prevPadding = keyboardPadding
keyboardPadding = max(0, animTargetPadding)
// Pause instead of invalidate preserves vsync phase for next animation.
displayLinkProxy?.isPaused = true
lastTickTime = 0
isAnimatingKeyboard = false
#if DEBUG
let elapsedMs = elapsed * 1000
print("⌨️ ✅ DONE | ticks=\(animTickCount) | final=\(Int(animTargetPadding)) | lastDelta=\(Int(animTargetPadding - prevPadding))pt | elapsed=\(String(format: "%.0f", elapsedMs))ms")
#endif
} else if rounded != keyboardPadding {
#if DEBUG
let prevPad = keyboardPadding
#endif
keyboardPadding = rounded
#if DEBUG
if animTickCount <= 5 {
let delta = Int(rounded - prevPad)
print("⌨️ TICK #\(animTickCount) | eased=\(String(format: "%.3f", eased)) | pad=\(Int(rounded)) | delta=\(delta)pt | elapsed=\(String(format: "%.1f", elapsed * 1000))ms")
}
#endif
}
}
// MARK: - Cubic bezier fallback
/// Maps `UIViewAnimationCurve` rawValue to cubic bezier control points.
private func configureBezier(curveRaw: Int) {
switch UIView.AnimationCurve(rawValue: curveRaw) {
case .easeIn:
bezierP1x = 0.42; bezierP1y = 0
bezierP2x = 1.0; bezierP2y = 1.0
case .easeOut:
bezierP1x = 0; bezierP1y = 0
bezierP2x = 0.58; bezierP2y = 1.0
case .linear:
bezierP1x = 0; bezierP1y = 0
bezierP2x = 1.0; bezierP2y = 1.0
default:
// CSS "ease" approximation of iOS keyboard curve 7.
// Only used as fallback when sync view is unavailable.
bezierP1x = 0.25; bezierP1y = 0.1
bezierP2x = 0.25; bezierP2y = 1.0
}
}
/// Evaluates the configured cubic bezier at linear time `x` (01).
/// Uses NewtonRaphson for fast convergence (~4 iterations).
private func cubicBezierEase(_ x: CGFloat) -> CGFloat {
guard x > 0 else { return 0 }
guard x < 1 else { return 1 }
var t = x
for _ in 0..<8 {
let bx = bezierValue(t, p1: bezierP1x, p2: bezierP2x)
let dx = bezierDerivative(t, p1: bezierP1x, p2: bezierP2x)
guard abs(dx) > 1e-6 else { break }
t -= (bx - x) / dx
t = min(max(t, 0), 1)
}
return bezierValue(t, p1: bezierP1y, p2: bezierP2y)
}
/// Single axis of cubic bezier: B(t) = 3(1t)²t·p1 + 3(1t)t²·p2 + t³
private func bezierValue(_ t: CGFloat, p1: CGFloat, p2: CGFloat) -> CGFloat {
let mt = 1 - t
return 3 * mt * mt * t * p1 + 3 * mt * t * t * p2 + t * t * t
}
/// Derivative of single bezier axis: B'(t)
private func bezierDerivative(_ t: CGFloat, p1: CGFloat, p2: CGFloat) -> CGFloat {
let mt = 1 - t
return 3 * mt * mt * p1 + 6 * mt * t * (p2 - p1) + 3 * t * t * (1 - p2)
}
}
// MARK: - CADisplayLink wrapper (avoids @objc requirement on @MainActor class)
private class DisplayLinkProxy {
private var callback: (() -> Void)?
private var displayLink: CADisplayLink?
/// - Parameter maxFPS: Max frame rate. 0 = device native (120Hz on ProMotion).
/// Non-zero values cap via preferredFrameRateRange.
init(maxFPS: Int = 0, callback: @escaping () -> Void) {
self.callback = callback
self.displayLink = CADisplayLink(target: self, selector: #selector(tick))
if maxFPS > 0 {
let fps = Float(maxFPS)
self.displayLink?.preferredFrameRateRange = CAFrameRateRange(
minimum: fps / 2, maximum: fps, preferred: fps
)
}
// maxFPS == 0: no range set runs at device native refresh rate (120Hz ProMotion)
self.displayLink?.add(to: .main, forMode: .common)
}
@objc private func tick() {
callback?()
}
var isPaused: Bool {
get { displayLink?.isPaused ?? true }
set { displayLink?.isPaused = newValue }
}
func stop() {
displayLink?.invalidate()
displayLink = nil
callback = nil
}
deinit {
stop()
}
}