[fenix] Bug 1812518 - Control the snackbar positioning from Fenix
Previously Android-Components - BrowserToolbarBehavior would be responsible for positioning the snackbar above the toolbar. With that responsibility removed we can handle in Fenix positioning the snackbar depending on the toolbar and many more cases - like positioning it depending on the download dialogs.pull/600/head
parent
a8df7dfe3c
commit
693161d0ea
@ -0,0 +1,76 @@
|
||||
/* 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 org.mozilla.fenix.components
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import androidx.core.view.children
|
||||
import androidx.core.view.isVisible
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.components.toolbar.ToolbarPosition
|
||||
|
||||
/**
|
||||
* [CoordinatorLayout.Behavior] to be used by a snackbar that want to ensure it it always positioned
|
||||
* such that it will be shown on top (vertically) of other siblings that may obstruct it's view.
|
||||
*
|
||||
* @param context [Context] used for various system interactions.
|
||||
* @param toolbarPosition Where the toolbar is positioned on the screen.
|
||||
* Depending on it's position (top / bottom) the snackbar will be shown below / above the toolbar.
|
||||
*/
|
||||
class FenixSnackbarBehavior<V : View>(
|
||||
context: Context,
|
||||
@get:VisibleForTesting internal val toolbarPosition: ToolbarPosition,
|
||||
) : CoordinatorLayout.Behavior<V>(context, null) {
|
||||
|
||||
private val dependenciesIds = listOf(
|
||||
R.id.viewDynamicDownloadDialog,
|
||||
R.id.toolbar,
|
||||
)
|
||||
|
||||
private var currentAnchorId = View.NO_ID
|
||||
|
||||
override fun layoutDependsOn(
|
||||
parent: CoordinatorLayout,
|
||||
child: V,
|
||||
dependency: View,
|
||||
): Boolean {
|
||||
val anchorId = dependenciesIds
|
||||
.intersect(parent.children.filter { it.isVisible }.map { it.id }.toSet())
|
||||
.firstOrNull()
|
||||
|
||||
// It is possible that previous anchor's visibility is changed.
|
||||
// The layout is updated and layoutDependsOn is called but onDependentViewChanged not.
|
||||
// We have to check here if a new anchor is available and reparent the snackbar.
|
||||
// This check also ensures we are not positioning the snackbar multiple times for the same anchor.
|
||||
return if (anchorId != currentAnchorId) {
|
||||
positionSnackbar(child, parent.children.firstOrNull { it.id == anchorId })
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun positionSnackbar(child: View, dependency: View?) {
|
||||
currentAnchorId = dependency?.id ?: View.NO_ID
|
||||
val params = child.layoutParams as CoordinatorLayout.LayoutParams
|
||||
|
||||
if (dependency == null || (dependency.id == R.id.toolbar && toolbarPosition == ToolbarPosition.TOP)) {
|
||||
// Position the snackbar at the bottom of the screen.
|
||||
params.anchorId = View.NO_ID
|
||||
params.anchorGravity = Gravity.NO_GRAVITY
|
||||
params.gravity = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
|
||||
} else {
|
||||
// Position the snackbar just above the anchor.
|
||||
params.anchorId = dependency.id
|
||||
params.anchorGravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
|
||||
params.gravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL
|
||||
}
|
||||
|
||||
child.layoutParams = params
|
||||
}
|
||||
}
|
@ -0,0 +1,187 @@
|
||||
/* 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 org.mozilla.fenix.components
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
import mozilla.components.support.test.robolectric.testContext
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.mozilla.fenix.R
|
||||
import org.mozilla.fenix.components.toolbar.ToolbarPosition
|
||||
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
|
||||
|
||||
@RunWith(FenixRobolectricTestRunner::class)
|
||||
class FenixSnackbarBehaviorTest {
|
||||
private val snackbarParams = CoordinatorLayout.LayoutParams(0, 0)
|
||||
private val snackbarContainer = FrameLayout(testContext)
|
||||
private val dependency = View(testContext)
|
||||
private val parent = CoordinatorLayout(testContext)
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
snackbarContainer.layoutParams = snackbarParams
|
||||
parent.addView(dependency)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN no valid anchors are shown WHEN the snackbar is shown THEN don't anchor it`() {
|
||||
val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
|
||||
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
|
||||
assertSnackbarIsPlacedAtTheBottomOfTheScreen()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN the dynamic download dialog is shown WHEN the snackbar is shown THEN place the snackbar above the dialog`() {
|
||||
dependency.id = R.id.viewDynamicDownloadDialog
|
||||
val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
|
||||
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
|
||||
assertSnackbarPlacementAboveAnchor()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a bottom toolbar is shown WHEN the snackbar is shown THEN place the snackbar above the toolbar`() {
|
||||
dependency.id = R.id.toolbar
|
||||
val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
|
||||
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
|
||||
assertSnackbarPlacementAboveAnchor()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN a toolbar and a dynamic download dialog are shown WHEN the snackbar is shown THEN place the snackbar above the dialog`() {
|
||||
listOf(R.id.viewDynamicDownloadDialog, R.id.toolbar).forEach {
|
||||
parent.addView(View(testContext).apply { id = it })
|
||||
}
|
||||
val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
|
||||
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
|
||||
assertSnackbarPlacementAboveAnchor(parent.findViewById(R.id.viewDynamicDownloadDialog))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN the snackbar is anchored to the dynamic download dialog and a bottom toolbar is shown WHEN the dialog is not shown anymore THEN place the snackbar above the toolbar`() {
|
||||
val dialog = View(testContext)
|
||||
.apply { id = R.id.viewDynamicDownloadDialog }
|
||||
.also { parent.addView(it) }
|
||||
val toolbar = View(testContext)
|
||||
.apply { id = R.id.toolbar }
|
||||
.also { parent.addView(it) }
|
||||
val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
|
||||
|
||||
// Test the scenario where the dialog is invisible.
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarPlacementAboveAnchor(dialog)
|
||||
dialog.visibility = View.GONE
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarPlacementAboveAnchor(toolbar)
|
||||
|
||||
// Test the scenario where the dialog is removed from parent.
|
||||
dialog.visibility = View.VISIBLE
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarPlacementAboveAnchor(dialog)
|
||||
parent.removeView(dialog)
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarPlacementAboveAnchor(toolbar)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN the snackbar is anchored to the bottom toolbar WHEN the toolbar is not shown anymore THEN place the snackbar at the bottom`() {
|
||||
val toolbar = View(testContext)
|
||||
.apply { id = R.id.toolbar }
|
||||
.also { parent.addView(it) }
|
||||
val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.BOTTOM)
|
||||
|
||||
// Test the scenario where the toolbar is invisible.
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarPlacementAboveAnchor(toolbar)
|
||||
toolbar.visibility = View.GONE
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarIsPlacedAtTheBottomOfTheScreen()
|
||||
|
||||
// Test the scenario where the toolbar is removed from parent.
|
||||
toolbar.visibility = View.VISIBLE
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarPlacementAboveAnchor(toolbar)
|
||||
parent.removeView(toolbar)
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarIsPlacedAtTheBottomOfTheScreen()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN the snackbar is anchored to the dynamic download dialog and a top toolbar is shown WHEN the dialog is not shown anymore THEN place the snackbar to the bottom`() {
|
||||
val dialog = View(testContext)
|
||||
.apply { id = R.id.viewDynamicDownloadDialog }
|
||||
.also { parent.addView(it) }
|
||||
View(testContext)
|
||||
.apply { id = R.id.toolbar }
|
||||
.also { parent.addView(it) }
|
||||
val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.TOP)
|
||||
|
||||
// Test the scenario where the dialog is invisible.
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarPlacementAboveAnchor(dialog)
|
||||
dialog.visibility = View.GONE
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarIsPlacedAtTheBottomOfTheScreen()
|
||||
|
||||
// Test the scenario where the dialog is removed from parent.
|
||||
dialog.visibility = View.VISIBLE
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarPlacementAboveAnchor(dialog)
|
||||
parent.removeView(dialog)
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarIsPlacedAtTheBottomOfTheScreen()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `GIVEN the snackbar is anchored based on a top toolbar WHEN the toolbar is not shown anymore THEN place the snackbar at the bottom`() {
|
||||
val toolbar = View(testContext)
|
||||
.apply { id = R.id.toolbar }
|
||||
.also { parent.addView(it) }
|
||||
val behavior = FenixSnackbarBehavior<ViewGroup>(testContext, ToolbarPosition.TOP)
|
||||
|
||||
// Test the scenario where the toolbar is invisible.
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarIsPlacedAtTheBottomOfTheScreen()
|
||||
toolbar.visibility = View.GONE
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarIsPlacedAtTheBottomOfTheScreen()
|
||||
|
||||
// Test the scenario where the toolbar is removed from parent.
|
||||
toolbar.visibility = View.VISIBLE
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarIsPlacedAtTheBottomOfTheScreen()
|
||||
parent.removeView(toolbar)
|
||||
behavior.layoutDependsOn(parent, snackbarContainer, dependency)
|
||||
assertSnackbarIsPlacedAtTheBottomOfTheScreen()
|
||||
}
|
||||
|
||||
private fun assertSnackbarPlacementAboveAnchor(anchor: View = dependency) {
|
||||
assertEquals(anchor.id, snackbarContainer.params.anchorId)
|
||||
assertEquals(Gravity.TOP or Gravity.CENTER_HORIZONTAL, snackbarContainer.params.anchorGravity)
|
||||
assertEquals(Gravity.TOP or Gravity.CENTER_HORIZONTAL, snackbarContainer.params.gravity)
|
||||
}
|
||||
|
||||
private fun assertSnackbarIsPlacedAtTheBottomOfTheScreen() {
|
||||
assertEquals(View.NO_ID, snackbarContainer.params.anchorId)
|
||||
assertEquals(Gravity.NO_GRAVITY, snackbarContainer.params.anchorGravity)
|
||||
assertEquals(Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL, snackbarContainer.params.gravity)
|
||||
}
|
||||
|
||||
private val FrameLayout.params
|
||||
get() = layoutParams as CoordinatorLayout.LayoutParams
|
||||
}
|
Loading…
Reference in New Issue