improve keyboard height calculation
This commit is contained in:
+121
-79
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user