From c6be4f92babeef0d6665ef3d345840ab927e4692 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Wed, 18 Mar 2026 10:04:04 +0100 Subject: [PATCH] improve keyboard height calculation --- .../feature/KeyboardVisibilityFeature.kt | 200 +++++++++++------- 1 file changed, 121 insertions(+), 79 deletions(-) diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/KeyboardVisibilityFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/KeyboardVisibilityFeature.kt index 828d3932..b4b15c6f 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/KeyboardVisibilityFeature.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/KeyboardVisibilityFeature.kt @@ -9,11 +9,12 @@ package eu.weblibre.flutter_mozilla_components.feature import android.os.Build import android.view.View import android.view.ViewTreeObserver -import android.view.WindowInsets -import androidx.core.graphics.Insets +import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsAnimationCompat import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type.ime +import androidx.core.view.WindowInsetsCompat.Type.navigationBars import eu.weblibre.flutter_mozilla_components.ext.EventSequence import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents import mozilla.components.support.base.log.logger.Logger @@ -22,12 +23,18 @@ import org.mozilla.gecko.util.ThreadUtils.runOnUiThread /** * Feature that detects keyboard visibility changes and reports them to Flutter. * - * Uses WindowInsets API for accurate keyboard detection on Android 11+ (API 30+), + * Uses WindowInsets API with animation-bounds-based height calculation on Android 11+ (API 30+), * with fallback to ViewTreeObserver.OnGlobalLayoutListener for older versions. + * + * The animation-bounds approach works around known + * Android bugs where live insets during animation don't reflect the full keyboard height + * (including suggestion bar/toolbar). */ class KeyboardVisibilityFeature( private val flutterEvents: GeckoViewportEvents -) { +) : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE), + OnApplyWindowInsetsListener { + companion object { private const val TAG = "KeyboardVisibilityFeature" } @@ -37,14 +44,16 @@ class KeyboardVisibilityFeature( private var rootView: View? = null private var lastKeyboardHeight: Int = 0 private var lastKeyboardVisible: Boolean = false - private var isAnimating: Boolean = false + + // State tracking for animation-bounds-based keyboard height + private var areKeyboardInsetsDeferred = false + private var isKeyboardShowingUp = false + private var keyboardAnimationInProgress = false + private var keyboardHeight = 0 // full target height from animation bounds // For legacy keyboard detection (pre-API 30) private var globalLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null - // For modern keyboard animation tracking (API 30+) - private var insetsAnimationCallback: WindowInsetsAnimationCompat.Callback? = null - /** * Start observing keyboard visibility changes on the given root view. */ @@ -52,14 +61,12 @@ class KeyboardVisibilityFeature( rootView = view if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - setupModernKeyboardDetection(view) + ViewCompat.setWindowInsetsAnimationCallback(view, this) + ViewCompat.setOnApplyWindowInsetsListener(view, this) } else { setupLegacyKeyboardDetection(view) } - // Also set up WindowInsets listener for immediate state - setupInsetsListener(view) - logger.debug("$TAG: Started keyboard visibility detection") } @@ -71,69 +78,117 @@ class KeyboardVisibilityFeature( globalLayoutListener?.let { view.viewTreeObserver.removeOnGlobalLayoutListener(it) } - insetsAnimationCallback?.let { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { ViewCompat.setWindowInsetsAnimationCallback(view, null) + ViewCompat.setOnApplyWindowInsetsListener(view, null) } } globalLayoutListener = null - insetsAnimationCallback = null rootView = null logger.debug("$TAG: Stopped keyboard visibility detection") } - /** - * Modern keyboard detection using WindowInsetsAnimation (API 30+). - * This provides smooth animation callbacks during keyboard show/hide. - */ - private fun setupModernKeyboardDetection(view: View) { - insetsAnimationCallback = object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { - override fun onPrepare(animation: WindowInsetsAnimationCompat) { - super.onPrepare(animation) - if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) { - isAnimating = true - } + // --- WindowInsetsAnimationCompat.Callback implementation (API 30+) --- + + override fun onPrepare(animation: WindowInsetsAnimationCompat) { + if (animation.typeMask and ime() != 0) { + areKeyboardInsetsDeferred = true + } + } + + override fun onStart( + animation: WindowInsetsAnimationCompat, + bounds: WindowInsetsAnimationCompat.BoundsCompat, + ): WindowInsetsAnimationCompat.BoundsCompat { + if (animation.typeMask and ime() != 0) { + keyboardAnimationInProgress = true + + // Workaround for https://issuetracker.google.com/issues/361027506 + // Compute the keyboard height based on the animation bounds, not live insets. + // This correctly includes suggestion bar / toolbar height. + keyboardHeight = bounds.upperBound.bottom - bounds.lowerBound.bottom + + // Workaround for https://issuetracker.google.com/issues/369223558 + // We expect the keyboard to have a bigger height than the OS navigation bar. + if (keyboardHeight <= getNavbarHeight()) { + keyboardHeight = 0 } - override fun onProgress( - insets: WindowInsetsCompat, - runningAnimations: MutableList - ): WindowInsetsCompat { - // Find IME animation - val imeAnimation = runningAnimations.find { - it.typeMask and WindowInsetsCompat.Type.ime() != 0 - } + notifyKeyboardChange( + calculateKeyboardHeight(keyboardHeight, getNavbarHeight()), + isKeyboardShowingUp, + isAnimating = true, + ) + } - if (imeAnimation != null) { - val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) - val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + return super.onStart(animation, bounds) + } - // Keyboard height is IME height minus navigation bar (which is included) - val keyboardHeight = (imeInsets.bottom - navInsets.bottom).coerceAtLeast(0) + override fun onProgress( + insets: WindowInsetsCompat, + runningAnimations: MutableList, + ): WindowInsetsCompat { + if (!keyboardAnimationInProgress) return insets - // During animation, report height changes - if (keyboardHeight != lastKeyboardHeight) { - notifyKeyboardChange(keyboardHeight, keyboardHeight > 0, isAnimating = true) - } - } - - return insets + runningAnimations.firstOrNull { it.typeMask and ime() != 0 }?.let { imeAnimation -> + // Ensure the fraction grows when keyboard is showing and shrinks when hiding + val fraction = when (isKeyboardShowingUp) { + true -> imeAnimation.interpolatedFraction + false -> 1 - imeAnimation.interpolatedFraction } - override fun onEnd(animation: WindowInsetsAnimationCompat) { - super.onEnd(animation) - if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) { - isAnimating = false - // Send final state - notifyKeyboardChange(lastKeyboardHeight, lastKeyboardVisible, isAnimating = false) - } + val currentHeight = calculateKeyboardHeight( + (keyboardHeight * fraction).toInt(), + getNavbarHeight(), + ) + + if (currentHeight != lastKeyboardHeight) { + notifyKeyboardChange(currentHeight, currentHeight > 0, isAnimating = true) } } - ViewCompat.setWindowInsetsAnimationCallback(view, insetsAnimationCallback) + return insets } + override fun onEnd(animation: WindowInsetsAnimationCompat) { + keyboardAnimationInProgress = false + + if (areKeyboardInsetsDeferred && (animation.typeMask and ime()) != 0) { + areKeyboardInsetsDeferred = false + + // Re-dispatch insets now that animation is complete. + // This triggers onApplyWindowInsets with the final state. + rootView?.let { view -> + ViewCompat.getRootWindowInsets(view)?.let { insets -> + ViewCompat.dispatchApplyWindowInsets(view, insets) + } + } + } + } + + // --- OnApplyWindowInsetsListener implementation (API 30+) --- + + override fun onApplyWindowInsets( + view: View, + insets: WindowInsetsCompat, + ): WindowInsetsCompat { + isKeyboardShowingUp = insets.isVisible(ime()) + + if (!areKeyboardInsetsDeferred) { + val height = calculateKeyboardHeight( + insets.getInsets(ime()).bottom, + getNavbarHeight(), + ) + notifyKeyboardChange(height, isKeyboardShowingUp, isAnimating = false) + } + + return insets + } + + // --- Legacy keyboard detection (pre-API 30) --- + /** * Legacy keyboard detection using ViewTreeObserver (pre-API 30). */ @@ -141,8 +196,8 @@ class KeyboardVisibilityFeature( globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { val insets = ViewCompat.getRootWindowInsets(view) ?: return@OnGlobalLayoutListener - val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) - val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val imeInsets = insets.getInsets(ime()) + val navInsets = insets.getInsets(navigationBars()) val keyboardHeight = (imeInsets.bottom - navInsets.bottom).coerceAtLeast(0) val isVisible = keyboardHeight > 0 @@ -155,29 +210,16 @@ class KeyboardVisibilityFeature( view.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener) } - /** - * Set up WindowInsets listener for immediate keyboard state. - */ - private fun setupInsetsListener(view: View) { - ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets -> - val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) - val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) - val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + // --- Helper methods --- - val keyboardHeight = if (imeVisible) { - (imeInsets.bottom - navInsets.bottom).coerceAtLeast(0) - } else { - 0 - } + private fun getNavbarHeight(): Int = + rootView?.let { + ViewCompat.getRootWindowInsets(it) + ?.getInsets(navigationBars())?.bottom + } ?: 0 - // Only notify if not currently animating (animation callbacks handle that) - if (!isAnimating && (keyboardHeight != lastKeyboardHeight || imeVisible != lastKeyboardVisible)) { - notifyKeyboardChange(keyboardHeight, imeVisible, isAnimating = false) - } - - insets - } - } + private fun calculateKeyboardHeight(rawHeight: Int, navbarHeight: Int): Int = + (rawHeight - navbarHeight).coerceAtLeast(0) /** * Notify Flutter about keyboard visibility change. @@ -208,9 +250,9 @@ class KeyboardVisibilityFeature( rootView?.let { view -> val insets = ViewCompat.getRootWindowInsets(view) ?: return - val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) - val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) - val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) + val imeVisible = insets.isVisible(ime()) + val imeInsets = insets.getInsets(ime()) + val navInsets = insets.getInsets(navigationBars()) val keyboardHeight = if (imeVisible) { (imeInsets.bottom - navInsets.bottom).coerceAtLeast(0)