keyboard: fix interactive dismiss race conditions, rewrite example

Fix several race conditions in the iOS interactive dismiss flow:
- Don't skip keyboard close notifications during pan tracking, which
  left Dart unaware the keyboard was dismissed
- Guard resignFirstResponder with generation check so reopened keyboards
  aren't killed by stale dismiss completions
- Block pan tracking from starting during an in-flight dismiss animation
- Always reset keyboard view bounds when the keyboard opens, not just
  when the dismiss animator is still running
- Handle duration=0 keyboard notifications by snapping immediately
- Gate adaptive learning debug output behind kDebugMode

Rewrite the example as a chat UI demonstrating ListenableBuilder,
scroll freeze during interactive dismiss, and proper bottom inset
handling with max(keyboardHeight, safeBottom).

Modernize example Android project from v1 to v2 embedding with
current AGP/Gradle versions.
This commit is contained in:
agra
2026-04-16 18:34:05 +03:00
parent 0be198e388
commit 8ac7b5a5d5
22 changed files with 591 additions and 327 deletions

View File

@@ -104,6 +104,7 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
// 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
@@ -147,13 +148,22 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
}
@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)
// 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
@@ -161,6 +171,16 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
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
@@ -291,6 +311,8 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
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 }
@@ -352,6 +374,10 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
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(
@@ -360,9 +386,14 @@ public class KeyboardPlugin: NSObject, FlutterPlugin {
)
}
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()
}