improve keyboard height calculation

This commit is contained in:
Fabian Freund
2026-03-18 10:04:04 +01:00
parent 54b3821ed0
commit c6be4f92ba
@@ -9,11 +9,12 @@ package eu.weblibre.flutter_mozilla_components.feature
import android.os.Build import android.os.Build
import android.view.View import android.view.View
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.view.WindowInsets import androidx.core.view.OnApplyWindowInsetsListener
import androidx.core.graphics.Insets
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsAnimationCompat import androidx.core.view.WindowInsetsAnimationCompat
import androidx.core.view.WindowInsetsCompat 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.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents import eu.weblibre.flutter_mozilla_components.pigeons.GeckoViewportEvents
import mozilla.components.support.base.log.logger.Logger 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. * 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. * 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( class KeyboardVisibilityFeature(
private val flutterEvents: GeckoViewportEvents private val flutterEvents: GeckoViewportEvents
) { ) : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE),
OnApplyWindowInsetsListener {
companion object { companion object {
private const val TAG = "KeyboardVisibilityFeature" private const val TAG = "KeyboardVisibilityFeature"
} }
@@ -37,14 +44,16 @@ class KeyboardVisibilityFeature(
private var rootView: View? = null private var rootView: View? = null
private var lastKeyboardHeight: Int = 0 private var lastKeyboardHeight: Int = 0
private var lastKeyboardVisible: Boolean = false 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) // For legacy keyboard detection (pre-API 30)
private var globalLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null 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. * Start observing keyboard visibility changes on the given root view.
*/ */
@@ -52,14 +61,12 @@ class KeyboardVisibilityFeature(
rootView = view rootView = view
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
setupModernKeyboardDetection(view) ViewCompat.setWindowInsetsAnimationCallback(view, this)
ViewCompat.setOnApplyWindowInsetsListener(view, this)
} else { } else {
setupLegacyKeyboardDetection(view) setupLegacyKeyboardDetection(view)
} }
// Also set up WindowInsets listener for immediate state
setupInsetsListener(view)
logger.debug("$TAG: Started keyboard visibility detection") logger.debug("$TAG: Started keyboard visibility detection")
} }
@@ -71,69 +78,117 @@ class KeyboardVisibilityFeature(
globalLayoutListener?.let { globalLayoutListener?.let {
view.viewTreeObserver.removeOnGlobalLayoutListener(it) view.viewTreeObserver.removeOnGlobalLayoutListener(it)
} }
insetsAnimationCallback?.let { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
ViewCompat.setWindowInsetsAnimationCallback(view, null) ViewCompat.setWindowInsetsAnimationCallback(view, null)
ViewCompat.setOnApplyWindowInsetsListener(view, null)
} }
} }
globalLayoutListener = null globalLayoutListener = null
insetsAnimationCallback = null
rootView = null rootView = null
logger.debug("$TAG: Stopped keyboard visibility detection") logger.debug("$TAG: Stopped keyboard visibility detection")
} }
/** // --- WindowInsetsAnimationCompat.Callback implementation (API 30+) ---
* Modern keyboard detection using WindowInsetsAnimation (API 30+).
* This provides smooth animation callbacks during keyboard show/hide. override fun onPrepare(animation: WindowInsetsAnimationCompat) {
*/ if (animation.typeMask and ime() != 0) {
private fun setupModernKeyboardDetection(view: View) { areKeyboardInsetsDeferred = true
insetsAnimationCallback = object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { }
override fun onPrepare(animation: WindowInsetsAnimationCompat) { }
super.onPrepare(animation)
if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) { override fun onStart(
isAnimating = true 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( notifyKeyboardChange(
insets: WindowInsetsCompat, calculateKeyboardHeight(keyboardHeight, getNavbarHeight()),
runningAnimations: MutableList<WindowInsetsAnimationCompat> isKeyboardShowingUp,
): WindowInsetsCompat { isAnimating = true,
// Find IME animation )
val imeAnimation = runningAnimations.find { }
it.typeMask and WindowInsetsCompat.Type.ime() != 0
}
if (imeAnimation != null) { return super.onStart(animation, bounds)
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) }
val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
// Keyboard height is IME height minus navigation bar (which is included) override fun onProgress(
val keyboardHeight = (imeInsets.bottom - navInsets.bottom).coerceAtLeast(0) insets: WindowInsetsCompat,
runningAnimations: MutableList<WindowInsetsAnimationCompat>,
): WindowInsetsCompat {
if (!keyboardAnimationInProgress) return insets
// During animation, report height changes runningAnimations.firstOrNull { it.typeMask and ime() != 0 }?.let { imeAnimation ->
if (keyboardHeight != lastKeyboardHeight) { // Ensure the fraction grows when keyboard is showing and shrinks when hiding
notifyKeyboardChange(keyboardHeight, keyboardHeight > 0, isAnimating = true) val fraction = when (isKeyboardShowingUp) {
} true -> imeAnimation.interpolatedFraction
} false -> 1 - imeAnimation.interpolatedFraction
return insets
} }
override fun onEnd(animation: WindowInsetsAnimationCompat) { val currentHeight = calculateKeyboardHeight(
super.onEnd(animation) (keyboardHeight * fraction).toInt(),
if (animation.typeMask and WindowInsetsCompat.Type.ime() != 0) { getNavbarHeight(),
isAnimating = false )
// Send final state
notifyKeyboardChange(lastKeyboardHeight, lastKeyboardVisible, isAnimating = false) 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). * Legacy keyboard detection using ViewTreeObserver (pre-API 30).
*/ */
@@ -141,8 +196,8 @@ class KeyboardVisibilityFeature(
globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { globalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener {
val insets = ViewCompat.getRootWindowInsets(view) ?: return@OnGlobalLayoutListener val insets = ViewCompat.getRootWindowInsets(view) ?: return@OnGlobalLayoutListener
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) val imeInsets = insets.getInsets(ime())
val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) val navInsets = insets.getInsets(navigationBars())
val keyboardHeight = (imeInsets.bottom - navInsets.bottom).coerceAtLeast(0) val keyboardHeight = (imeInsets.bottom - navInsets.bottom).coerceAtLeast(0)
val isVisible = keyboardHeight > 0 val isVisible = keyboardHeight > 0
@@ -155,29 +210,16 @@ class KeyboardVisibilityFeature(
view.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener) view.viewTreeObserver.addOnGlobalLayoutListener(globalLayoutListener)
} }
/** // --- Helper methods ---
* 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())
val keyboardHeight = if (imeVisible) { private fun getNavbarHeight(): Int =
(imeInsets.bottom - navInsets.bottom).coerceAtLeast(0) rootView?.let {
} else { ViewCompat.getRootWindowInsets(it)
0 ?.getInsets(navigationBars())?.bottom
} } ?: 0
// Only notify if not currently animating (animation callbacks handle that) private fun calculateKeyboardHeight(rawHeight: Int, navbarHeight: Int): Int =
if (!isAnimating && (keyboardHeight != lastKeyboardHeight || imeVisible != lastKeyboardVisible)) { (rawHeight - navbarHeight).coerceAtLeast(0)
notifyKeyboardChange(keyboardHeight, imeVisible, isAnimating = false)
}
insets
}
}
/** /**
* Notify Flutter about keyboard visibility change. * Notify Flutter about keyboard visibility change.
@@ -208,9 +250,9 @@ class KeyboardVisibilityFeature(
rootView?.let { view -> rootView?.let { view ->
val insets = ViewCompat.getRootWindowInsets(view) ?: return val insets = ViewCompat.getRootWindowInsets(view) ?: return
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) val imeVisible = insets.isVisible(ime())
val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime()) val imeInsets = insets.getInsets(ime())
val navInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars()) val navInsets = insets.getInsets(navigationBars())
val keyboardHeight = if (imeVisible) { val keyboardHeight = if (imeVisible) {
(imeInsets.bottom - navInsets.bottom).coerceAtLeast(0) (imeInsets.bottom - navInsets.bottom).coerceAtLeast(0)