[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
Mugurell 2 years ago committed by mergify[bot]
parent a8df7dfe3c
commit 693161d0ea

@ -359,7 +359,7 @@ abstract class BaseBrowserFragment :
} }
viewLifecycleOwner.lifecycleScope.allowUndo( viewLifecycleOwner.lifecycleScope.allowUndo(
binding.browserLayout, binding.dynamicSnackbarContainer,
snackbarMessage, snackbarMessage,
requireContext().getString(R.string.snackbar_deleted_undo), requireContext().getString(R.string.snackbar_deleted_undo),
{ {
@ -438,7 +438,7 @@ abstract class BaseBrowserFragment :
feature = ContextMenuFeature( feature = ContextMenuFeature(
fragmentManager = parentFragmentManager, fragmentManager = parentFragmentManager,
store = store, store = store,
candidates = getContextMenuCandidates(context, binding.browserLayout), candidates = getContextMenuCandidates(context, binding.dynamicSnackbarContainer),
engineView = binding.engineView, engineView = binding.engineView,
useCases = context.components.useCases.contextMenuUseCases, useCases = context.components.useCases.contextMenuUseCases,
tabId = customTabSessionId, tabId = customTabSessionId,
@ -527,7 +527,7 @@ abstract class BaseBrowserFragment :
didFail = downloadJobStatus == DownloadState.Status.FAILED, didFail = downloadJobStatus == DownloadState.Status.FAILED,
tryAgain = downloadFeature::tryAgain, tryAgain = downloadFeature::tryAgain,
onCannotOpenFile = { onCannotOpenFile = {
showCannotOpenFileError(binding.browserLayout, context, it) showCannotOpenFileError(binding.dynamicSnackbarContainer, context, it)
}, },
binding = binding.viewDynamicDownloadDialog, binding = binding.viewDynamicDownloadDialog,
toolbarHeight = toolbarHeight, toolbarHeight = toolbarHeight,
@ -971,7 +971,7 @@ abstract class BaseBrowserFragment :
didFail = savedDownloadState.second, didFail = savedDownloadState.second,
tryAgain = onTryAgain, tryAgain = onTryAgain,
onCannotOpenFile = { onCannotOpenFile = {
showCannotOpenFileError(binding.browserLayout, context, it) showCannotOpenFileError(binding.dynamicSnackbarContainer, context, it)
}, },
binding = binding.viewDynamicDownloadDialog, binding = binding.viewDynamicDownloadDialog,
toolbarHeight = toolbarHeight, toolbarHeight = toolbarHeight,
@ -1297,7 +1297,7 @@ abstract class BaseBrowserFragment :
withContext(Main) { withContext(Main) {
view?.let { view?.let {
FenixSnackbar.make( FenixSnackbar.make(
view = binding.browserLayout, view = binding.dynamicSnackbarContainer,
duration = FenixSnackbar.LENGTH_LONG, duration = FenixSnackbar.LENGTH_LONG,
isDisplayedWithBrowserToolbar = true, isDisplayedWithBrowserToolbar = true,
) )
@ -1318,7 +1318,7 @@ abstract class BaseBrowserFragment :
withContext(Main) { withContext(Main) {
view?.let { view?.let {
FenixSnackbar.make( FenixSnackbar.make(
view = binding.browserLayout, view = binding.dynamicSnackbarContainer,
duration = FenixSnackbar.LENGTH_LONG, duration = FenixSnackbar.LENGTH_LONG,
isDisplayedWithBrowserToolbar = true, isDisplayedWithBrowserToolbar = true,
) )
@ -1361,7 +1361,7 @@ abstract class BaseBrowserFragment :
// Close find in page bar if opened // Close find in page bar if opened
findInPageIntegration.onBackPressed() findInPageIntegration.onBackPressed()
FenixSnackbar.make( FenixSnackbar.make(
view = binding.browserLayout, view = binding.dynamicSnackbarContainer,
duration = Snackbar.LENGTH_SHORT, duration = Snackbar.LENGTH_SHORT,
isDisplayedWithBrowserToolbar = false, isDisplayedWithBrowserToolbar = false,
) )
@ -1442,12 +1442,12 @@ abstract class BaseBrowserFragment :
} }
private fun showCannotOpenFileError( private fun showCannotOpenFileError(
view: View, container: ViewGroup,
context: Context, context: Context,
downloadState: DownloadState, downloadState: DownloadState,
) { ) {
FenixSnackbar.make( FenixSnackbar.make(
view = view, view = container,
duration = Snackbar.LENGTH_SHORT, duration = Snackbar.LENGTH_SHORT,
isDisplayedWithBrowserToolbar = true, isDisplayedWithBrowserToolbar = true,
).setText(DynamicDownloadDialog.getCannotOpenFileErrorMessage(context, downloadState)) ).setText(DynamicDownloadDialog.getCannotOpenFileErrorMessage(context, downloadState))

@ -442,7 +442,7 @@ class BrowserFragment : BaseBrowserFragment(), UserInteractionHandler {
} }
} }
FenixSnackbar.make( FenixSnackbar.make(
view = binding.browserLayout, view = binding.dynamicSnackbarContainer,
duration = Snackbar.LENGTH_SHORT, duration = Snackbar.LENGTH_SHORT,
isDisplayedWithBrowserToolbar = true, isDisplayedWithBrowserToolbar = true,
) )

@ -141,10 +141,20 @@ class FenixSnackbar private constructor(
0 0
}, },
) )
if (parent.id == R.id.dynamicSnackbarContainer) {
(parent.layoutParams as? CoordinatorLayout.LayoutParams)?.apply {
behavior = FenixSnackbarBehavior<FrameLayout>(
context = view.context,
toolbarPosition = view.context.settings().toolbarPosition,
)
}
}
} }
} }
// Use the same implementation of `Snackbar` // Use the same implementation of `Snackbar`
@Suppress("ReturnCount")
private fun findSuitableParent(_view: View?): ViewGroup? { private fun findSuitableParent(_view: View?): ViewGroup? {
var view = _view var view = _view
var fallback: ViewGroup? = null var fallback: ViewGroup? = null
@ -159,6 +169,10 @@ class FenixSnackbar private constructor(
return view return view
} }
if (view.id == R.id.dynamicSnackbarContainer) {
return view
}
fallback = view fallback = view
} }

@ -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
}
}

@ -66,6 +66,11 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:visibility="gone" /> android:visibility="gone" />
<FrameLayout
android:id="@+id/dynamicSnackbarContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
<mozilla.components.feature.prompts.address.AddressSelectBar <mozilla.components.feature.prompts.address.AddressSelectBar

@ -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
}

@ -6,16 +6,29 @@ package org.mozilla.fenix.tabstray.ext
import android.content.Context import android.content.Context
import android.view.View import android.view.View
import android.widget.FrameLayout
import androidx.coordinatorlayout.widget.CoordinatorLayout
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.verifyOrder import io.mockk.verifyOrder
import mozilla.components.support.test.robolectric.testContext
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith
import org.mozilla.fenix.R import org.mozilla.fenix.R
import org.mozilla.fenix.components.FenixSnackbar import org.mozilla.fenix.components.FenixSnackbar
import org.mozilla.fenix.components.FenixSnackbarBehavior
import org.mozilla.fenix.components.toolbar.ToolbarPosition
import org.mozilla.fenix.ext.settings
import org.mozilla.fenix.helpers.FenixRobolectricTestRunner
import org.mozilla.fenix.helpers.MockkRetryTestRule import org.mozilla.fenix.helpers.MockkRetryTestRule
import org.mozilla.fenix.tabstray.TabsTrayFragment.Companion.ELEVATION import org.mozilla.fenix.tabstray.TabsTrayFragment.Companion.ELEVATION
import org.mozilla.fenix.utils.Settings
@RunWith(FenixRobolectricTestRunner::class)
class FenixSnackbarKtTest { class FenixSnackbarKtTest {
@get:Rule @get:Rule
@ -94,4 +107,24 @@ class FenixSnackbarKtTest {
snackbar.setAction("test1", any()) snackbar.setAction("test1", any())
} }
} }
@Test
fun `GIVEN the snackbar is a child of dynamic container WHEN it is shown THEN enable the dynamic behavior`() {
val container = FrameLayout(testContext).apply {
id = R.id.dynamicSnackbarContainer
layoutParams = CoordinatorLayout.LayoutParams(0, 0)
}
val settings: Settings = mockk(relaxed = true) {
every { toolbarPosition } returns ToolbarPosition.BOTTOM
}
mockkStatic("org.mozilla.fenix.ext.ContextKt") {
every { any<Context>().settings() } returns settings
FenixSnackbar.make(view = container)
val behavior = (container.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior
assertTrue(behavior is FenixSnackbarBehavior)
assertEquals(ToolbarPosition.BOTTOM, (behavior as? FenixSnackbarBehavior)?.toolbarPosition)
}
}
} }

Loading…
Cancel
Save