Catch-all commit for outstanding pre-existing local changes. Mixes several themes that would normally be split: - Rename: UxPlugin → XPlugin across iOS, macOS, Android registrants. - New top-level packages under lib/src/: anim/ (animated values, panes, sheets, dock, measured), core/ (Emitter, ReactiveBuilder scaffolding, presenter/widget/value/dispose primitives), navi/ (Screen/ScreenStack/Router/hero/transitions), reactive/. - Edits across existing plugins (clipboard, crash, file, gallery, keyboard, scanner, sensor, url) to align with the new core. - Test updates and CHANGELOG/README touches accompanying the above.
419 lines
14 KiB
Swift
419 lines
14 KiB
Swift
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, NativePlugin {
|
|
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 dismissAnimator: 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 func register(with registrar: FlutterPluginRegistrar) {
|
|
KeyboardPlugin.shared = self
|
|
KeyboardPlugin.warmup()
|
|
startObserving()
|
|
}
|
|
|
|
/// 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 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)
|
|
|
|
// If the keyboard is closing while we're tracking (e.g. resignFirstResponder
|
|
// fired from a completed dismiss during a new pan), stop tracking and process
|
|
// the close — otherwise Dart never learns the keyboard went away.
|
|
if isTracking {
|
|
if height == 0 {
|
|
isTracking = false
|
|
} else {
|
|
return
|
|
}
|
|
}
|
|
|
|
// 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 {
|
|
// Cancel any in-flight dismiss so it doesn't resignFirstResponder
|
|
// after the user has already re-focused.
|
|
if let anim = dismissAnimator {
|
|
anim.stopAnimation(true)
|
|
dismissAnimator = nil
|
|
}
|
|
// Always reset bounds — the dismiss animation (or its completion)
|
|
// may have shifted the keyboard view offscreen.
|
|
isDismissing = false
|
|
keyboardView?.layer.bounds.origin = .zero
|
|
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() {
|
|
guard let window = XWindow.keyWindow else { return }
|
|
let view: UIView = window.rootViewController?.view ?? window
|
|
|
|
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 {
|
|
// Don't start tracking during a dismiss — the keyboard is going away.
|
|
guard dismissAnimator == nil, !isDismissing else { return }
|
|
// 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
|
|
|
|
// Snapshot the generation so the completion can detect if the keyboard
|
|
// was reopened between now and when the animator finishes.
|
|
let genAtDismiss = animGeneration
|
|
|
|
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?.dismissAnimator = nil
|
|
self?.isDismissing = false
|
|
// Only resign if no new keyboard event arrived since the dismiss started.
|
|
// Otherwise we'd kill a keyboard the user just re-opened.
|
|
guard self?.animGeneration == genAtDismiss else { return }
|
|
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
|
|
}
|
|
dismissAnimator = animator
|
|
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
|
|
}
|
|
}
|