475 lines
19 KiB
Swift
475 lines
19 KiB
Swift
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` (0…1).
|
||
/// Uses Newton–Raphson 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(1−t)²t·p1 + 3(1−t)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()
|
||
}
|
||
}
|