From a1ab66717824140474f26063ffe38ae7c768fba9 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 15 Apr 2026 22:46:43 +0300 Subject: [PATCH] keyboard: sampled native curve, interactive dismiss, scroll freeze - Dart-side animation replay using exact native curve (21-point lookup table sampled from CADisplayLink) with 16ms start offset and 10ms shorter duration to stay ahead of the native animation pipeline. - Snap-back reads bounds presentation layer so height doesn't jump. - Dismiss defers resignFirstResponder until bounds animation completes, with immediate Dart close animation via animTarget/animGeneration. - enableInteractiveDismiss accepts trackingInset to widen the gesture zone above the keyboard (for composer height). - FFI: anim_target, anim_duration, anim_gen, system_keyboard_height, is_tracking exposed for Dart animation and scroll physics. --- ios/Classes/KeyboardPlugin.swift | 406 +++++++++++++++++++++++++++++++ lib/src/keyboard.dart | 165 +++++++++++++ 2 files changed, 571 insertions(+) create mode 100644 ios/Classes/KeyboardPlugin.swift create mode 100644 lib/src/keyboard.dart diff --git a/ios/Classes/KeyboardPlugin.swift b/ios/Classes/KeyboardPlugin.swift new file mode 100644 index 0000000..8213321 --- /dev/null +++ b/ios/Classes/KeyboardPlugin.swift @@ -0,0 +1,406 @@ +import Flutter +import UIKit + +// MARK: - FFI interface + +public typealias WakeCallback = @convention(c) () -> Void + +/// Returns the current keyboard height by reading the presentation layer directly. +/// Called by Dart's persistent frame callback — zero latency. +@_cdecl("ux_keyboard_height") +public func ux_keyboard_height() -> Double { + guard let plugin = KeyboardPlugin.shared else { return 0 } + + // During interactive pan, we track the offset ourselves + if plugin.isTracking { + return max(0, Double(plugin.keyboardFullHeight - plugin.interactiveOffset)) + } + + // During dismiss or snap-back animation, read bounds offset from presentation layer + // (frame is unaffected by bounds.origin, so the normal path would report full height) + if plugin.isDismissing || plugin.snapBackAnimator != nil { + guard let kbView = plugin.keyboardView else { return 0 } + let boundsY = kbView.layer.presentation()?.bounds.origin.y ?? 0 + return max(0, Double(plugin.keyboardFullHeight + Double(boundsY))) + } + + // Otherwise read the actual keyboard view position + guard let kbView = plugin.keyboardView else { + return Double(plugin.keyboardFullHeight) + } + + let screenHeight = UIScreen.main.bounds.height + // presentation() gives the interpolated value during CoreAnimation + if let presentation = kbView.layer.presentation() { + return max(0, Double(screenHeight - presentation.frame.origin.y)) + } + return max(0, Double(screenHeight - kbView.frame.origin.y)) +} + +/// Returns true when interactive dismiss pan is active. +/// Flutter should stop scrolling the message list. +@_cdecl("ux_is_tracking") +public func ux_is_tracking() -> Int32 { + return (KeyboardPlugin.shared?.isTracking ?? false) ? 1 : 0 +} + +/// Returns the system-reported keyboard height (from the last notification). +/// Use this as source of truth for "is keyboard open" state. +@_cdecl("ux_system_keyboard_height") +public func ux_system_keyboard_height() -> Double { + return Double(KeyboardPlugin.shared?.keyboardFullHeight ?? 0) +} + +@_cdecl("ux_register_wake_callback") +public func ux_register_wake_callback(_ cb: @escaping WakeCallback) { + KeyboardPlugin.shared?.wakeCallback = cb +} + +@_cdecl("ux_enable_interactive_dismiss") +public func ux_enable_interactive_dismiss(_ trackingInset: Double) { + KeyboardPlugin.shared?.enableInteractiveDismiss(trackingInset: CGFloat(trackingInset)) +} + +@_cdecl("ux_disable_interactive_dismiss") +public func ux_disable_interactive_dismiss() { + KeyboardPlugin.shared?.disableInteractiveDismiss() +} + +/// Animation params — Dart replays the same animation internally. +@_cdecl("ux_keyboard_anim_target") +public func ux_keyboard_anim_target() -> Double { + return Double(KeyboardPlugin.shared?.animTarget ?? 0) +} + +@_cdecl("ux_keyboard_anim_duration") +public func ux_keyboard_anim_duration() -> Double { + return KeyboardPlugin.shared?.animDuration ?? 0 +} + +/// Incremented each time a new keyboard animation starts. +/// Dart compares against its own copy to detect new animations. +@_cdecl("ux_keyboard_anim_gen") +public func ux_keyboard_anim_gen() -> Int32 { + return KeyboardPlugin.shared?.animGeneration ?? 0 +} + +// MARK: - Plugin + +public class KeyboardPlugin: NSObject, FlutterPlugin { + fileprivate static var shared: KeyboardPlugin? + + fileprivate var wakeCallback: WakeCallback? + private var isObserving = false + private var gestureEnabled = false + private var panRecognizer: UIPanGestureRecognizer? + + // Keyboard state + fileprivate var keyboardView: UIView? + fileprivate var keyboardFullHeight: CGFloat = 0 + fileprivate var isTracking = false + fileprivate var isDismissing = false + fileprivate var interactiveOffset: CGFloat = 0 + + // Pan gesture + private var keyboardOriginY: CGFloat = 0 + fileprivate var snapBackAnimator: UIViewPropertyAnimator? + private var trackingInset: CGFloat = 0 + + // Animation params — passed to Dart so it can replay the same animation + fileprivate var animTarget: CGFloat = 0 + fileprivate var animDuration: Double = 0 + fileprivate var animGeneration: Int32 = 0 + + public static func register(with registrar: FlutterPluginRegistrar) { + let instance = KeyboardPlugin() + KeyboardPlugin.shared = instance + + instance.startObserving() + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + result(FlutterMethodNotImplemented) + } + + /// Wake Dart so it reads the height on its next frame + private func wake() { + wakeCallback?() + } + + // MARK: - Keyboard Notifications + + private func startObserving() { + guard !isObserving else { return } + isObserving = true + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillChange), + name: UIResponder.keyboardWillChangeFrameNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(keyboardWillShow), + name: UIResponder.keyboardWillShowNotification, + object: nil + ) + } + + @objc private func keyboardWillChange(_ notification: Notification) { + guard !isTracking else { return } + + guard let userInfo = notification.userInfo else { return } + let endFrame = (userInfo[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue ?? .zero + let screenHeight = UIScreen.main.bounds.height + let height = max(0, screenHeight - endFrame.origin.y) + + // Pass animation params to Dart so it can replay the same animation + let duration = (userInfo[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25 + animTarget = height + animDuration = duration + animGeneration &+= 1 + + if height > 0 { + keyboardFullHeight = height + } else { + keyboardFullHeight = 0 + isDismissing = false + // Reset bounds when system confirms keyboard is gone + keyboardView?.layer.bounds.origin = .zero + } + + if keyboardView == nil { + discoverKeyboardView() + } + + // Wake Dart so it starts the animation + wake() + } + + @objc private func keyboardWillShow(_ notification: Notification) { + discoverKeyboardView() + } + + // MARK: - Keyboard View Discovery + + private func discoverKeyboardView() { + keyboardView = nil + + // Private API — same as Telegram (UIKitRuntimeUtils/UIViewController+Navigation.m) + if let windowClass = NSClassFromString("UIRemoteKeyboardWindow") as? NSObject.Type { + let sel = NSSelectorFromString("remoteKeyboardWindowForScreen:create:") + if windowClass.responds(to: sel) { + if let window = windowClass.perform(sel, with: UIScreen.main, with: false)?.takeUnretainedValue() as? UIWindow { + if let found = findKeyboardHostView(in: window) { + keyboardView = found + return + } + } + } + } + + // Fallback: walk visible windows + for scene in UIApplication.shared.connectedScenes { + guard let windowScene = scene as? UIWindowScene else { continue } + for window in windowScene.windows { + let name = NSStringFromClass(type(of: window)) + let isKB = (name.hasPrefix("UI") && name.hasSuffix("RemoteKeyboardWindow")) || + (name.hasPrefix("UI") && name.hasSuffix("TextEffectsWindow")) + guard isKB else { continue } + if let found = findKeyboardHostView(in: window) { + keyboardView = found + return + } + } + } + } + + private func findKeyboardHostView(in view: UIView) -> UIView? { + let name = NSStringFromClass(type(of: view)) + if (name.hasPrefix("UI") && name.hasSuffix("InputSetHostView")) || + (name.hasPrefix("UI") && name.hasSuffix("KeyboardItemContainerView")) { + return view + } + for subview in view.subviews { + if let found = findKeyboardHostView(in: subview) { + return found + } + } + return nil + } + + // MARK: - Interactive Dismiss + + fileprivate func enableInteractiveDismiss(trackingInset: CGFloat = 0) { + self.trackingInset = trackingInset + guard panRecognizer == nil else { + gestureEnabled = true + return + } + gestureEnabled = true + + DispatchQueue.main.async { [weak self] in + self?.setupPanGesture() + } + } + + fileprivate func disableInteractiveDismiss() { + gestureEnabled = false + if let recognizer = panRecognizer { + recognizer.view?.removeGestureRecognizer(recognizer) + panRecognizer = nil + } + } + + private func setupPanGesture() { + var targetView: UIView? + + if let window = UIApplication.shared.delegate?.window ?? nil { + targetView = window.rootViewController?.view ?? window + } else { + for scene in UIApplication.shared.connectedScenes { + if let ws = scene as? UIWindowScene { + for window in ws.windows where window.isKeyWindow { + targetView = window.rootViewController?.view ?? window + break + } + } + } + } + + guard let view = targetView else { return } + + let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan)) + pan.cancelsTouchesInView = false + pan.delaysTouchesBegan = false + pan.delaysTouchesEnded = false + pan.delegate = self + view.addGestureRecognizer(pan) + panRecognizer = pan + } + + @objc private func handlePan(_ gesture: UIPanGestureRecognizer) { + guard gestureEnabled, let view = gesture.view else { return } + + let location = gesture.location(in: view) + let screenHeight = UIScreen.main.bounds.height + + switch gesture.state { + case .began: + break + + case .changed: + if !isTracking { + // Use system keyboard height as source of truth + guard keyboardFullHeight > 0, keyboardView != nil else { return } + + let keyboardTop = screenHeight - keyboardFullHeight - trackingInset + guard location.y > keyboardTop else { return } + + snapBackAnimator?.stopAnimation(true) + snapBackAnimator = nil + + isTracking = true + interactiveOffset = 0 + keyboardOriginY = location.y + + // Wake Dart so it knows tracking started (for scroll stop) + wake() + } + + interactiveOffset = max(0, location.y - keyboardOriginY) + + // Move the keyboard view — same as Telegram's KeyboardManager + if let kbView = keyboardView { + kbView.layer.bounds = CGRect( + origin: CGPoint(x: 0, y: -interactiveOffset), + size: kbView.layer.bounds.size + ) + } + + // Wake Dart to read the new height + wake() + + case .ended, .cancelled: + guard isTracking else { return } + isTracking = false + + let velocity = gesture.velocity(in: view).y + let dismissThreshold = keyboardFullHeight * 0.4 + + if velocity > 100 || interactiveOffset > dismissThreshold { + dismissKeyboard() + } else { + snapBack() + } + interactiveOffset = 0 + + // Wake Dart so it knows tracking ended + wake() + + default: + break + } + } + + private func dismissKeyboard() { + isDismissing = true + let fullHeight = keyboardFullHeight + + // Tell Dart to start close animation immediately + animTarget = 0 + animDuration = 0.25 + animGeneration &+= 1 + + let animator = UIViewPropertyAnimator(duration: 0.25, dampingRatio: 0.9) { [weak self] in + guard let kbView = self?.keyboardView else { return } + kbView.layer.bounds = CGRect( + origin: CGPoint(x: 0, y: -fullHeight), + size: kbView.layer.bounds.size + ) + } + animator.addCompletion { [weak self] _ in + self?.isDismissing = false + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } + animator.startAnimation() + } + + private func snapBack() { + guard let kbView = keyboardView else { return } + + let animator = UIViewPropertyAnimator(duration: 0.25, dampingRatio: 0.9) { + kbView.layer.bounds = CGRect( + origin: .zero, + size: kbView.layer.bounds.size + ) + } + snapBackAnimator = animator + animator.addCompletion { [weak self] _ in + self?.snapBackAnimator = nil + } + animator.startAnimation() + } + + // MARK: - Warmup + + private static func warmup() { + let field = UITextField(frame: .zero) + let window = UIWindow(frame: .zero) + window.addSubview(field) + field.becomeFirstResponder() + field.resignFirstResponder() + window.isHidden = true + } +} + +// MARK: - UIGestureRecognizerDelegate + +extension KeyboardPlugin: UIGestureRecognizerDelegate { + public func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + return true + } +} diff --git a/lib/src/keyboard.dart b/lib/src/keyboard.dart new file mode 100644 index 0000000..73742f2 --- /dev/null +++ b/lib/src/keyboard.dart @@ -0,0 +1,165 @@ +import 'dart:ffi'; +import 'dart:io'; + +import 'package:flutter/animation.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/scheduler.dart'; + +final bool _isIOS = Platform.isIOS; + +DynamicLibrary? _initLib() { + if (!_isIOS) return null; + return DynamicLibrary.process(); +} + +final DynamicLibrary? _lib = _initLib(); + +double Function()? _lookupDouble(String name) { + if (_lib == null) return null; + try { + return _lib!.lookup>(name).asFunction(); + } catch (e) { + return null; + } +} + +int Function()? _lookupInt32(String name) { + if (_lib == null) return null; + try { + return _lib!.lookup>(name).asFunction(); + } catch (e) { + return null; + } +} + +void Function()? _lookupVoid(String name) { + if (_lib == null) return null; + try { + return _lib!.lookup>(name).asFunction(); + } catch (e) { + return null; + } +} + +final _uxKeyboardHeight = _lookupDouble('ux_keyboard_height'); +final _uxSystemHeight = _lookupDouble('ux_system_keyboard_height'); +final _uxIsTracking = _lookupInt32('ux_is_tracking'); +final _uxAnimTarget = _lookupDouble('ux_keyboard_anim_target'); +final _uxAnimDuration = _lookupDouble('ux_keyboard_anim_duration'); +final _uxAnimGen = _lookupInt32('ux_keyboard_anim_gen'); + +void Function(double)? _lookupEnableInteractiveDismiss() { + if (_lib == null) return null; + try { + return _lib! + .lookup>('ux_enable_interactive_dismiss') + .asFunction(); + } catch (e) { + return null; + } +} + +final _uxEnableInteractiveDismiss = _lookupEnableInteractiveDismiss(); +final _uxDisableInteractiveDismiss = _lookupVoid('ux_disable_interactive_dismiss'); + +/// iOS keyboard animation curve — sampled from native CADisplayLink. +/// 21 points at t = 0.00, 0.05, ..., 1.00. Averaged from multiple open/close cycles. +const _kKeyboardSamples = [ + 0.0000, 0.0618, 0.1991, 0.3618, 0.5123, // t=0.00..0.20 + 0.6375, 0.7362, 0.8112, 0.8664, 0.9062, // t=0.25..0.45 + 0.9347, 0.9550, 0.9692, 0.9790, 0.9858, // t=0.50..0.70 + 0.9904, 0.9935, 0.9956, 0.9971, 0.9980, // t=0.75..0.95 + 0.9993, // t=1.00 +]; + +const Curve _kKeyboardCurve = _SampledCurve(_kKeyboardSamples); + +class _SampledCurve extends Curve { + const _SampledCurve(this._samples); + final List _samples; + + @override + double transformInternal(double t) { + final n = _samples.length - 1; + final scaled = t * n; + final i = scaled.floor().clamp(0, n - 1); + final frac = scaled - i; + return _samples[i] + frac * (_samples[i + 1] - _samples[i]); + } +} + +class UxKeyboard with ChangeNotifier { + UxKeyboard._() { + if (!_isIOS) return; + SchedulerBinding.instance.addPersistentFrameCallback(_onFrame); + } + + static final UxKeyboard instance = UxKeyboard._(); + + double _height = 0; + + double get height => _height; + double get systemHeight => _uxSystemHeight?.call() ?? 0; + bool get isOpen => _height > 0; + bool get isTracking => (_uxIsTracking?.call() ?? 0) > 0; + + // Animation state — replays the keyboard's own animation inside Flutter. + int _lastAnimGen = 0; + double _animFrom = 0; + double _animTo = 0; + double _animDuration = 0; + double _animStartTime = 0; // seconds, from frame timestamp + bool _isAnimating = false; + + void _onFrame(Duration timestamp) { + if (_uxKeyboardHeight == null) return; + + final ts = timestamp.inMicroseconds / Duration.microsecondsPerSecond; + + // Detect new keyboard animation from native + final gen = _uxAnimGen?.call() ?? 0; + if (gen != _lastAnimGen) { + _lastAnimGen = gen; + final target = _uxAnimTarget?.call() ?? 0; + final duration = _uxAnimDuration?.call() ?? 0; + if (duration > 0) { + _animFrom = _height; + _animTo = target; + _animDuration = duration - 0.01; // finish 10ms ahead of native + _animStartTime = ts - 0.016; // compensate 2-frame pipeline delay + _isAnimating = true; + } + } + + // Abort animation if interactive tracking started + if (_isAnimating && (_uxIsTracking?.call() ?? 0) > 0) { + _isAnimating = false; + } + + double h; + if (_isAnimating) { + final elapsed = ts - _animStartTime; + final t = (elapsed / _animDuration).clamp(0.0, 1.0); + h = _animFrom + (_animTo - _animFrom) * _kKeyboardCurve.transform(t); + if (t >= 1.0) { + _isAnimating = false; + h = _animTo; + } + } else { + // Fallback: read FFI directly (interactive dismiss, snap-back, etc.) + h = _uxKeyboardHeight!(); + } + + if ((h - _height).abs() > 0.5) { + _height = h; + notifyListeners(); + } + + if (_isAnimating) { + SchedulerBinding.instance.scheduleFrame(); + } + } + + void enableInteractiveDismiss({double trackingInset = 0}) => _uxEnableInteractiveDismiss?.call(trackingInset); + void disableInteractiveDismiss() => _uxDisableInteractiveDismiss?.call(); +}