keyboard(android): pre-R IME tracking + flush dirty marks during persistent callback

KeyboardPlugin.setupInsetsCallback used to early-return on SDK < R, so
the FFI height stayed at 0 on API 29 devices like the Huawei Mate 20 Pro
— the chat composer never tracked the IME. Run the global-layout
listener on all SDKs, and on pre-R also wire setOnApplyWindowInsetsListener
since EMUI 10's IME-hide dispatches new insets without a follow-up layout
pass. Pre-R IME height comes from systemWindowInsetBottom −
stableInsetBottom (stable insets exclude things that animate in/out).

Inside XKeyboard._onFrame, follow notifyListeners with an explicit
scheduleFrame. _onFrame runs as a persistent frame callback after the
build phase has finished, so setState in listeners marks elements dirty
but ensureVisualUpdate is a no-op in this phase — the steady-state pump
masked the issue while the keyboard was open but on the close-edge
(h transitions to 0) the pump stops and the final rebuild was never
scheduled.
This commit is contained in:
agra
2026-05-22 20:55:07 +03:00
parent 34d3616d16
commit de0a96b557
2 changed files with 42 additions and 9 deletions

View File

@@ -80,19 +80,26 @@ class KeyboardPlugin : NativePlugin, MethodChannel.MethodCallHandler {
}
private fun setupInsetsCallback() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return
val view = activity?.window?.decorView ?: return
// Catch inset changes that don't trigger animations (e.g., emoji keyboard resize)
// Layout-driven changes (e.g. emoji keyboard resize on R+ where the
// animation callback doesn't fire).
view.viewTreeObserver.addOnGlobalLayoutListener {
val insets = view.rootWindowInsets?.getInsets(WindowInsets.Type.ime()) ?: Insets.NONE
val density = view.resources.displayMetrics.density
val height = insets.bottom.toDouble() / density
val rootInsets = view.rootWindowInsets ?: return@addOnGlobalLayoutListener
publishImeHeight(view, rootInsets)
}
if (height != KeyboardBridge.nGetSystemHeight()) {
KeyboardBridge.nSetSystemHeight(height)
KeyboardBridge.nSetHeight(height)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
// No WindowInsetsAnimation API on pre-R. The global-layout listener
// catches IME-open (activity resizes) but on some OEMs (notably
// EMUI 10) IME-hide dispatches new insets without a follow-up
// layout — leaving that listener silent. setOnApplyWindowInsetsListener
// fires on every inset dispatch, so it catches both ends.
view.setOnApplyWindowInsetsListener { v, insets ->
publishImeHeight(v, insets)
v.onApplyWindowInsets(insets)
}
return
}
view.setWindowInsetsAnimationCallback(object : WindowInsetsAnimation.Callback(DISPATCH_MODE_STOP) {
@@ -140,4 +147,23 @@ class KeyboardPlugin : NativePlugin, MethodChannel.MethodCallHandler {
}
})
}
private fun publishImeHeight(view: android.view.View, insets: WindowInsets) {
val density = view.resources.displayMetrics.density
val imePx: Int = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
insets.getInsets(WindowInsets.Type.ime()).bottom
} else {
// systemWindowInsetBottom = nav bar + IME; stableInsetBottom = nav
// bar only (stable insets exclude things that animate in/out like
// the IME). The difference isolates IME height.
@Suppress("DEPRECATION")
(insets.systemWindowInsetBottom - insets.stableInsetBottom).coerceAtLeast(0)
}
val height = imePx.toDouble() / density
if (height != KeyboardBridge.nGetSystemHeight()) {
KeyboardBridge.nSetSystemHeight(height)
KeyboardBridge.nSetHeight(height)
}
}
}

View File

@@ -229,9 +229,16 @@ class XKeyboard with ChangeNotifier {
if ((h - _height).abs() > 0.5) {
_height = h;
notifyListeners();
// notifyListeners triggers setState in listeners, which marks elements
// dirty. We're inside a persistent frame callback (after the build phase
// has already run), so `ensureVisualUpdate` is a no-op — without an
// explicit scheduleFrame the dirty marks never get flushed. This matters
// especially on the down-edge (h transitions to 0) where the steady-state
// pump below stops scheduling.
SchedulerBinding.instance.scheduleFrame();
}
// Schedule frames while the curve is still running.
// Steady-state pump while the curve is still running or the keyboard is up.
final curveActive = _isAnimating &&
(ts - _animStartTime) < _animDuration;
if (curveActive || (!Platform.isIOS && (h > 0 || _height > 0))) {