commit a13db57fbf6ffad1de676c09672da517078c0fc9 parent ad7bf747a5d4866f07807dec5caf0444dd41d588 Author: Mugurell <Mugurell@users.noreply.github.com> Date: Wed, 12 Nov 2025 08:07:06 +0000 Bug 1991654 - part 3 - Use new EngineViewScrollingDataBehavior r=android-reviewers,Roger This will animate the bottom toolbar based on scroll data exposed by GeckoView instead of inferring the scroll distances based on intercepted MotionEvents. As a result the dynamic toolbar will be always kept in sync with the engine view being scrolled. Differential Revision: https://phabricator.services.mozilla.com/D267223 Diffstat:
8 files changed, 381 insertions(+), 10 deletions(-)
diff --git a/mobile/android/android-components/components/ui/widgets/build.gradle b/mobile/android/android-components/components/ui/widgets/build.gradle @@ -29,6 +29,7 @@ dependencies { testImplementation project(':components:support-test-fakes') testImplementation libs.androidx.test.junit + testImplementation libs.kotlinx.coroutines.test testImplementation libs.robolectric } diff --git a/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehaviorFactory.kt b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehaviorFactory.kt @@ -7,11 +7,14 @@ package mozilla.components.ui.widgets.behavior import android.view.View import mozilla.components.concept.base.crash.CrashReporting import mozilla.components.concept.engine.EngineView +import mozilla.components.ui.widgets.behavior.DependencyGravity.Bottom /** * Factory for [EngineViewScrollingBehavior] instances. */ -object EngineViewScrollingBehaviorFactory { +class EngineViewScrollingBehaviorFactory( + private val useScrollData: Boolean = false, +) { /** * Create a new [EngineViewScrollingBehavior] instance. * @@ -25,10 +28,18 @@ object EngineViewScrollingBehaviorFactory { dependency: View, dependencyGravity: DependencyGravity, crashReporting: CrashReporting? = null, - ) = EngineViewScrollingGesturesBehavior( - engineView = engineView, - dependency = dependency, - dependencyGravity = dependencyGravity, - crashReporting = crashReporting, - ) + ) = when (useScrollData && dependencyGravity is Bottom) { + true -> EngineViewScrollingDataBehavior( + engineView = engineView, + dependency = dependency, + dependencyGravity = dependencyGravity, + ) + + false -> EngineViewScrollingGesturesBehavior( + engineView = engineView, + dependency = dependency, + dependencyGravity = dependencyGravity, + crashReporting = crashReporting, + ) + } } diff --git a/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingDataBehavior.kt b/mobile/android/android-components/components/ui/widgets/src/main/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingDataBehavior.kt @@ -0,0 +1,65 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.ui.widgets.behavior + +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.isVisible +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import mozilla.components.concept.engine.EngineView +import mozilla.components.support.ktx.android.view.toScope + +/** + * A [CoordinatorLayout.Behavior] implementation to be used for moving [dependency] up/down + * depending on scroll data exposed by [engineView]. + * + * This is safe to use even if [dependency] has it's visibility modified. + * + * This implementation will: + * - Show/Hide the [View] automatically when scrolling vertically. + * - Snap the [View] to be hidden or visible when the user stops scrolling. + */ +class EngineViewScrollingDataBehavior( + private val engineView: EngineView, + private val dependency: View, + dependencyGravity: DependencyGravity, + private val scrollListenerScope: CoroutineScope = engineView.asView().toScope(), +) : EngineViewScrollingBehavior(engineView, dependency, dependencyGravity) { + private var scrollUpdatesJob: Job? = null + + override fun onStartNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: View, + directTargetChild: View, + target: View, + axes: Int, + type: Int, + ): Boolean = when (dependency.isVisible && isScrollEnabled) { + true -> { + yTranslator.cancelInProgressTranslation() + + scrollUpdatesJob = scrollListenerScope.launch { + engineView.verticalScrollDelta.collect { + if (isActive) { yTranslator.translate(dependency, it) } + } + } + true + } + false -> false // not interested in subsequent scroll events + } + + override fun onStopNestedScroll( + coordinatorLayout: CoordinatorLayout, child: View, target: View, type: Int, + ) { + scrollUpdatesJob?.cancel() + + if (dependency.isVisible) { + yTranslator.snapWithAnimation(dependency) + } + } +} diff --git a/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehaviorFactoryTest.kt b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingBehaviorFactoryTest.kt @@ -0,0 +1,72 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.ui.widgets.behavior + +import android.view.View +import androidx.test.ext.junit.runners.AndroidJUnit4 +import mozilla.components.concept.engine.EngineView +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.ui.widgets.behavior.DependencyGravity.Bottom +import mozilla.components.ui.widgets.behavior.DependencyGravity.Top +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn + +@RunWith(AndroidJUnit4::class) +class EngineViewScrollingBehaviorFactoryTest { + private val engineView: EngineView = mock() + + @Before + fun setUp() { + doReturn(View(testContext)).`when`(engineView).asView() + } + + @Test + fun `GIVEN should use scroll data and dependency is at bottom WHEN building a scrolling behavior THEN return one using scroll data`() { + val result = EngineViewScrollingBehaviorFactory(true).build( + engineView = engineView, + dependency = mock(), + dependencyGravity = Bottom, + ) + + assertTrue(result is EngineViewScrollingDataBehavior) + } + + @Test + fun `GIVEN should not use scroll data and dependency is at bottom WHEN building a scrolling behavior THEN return one using scroll gestures`() { + val result = EngineViewScrollingBehaviorFactory(false).build( + engineView = engineView, + dependency = mock(), + dependencyGravity = Bottom, + ) + + assertTrue(result is EngineViewScrollingGesturesBehavior) + } + + @Test + fun `GIVEN should use scroll data and dependency is at top WHEN building a scrolling behavior THEN return one using scroll gestures`() { + val result = EngineViewScrollingBehaviorFactory(true).build( + engineView = engineView, + dependency = mock(), + dependencyGravity = Top, + ) + + assertTrue(result is EngineViewScrollingGesturesBehavior) + } + + @Test + fun `GIVEN should not use scroll data and dependency is at top WHEN building a scrolling behavior THEN return one using scroll gestures`() { + val result = EngineViewScrollingBehaviorFactory(false).build( + engineView = engineView, + dependency = mock(), + dependencyGravity = Top, + ) + + assertTrue(result is EngineViewScrollingGesturesBehavior) + } +} diff --git a/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingDataBehaviorTest.kt b/mobile/android/android-components/components/ui/widgets/src/test/java/mozilla/components/ui/widgets/behavior/EngineViewScrollingDataBehaviorTest.kt @@ -0,0 +1,215 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +package mozilla.components.ui.widgets.behavior + +import android.content.Context +import android.graphics.Bitmap +import android.view.View +import android.widget.FrameLayout +import androidx.core.view.ViewCompat +import androidx.core.view.isVisible +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.plus +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import mozilla.components.concept.engine.EngineSession +import mozilla.components.concept.engine.EngineView +import mozilla.components.concept.engine.selection.SelectionActionDelegate +import mozilla.components.support.test.any +import mozilla.components.support.test.mock +import mozilla.components.support.test.robolectric.testContext +import mozilla.components.ui.widgets.behavior.DependencyGravity.Bottom +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.doReturn +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoMoreInteractions + +@RunWith(AndroidJUnit4::class) +class EngineViewScrollingDataBehaviorTest { + @Test + fun `GIVEN dependency is not valid WHEN scrolling starts THEN don't do anything`() { + val dependency: View = mock() + val behavior = EngineViewScrollingDataBehavior(createDummyEngineView(), dependency, Bottom) + val yTranslator: ViewYTranslator = mock() + behavior.yTranslator = yTranslator + + doReturn(View.GONE).`when`(dependency).visibility + val acceptsNestedScroll = behavior.onStartNestedScroll( + coordinatorLayout = mock(), + child = mock(), + directTargetChild = mock(), + target = mock(), + axes = ViewCompat.SCROLL_AXIS_VERTICAL, + type = ViewCompat.TYPE_TOUCH, + ) + + assertFalse(acceptsNestedScroll) + verify(yTranslator, never()).cancelInProgressTranslation() + } + + @Test + fun `GIVEN dependency is valid WHEN scrolling ends THEN snap the dependency`() { + val dependency: View = mock() + val behavior = EngineViewScrollingDataBehavior(createDummyEngineView(), dependency, Bottom) + val yTranslator: ViewYTranslator = mock() + behavior.yTranslator = yTranslator + + doReturn(View.VISIBLE).`when`(dependency).visibility + behavior.onStopNestedScroll( + coordinatorLayout = mock(), + child = mock(), + target = mock(), + type = 0, + ) + + verify(yTranslator).snapWithAnimation(any()) + } + + @Test + fun `GIVEN dependency is not valid WHEN scrolling ends THEN don't to anything`() { + val dependency: View = mock() + val behavior = EngineViewScrollingDataBehavior(createDummyEngineView(), dependency, Bottom) + val yTranslator: ViewYTranslator = mock() + behavior.yTranslator = yTranslator + + doReturn(View.GONE).`when`(dependency).visibility + behavior.onStopNestedScroll( + coordinatorLayout = mock(), + child = mock(), + target = mock(), + type = 0, + ) + + verify(yTranslator, never()).snapWithAnimation(any()) + } + + @Test + fun `WHEN called to expand the dependency THEN do this through the y translator`() { + val dependency: View = mock() + val behavior = EngineViewScrollingDataBehavior(createDummyEngineView(), dependency, Bottom) + val yTranslator: ViewYTranslator = mock() + behavior.yTranslator = yTranslator + + behavior.forceExpand() + + verify(yTranslator).expandWithAnimation(dependency) + } + + @Test + fun `WHEN called to collapse the dependency THEN do this through the y translator`() { + val dependency: View = mock() + val behavior = EngineViewScrollingDataBehavior(createDummyEngineView(), dependency, Bottom) + val yTranslator: ViewYTranslator = mock() + behavior.yTranslator = yTranslator + + behavior.forceCollapse() + + verify(yTranslator).collapseWithAnimation(dependency) + } + + @Test + fun `WHEN scrolling is enabled THEN remember this internally`() { + val behavior = EngineViewScrollingDataBehavior(createDummyEngineView(), mock(), Bottom) + + assertFalse(behavior.isScrollEnabled) + behavior.enableScrolling() + + assertTrue(behavior.isScrollEnabled) + } + + @Test + fun `GIVEN scrolling is disabled THEN remember this internally`() { + val behavior = EngineViewScrollingDataBehavior(createDummyEngineView(), mock(), Bottom) + behavior.isScrollEnabled = true + + assertTrue(behavior.isScrollEnabled) + behavior.disableScrolling() + + assertFalse(behavior.isScrollEnabled) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `GIVEN dependency is valid and scrolling is enabled WHEN receiving vertical scroll data THEN translate the dependency`() = runTest { + val scrollDeltaUpdates = MutableStateFlow(0f) + val engineView = object : EngineView by createDummyEngineView() { + override val verticalScrollDelta = scrollDeltaUpdates + } + val scrollUpdatesJob = Job() + val dependency: View = mock() + doReturn(View.VISIBLE).`when`(dependency).isVisible + val behavior = EngineViewScrollingDataBehavior(engineView, dependency, Bottom, this + scrollUpdatesJob) + behavior.enableScrolling() + val yTranslator: ViewYTranslator = mock() + behavior.yTranslator = yTranslator + + behavior.onStartNestedScroll(mock(), mock(), mock(), mock(), 2, 2) + scrollDeltaUpdates.emit(8f) + advanceUntilIdle() + scrollDeltaUpdates.emit(9f) + advanceUntilIdle() + + verify(yTranslator).cancelInProgressTranslation() + verify(yTranslator).translate(dependency, 8f) + verify(yTranslator).translate(dependency, 9f) + verifyNoMoreInteractions(yTranslator) + + behavior.onStopNestedScroll(mock(), mock(), mock(), 2) + verify(yTranslator).snapWithAnimation(dependency) + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `GIVEN scrolling is disabled WHEN receiving vertical scroll data THEN don't translate the dependency`() = runTest { + val scrollDeltaUpdates = MutableStateFlow(0f) + val engineView = object : EngineView by createDummyEngineView() { + override val verticalScrollDelta = scrollDeltaUpdates + } + val dependency: View = mock() + doReturn(View.VISIBLE).`when`(dependency).isVisible + val behavior = EngineViewScrollingDataBehavior(engineView, dependency, Bottom, this + Job()) + behavior.disableScrolling() + val yTranslator: ViewYTranslator = mock() + behavior.yTranslator = yTranslator + + behavior.onStartNestedScroll(mock(), mock(), mock(), mock(), 2, 2) + scrollDeltaUpdates.emit(8f) + advanceUntilIdle() + scrollDeltaUpdates.emit(9f) + advanceUntilIdle() + verifyNoMoreInteractions(yTranslator) + + behavior.onStopNestedScroll(mock(), mock(), mock(), 2) + verify(yTranslator).snapWithAnimation(dependency) + } + + private fun createDummyEngineView(): EngineView = DummyEngineView(testContext) + + open class DummyEngineView(context: Context) : FrameLayout(context), EngineView { + override val verticalScrollPosition = flowOf(0f) + override val verticalScrollDelta = flowOf(0f) + override fun setVerticalClipping(clippingHeight: Int) {} + override fun setDynamicToolbarMaxHeight(height: Int) {} + override fun setActivityContext(context: Context?) {} + override fun captureThumbnail(onFinish: (Bitmap?) -> Unit) = Unit + override fun render(session: EngineSession) {} + override fun release() {} + override var selectionActionDelegate: SelectionActionDelegate? = null + override fun addWindowInsetsListener( + key: String, + listener: androidx.core.view.OnApplyWindowInsetsListener?, + ) {} + override fun removeWindowInsetsListener(key: String) {} + override fun asView() = View(context) + } +} diff --git a/mobile/android/android-components/docs/changelog.md b/mobile/android/android-components/docs/changelog.md @@ -5,8 +5,10 @@ permalink: /changelog/ --- # 147.0 (In Development) +* **ui-widgets** + * 🆕 New `EngineViewScrollingDataBehavior` meant to only be used to animate a bottom toolbar/banner in sync with the current webpage [Bug 1991654](https://bugzilla.mozilla.org/show_bug.cgi?id=1991654). * **concept-engine** and **browser-engine-gecko** - * 🆕 New `verticalScrollPosition` and `verticalScrollDelta` APIs exposing the current scroll position and delta of the webpage [Bug 1990215](https://bugzilla.mozilla.org/show_bug.cgi?id=1990215) + * 🆕 New `verticalScrollPosition` and `verticalScrollDelta` APIs exposing the current scroll position and delta of the webpage [Bug 1990215](https://bugzilla.mozilla.org/show_bug.cgi?id=1990215). # 146.0 diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/BottomToolbarContainerView.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/BottomToolbarContainerView.kt @@ -19,6 +19,7 @@ import mozilla.components.ui.widgets.behavior.DependencyGravity.Bottom import mozilla.components.ui.widgets.behavior.EngineViewScrollingBehavior import mozilla.components.ui.widgets.behavior.EngineViewScrollingBehaviorFactory import org.mozilla.fenix.R +import org.mozilla.fenix.ext.settings /** * A helper class to add a bottom toolbar container view to the given [parent]. @@ -54,7 +55,9 @@ class BottomToolbarContainerView( gravity = Gravity.BOTTOM val engineView = parent.findViewInHierarchy { it is EngineView } as? EngineView if (hideOnScroll && engineView != null) { - behavior = EngineViewScrollingBehaviorFactory.build( + behavior = EngineViewScrollingBehaviorFactory( + useScrollData = context.settings().useNewDynamicToolbarBehaviour, + ).build( engineView = engineView, dependency = toolbarContainerView, dependencyGravity = Bottom, diff --git a/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/FenixBrowserToolbarView.kt b/mobile/android/fenix/app/src/main/java/org/mozilla/fenix/components/toolbar/FenixBrowserToolbarView.kt @@ -137,7 +137,9 @@ abstract class FenixBrowserToolbarView( internal fun setDynamicToolbarBehavior(isToolbarAtBottom: Boolean) { (parent.findViewInHierarchy { it is EngineView } as? EngineView)?.let { engineView -> (layout.layoutParams as CoordinatorLayout.LayoutParams).apply { - behavior = EngineViewScrollingBehaviorFactory.build( + behavior = EngineViewScrollingBehaviorFactory( + useScrollData = settings.useNewDynamicToolbarBehaviour, + ).build( engineView = engineView, dependency = layout, dependencyGravity = when (isToolbarAtBottom) {