Bug 1917818 - part 1 - Synchronize showing browser's bottom toolbar on top of the keyboard r=android-reviewers,sfamisa

Previously when the keyboard would be showing / hiding the entire screen will first resize to
the end height after which the keyboard will be animated.
This meant just one reflow for the engine view but a choppy layout animation.

With the new changes we'll ensure the bottom toolbar is animated in sync with the keyboard
and the engine view is resized only once
- before hiding the keyboard to ensure that it will then nicely reveal the web content
- after showing the full keyboard to ensure it gradually covered web content with no UI issue

Differential Revision: https://phabricator.services.mozilla.com/D223585
This commit is contained in:
Mugurell 2024-10-14 08:27:42 +00:00
parent a5dba92c22
commit 5c92bf47ef
3 changed files with 198 additions and 6 deletions

View File

@ -23,9 +23,15 @@ import androidx.core.view.WindowInsetsCompat.Type.systemBars
*
* @param targetView The view which will be shown on top of the keyboard while this is animated to be
* showing or to be hidden.
* @param onIMEAnimationStarted Callback for when the IME animation starts.
* It will inform whether the keyboard is showing or hiding and the height of the keyboard.
* @param onIMEAnimationFinished Callback for when the IME animation finishes.
* It will inform whether the keyboard is showing or hiding and the height of the keyboard.
*/
class ImeInsetsSynchronizer private constructor(
private val targetView: View,
private val onIMEAnimationStarted: (Boolean, Int) -> Unit,
private val onIMEAnimationFinished: (Boolean, Int) -> Unit,
) : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_CONTINUE_ON_SUBTREE),
OnApplyWindowInsetsListener {
@ -50,7 +56,15 @@ class ImeInsetsSynchronizer private constructor(
view.updateBottomMargin(
calculateBottomMargin(
windowInsets.keyboardInsets.bottom,
windowInsets.navigationBarInsetHeight,
getNavbarHeight(),
),
)
onIMEAnimationFinished(
isKeyboardShowingUp,
calculateBottomMargin(
windowInsets.keyboardInsets.bottom,
getNavbarHeight(),
),
)
}
@ -78,9 +92,14 @@ class ImeInsetsSynchronizer private constructor(
// Workaround for https://issuetracker.google.com/issues/369223558
// We expect the keyboard to have a bigger height than the OS navigation bar.
if (keyboardHeight <= lastWindowInsets.navigationBarInsetHeight) {
if (keyboardHeight <= getNavbarHeight()) {
keyboardHeight = 0
}
onIMEAnimationStarted(
isKeyboardShowingUp,
calculateBottomMargin(keyboardHeight, getNavbarHeight()),
)
}
return super.onStart(animation, bounds)
@ -101,7 +120,7 @@ class ImeInsetsSynchronizer private constructor(
targetView.updateBottomMargin(
calculateBottomMargin(
(keyboardHeight * imeAnimationFractionBasedOnDirection).toInt(),
lastWindowInsets.navigationBarInsetHeight,
getNavbarHeight(),
),
)
}
@ -134,6 +153,9 @@ class ImeInsetsSynchronizer private constructor(
false -> 0
}
private fun getNavbarHeight() = ViewCompat.getRootWindowInsets(targetView)
?.getInsets(systemBars())?.bottom ?: lastWindowInsets.navigationBarInsetHeight
private fun calculateBottomMargin(
keyboardHeight: Int,
navigationBarHeight: Int,
@ -150,11 +172,17 @@ class ImeInsetsSynchronizer private constructor(
* This works only on Android 10+, otherwise the dynamic padding based on the keyboard is not reliable.
*
* @param targetView The root view to add paddings to for accounting the visible keyboard height.
* @param onIMEAnimationStarted Callback for when the IME animation starts.
* It will inform whether the keyboard is showing or hiding and the height of the keyboard.
* @param onIMEAnimationFinished Callback for when the IME animation finishes.
* It will inform whether the keyboard is showing or hiding and the height of the keyboard.
*/
fun setup(
targetView: View,
onIMEAnimationStarted: (Boolean, Int) -> Unit = { _, _ -> },
onIMEAnimationFinished: (Boolean, Int) -> Unit = { _, _ -> },
) = when (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
true -> ImeInsetsSynchronizer(targetView)
true -> ImeInsetsSynchronizer(targetView, onIMEAnimationStarted, onIMEAnimationFinished)
false -> null
}
}

View File

@ -41,7 +41,7 @@ class ImeInsetsSynchronizerTest {
}
@Test
fun `GIVEN ime animation is not in progress WHEN window insets change THEN update target view margins to be shown on top of the keyboard`() {
fun `GIVEN ime animation in progress WHEN window insets change THEN don't update any margins`() {
val windowInsets: WindowInsetsCompat = mock()
doReturn(Insets.of(1, 2, 3, 4)).`when`(windowInsets).getInsets(ime())
doReturn(Insets.of(10, 20, 30, 40)).`when`(windowInsets).getInsets(systemBars())
@ -56,7 +56,7 @@ class ImeInsetsSynchronizerTest {
}
@Test
fun `GIVEN ime animation in progress WHEN window insets change THEN don't update any margins`() {
fun `GIVEN ime animation is not in progress WHEN window insets change THEN update target view margins to be shown on top of the keyboard`() {
val windowInsets: WindowInsetsCompat = mock()
doReturn(Insets.of(1, 2, 3, 4)).`when`(windowInsets).getInsets(ime())
doReturn(Insets.of(10, 20, 30, 40)).`when`(windowInsets).getInsets(systemBars())
@ -68,6 +68,134 @@ class ImeInsetsSynchronizerTest {
verify(targetView).requestLayout()
}
@Test
fun `GIVEN ime animation is not in progress and it is hidden WHEN window insets change THEN inform about the current keyboard status`() {
var isKeyboardShowing = "unknown"
var keyboardAnimationFinishedHeight = -1
val synchronizer = ImeInsetsSynchronizer.setup(
view = targetView,
onIMEAnimationStarted = { _, height ->
isKeyboardShowing = "error"
keyboardAnimationFinishedHeight = height + 22
},
onIMEAnimationFinished = { keyboardShowing, height ->
isKeyboardShowing = keyboardShowing.toString()
keyboardAnimationFinishedHeight = height
},
)!!
val windowInsets: WindowInsetsCompat = mock()
doReturn(Insets.of(0, 0, 0, 0)).`when`(windowInsets).getInsets(ime())
doReturn(Insets.of(10, 20, 30, 40)).`when`(windowInsets).getInsets(systemBars())
// Set that the keyboard is hidden
doReturn(false).`when`(windowInsets).isVisible(ime())
val result = synchronizer.onApplyWindowInsets(targetView, windowInsets)
assertEquals(windowInsets, result)
verify(targetViewLayoutParams).setMargins(0, 0, 0, 0)
verify(targetView).requestLayout()
assertEquals("false", isKeyboardShowing)
assertEquals(0, keyboardAnimationFinishedHeight)
}
@Test
fun `GIVEN ime animation is not in progress and it is shown WHEN window insets change THEN inform about the current keyboard status`() {
var isKeyboardShowing = "unknown"
var keyboardAnimationFinishedHeight = -1
val synchronizer = ImeInsetsSynchronizer.setup(
view = targetView,
onIMEAnimationStarted = { _, height ->
isKeyboardShowing = "error"
keyboardAnimationFinishedHeight = height + 22
},
onIMEAnimationFinished = { keyboardShowing, height ->
isKeyboardShowing = keyboardShowing.toString()
keyboardAnimationFinishedHeight = height
},
)!!
val windowInsets: WindowInsetsCompat = mock()
doReturn(Insets.of(0, 0, 0, 1000)).`when`(windowInsets).getInsets(ime())
doReturn(Insets.of(10, 20, 30, 40)).`when`(windowInsets).getInsets(systemBars())
// Set that the keyboard is shown
doReturn(true).`when`(windowInsets).isVisible(ime())
val result = synchronizer.onApplyWindowInsets(targetView, windowInsets)
assertEquals(windowInsets, result)
verify(targetViewLayoutParams).setMargins(0, 0, 0, 960)
verify(targetView).requestLayout()
assertEquals("true", isKeyboardShowing)
assertEquals(960, keyboardAnimationFinishedHeight)
}
@Test
fun `WHEN the keyboard starts to hide THEN inform about the current keyboard status`() {
var isKeyboardShowing = "unknown"
var keyboardAnimationFinishedHeight = -1
val synchronizer = ImeInsetsSynchronizer.setup(
view = targetView,
onIMEAnimationStarted = { keyboardShowing, height ->
isKeyboardShowing = keyboardShowing.toString()
keyboardAnimationFinishedHeight = height
},
onIMEAnimationFinished = { _, height ->
isKeyboardShowing = "error"
keyboardAnimationFinishedHeight = height + 22
},
)!!
val windowInsets: WindowInsetsCompat = mock()
doReturn(Insets.of(0, 0, 0, 1000)).`when`(windowInsets).getInsets(ime())
doReturn(Insets.of(10, 20, 30, 40)).`when`(windowInsets).getInsets(systemBars())
// Set that the keyboard is now considered hidden, just need to animate to that state
doReturn(false).`when`(windowInsets).isVisible(ime())
val imeAnimation: WindowInsetsAnimationCompat = mock()
doReturn(ime()).`when`(imeAnimation).typeMask
val animationBounds: WindowInsetsAnimationCompat.BoundsCompat = mock()
doReturn(Insets.of(0, 0, 0, 200)).`when`(animationBounds).lowerBound
doReturn(Insets.of(0, 0, 0, 1200)).`when`(animationBounds).upperBound
// Ensure the current window insets are known
synchronizer.onApplyWindowInsets(targetView, windowInsets)
synchronizer.onStart(imeAnimation, animationBounds)
assertEquals("false", isKeyboardShowing)
assertEquals(1000, keyboardAnimationFinishedHeight)
}
@Test
fun `WHEN the keyboard starts to show THEN inform about the current keyboard status`() {
var isKeyboardShowing = "unknown"
var keyboardAnimationFinishedHeight = -1
val synchronizer = ImeInsetsSynchronizer.setup(
view = targetView,
onIMEAnimationStarted = { keyboardShowing, height ->
isKeyboardShowing = keyboardShowing.toString()
keyboardAnimationFinishedHeight = height
},
onIMEAnimationFinished = { _, height ->
isKeyboardShowing = "error"
keyboardAnimationFinishedHeight = height + 22
},
)!!
val windowInsets: WindowInsetsCompat = mock()
doReturn(Insets.of(0, 0, 0, 1000)).`when`(windowInsets).getInsets(ime())
doReturn(Insets.of(10, 20, 30, 40)).`when`(windowInsets).getInsets(systemBars())
// Set that the keyboard starts to show
doReturn(true).`when`(windowInsets).isVisible(ime())
val imeAnimation: WindowInsetsAnimationCompat = mock()
doReturn(ime()).`when`(imeAnimation).typeMask
val animationBounds: WindowInsetsAnimationCompat.BoundsCompat = mock()
doReturn(Insets.of(0, 0, 0, 200)).`when`(animationBounds).lowerBound
doReturn(Insets.of(0, 0, 0, 1200)).`when`(animationBounds).upperBound
// Ensure the current window insets are known
synchronizer.onApplyWindowInsets(targetView, windowInsets)
synchronizer.onStart(imeAnimation, animationBounds)
assertEquals("true", isKeyboardShowing)
assertEquals(960, keyboardAnimationFinishedHeight)
}
@Test
fun `GIVEN a show keyboard animation WHEN the progress is updated THEN update the targetView to be shown above the keyboard`() {
// Set the initial system insets

View File

@ -130,6 +130,7 @@ import mozilla.components.support.base.feature.ActivityResultHandler
import mozilla.components.support.base.feature.PermissionsFeature
import mozilla.components.support.base.feature.UserInteractionHandler
import mozilla.components.support.base.feature.ViewBoundFeatureWrapper
import mozilla.components.support.ktx.android.view.ImeInsetsSynchronizer
import mozilla.components.support.ktx.android.view.enterImmersiveMode
import mozilla.components.support.ktx.android.view.exitImmersiveMode
import mozilla.components.support.ktx.android.view.hideKeyboard
@ -457,6 +458,7 @@ abstract class BaseBrowserFragment :
val tab = getCurrentTab()
browserInitialized = if (tab != null) {
initializeUI(view, tab)
setupIMEInsetsHandling(view)
true
} else {
false
@ -2453,6 +2455,8 @@ abstract class BaseBrowserFragment :
reinitializeMicrosurveyPrompt = ::initializeMicrosurveyPrompt,
)
}
view?.let { setupIMEInsetsHandling(it) }
}
private fun reinitializeNavBar() {
@ -2654,4 +2658,36 @@ abstract class BaseBrowserFragment :
navController.navigate(directions)
}
}
private fun setupIMEInsetsHandling(view: View) {
if (context?.settings()?.toolbarPosition != ToolbarPosition.BOTTOM) return
val toolbar = listOf(
_bottomToolbarContainerView?.toolbarContainerView,
_browserToolbarView?.layout,
).firstOrNull { it != null } ?: return
ImeInsetsSynchronizer.setup(
targetView = toolbar,
onIMEAnimationStarted = { isKeyboardShowingUp, keyboardHeight ->
// If the keyboard is hiding have the engine view immediately expand to the entire height of the
// screen and ensure the toolbar is shown above keyboard before both would be animated down.
if (!isKeyboardShowingUp) {
(view.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin = 0
(toolbar.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin = keyboardHeight
view.requestLayout()
}
},
onIMEAnimationFinished = { isKeyboardShowingUp, keyboardHeight ->
// If the keyboard is showing up keep the engine view covering the entire height
// of the screen until the animation is finished to avoid reflowing the web content
// together with the keyboard animation in a short burst of updates.
if (isKeyboardShowingUp) {
(view.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin = keyboardHeight
(toolbar.layoutParams as? ViewGroup.MarginLayoutParams)?.bottomMargin = 0
view.requestLayout()
}
},
)
}
}