Skip to content

Commit

Permalink
Closes mozilla-mobile#2916: Improve BrowserMenu popup orientation.
Browse files Browse the repository at this point in the history
  • Loading branch information
Amejia481 committed Jun 7, 2019
1 parent 90ba4d4 commit 0e99af6
Show file tree
Hide file tree
Showing 2 changed files with 241 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,20 @@ package mozilla.components.browser.menu

import android.annotation.SuppressLint
import android.graphics.Color
import android.graphics.Rect
import android.graphics.drawable.ColorDrawable
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.widget.PopupWindow
import androidx.annotation.VisibleForTesting
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.core.widget.PopupWindowCompat
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import mozilla.components.browser.menu.BrowserMenu.Orientation.DOWN
import mozilla.components.browser.menu.BrowserMenu.Orientation.UP
import mozilla.components.support.ktx.android.content.res.pxToDp
import mozilla.components.support.ktx.android.view.isRTL

Expand All @@ -28,7 +33,7 @@ class BrowserMenu internal constructor(
private var menuList: RecyclerView? = null

@SuppressLint("InflateParams")
fun show(anchor: View, orientation: Orientation = Orientation.DOWN): PopupWindow {
fun show(anchor: View, orientation: Orientation = DOWN): PopupWindow {
val view = LayoutInflater.from(anchor.context).inflate(R.layout.mozac_browser_menu, null)

adapter.menu = this
Expand All @@ -39,15 +44,11 @@ class BrowserMenu internal constructor(
}

return PopupWindow(
view,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT
view,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT
).apply {
setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
animationStyle = when (orientation) {
Orientation.DOWN -> R.style.Mozac_Browser_Menu_Animation_OverflowMenuTop
Orientation.UP -> R.style.Mozac_Browser_Menu_Animation_OverflowMenuBottom
}
isFocusable = true
elevation = view.resources.pxToDp(MENU_ELEVATION_DP).toFloat()

Expand All @@ -56,9 +57,7 @@ class BrowserMenu internal constructor(
currentPopup = null
}

val xOffset = if (anchor.isRTL) -anchor.width else 0
val yOffset = determineVerticalOffset(orientation, view, anchor)
showAsDropDown(anchor, xOffset, yOffset)
displayPopup(view, anchor, orientation)
}.also {
currentPopup = it
}
Expand All @@ -82,12 +81,12 @@ class BrowserMenu internal constructor(
val params = parent.layoutParams
return if (params is CoordinatorLayout.LayoutParams) {
if ((params.gravity and Gravity.BOTTOM) == Gravity.BOTTOM) {
Orientation.UP
UP
} else {
Orientation.DOWN
DOWN
}
} else {
Orientation.DOWN
DOWN
}
}
}
Expand All @@ -98,14 +97,101 @@ class BrowserMenu internal constructor(
}
}

private fun determineVerticalOffset(orientation: BrowserMenu.Orientation, view: View, anchor: View): Int {
return if (orientation == BrowserMenu.Orientation.DOWN) {
// Menu should overlay anchor.
-anchor.height
@VisibleForTesting
internal fun PopupWindow.displayPopup(
containerView: View,
anchor: View,
preferredOrientation: BrowserMenu.Orientation
) {
// Measure menu
val spec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
containerView.measure(spec, spec)

val topAndBottomPair = getMaxAvailableHeightToTopAndBottom(anchor)
val availableHeightToTop = topAndBottomPair.first
val availableHeightToBottom = topAndBottomPair.second
val containerHeight = containerView.measuredHeight

val fitsUp = availableHeightToTop >= containerHeight
val fitsDown = availableHeightToBottom >= containerHeight

// Try to use the preferred orientation, if doesn't fit fallback to the best fit.
when {
preferredOrientation == DOWN && fitsDown -> {
showPopupWithDownOrientation(anchor)
}
preferredOrientation == UP && fitsUp -> {
showPopupWithUpOrientation(anchor, availableHeightToBottom, containerHeight)
}
else -> {
showPopupWhereBestFits(anchor, fitsUp, fitsDown, availableHeightToBottom, containerHeight)
}
}
}

private fun PopupWindow.showPopupWhereBestFits(
anchor: View,
fitsUp: Boolean,
fitsDown: Boolean,
availableHeightToBottom: Int,
containerHeight: Int
) {
// We don't have enough space to show the menu UP neither DOWN.
// Let's just show the popup at the location of the anchor.
if (!fitsUp && !fitsDown) {
showAtAnchorLocation(anchor)
} else {
// Measure menu and then position menu above (and overlapping) anchor
val spec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
view.measure(spec, spec)
-view.measuredHeight
if (fitsDown) {
showPopupWithDownOrientation(anchor)
} else {
showPopupWithUpOrientation(anchor, availableHeightToBottom, containerHeight)
}
}
}

private fun PopupWindow.showPopupWithUpOrientation(anchor: View, availableHeightToBottom: Int, containerHeight: Int) {
val xOffset = if (anchor.isRTL) -anchor.width else 0
animationStyle = R.style.Mozac_Browser_Menu_Animation_OverflowMenuBottom

// Positioning the menu above and overlapping the anchor.
val yOffset = if (availableHeightToBottom < 0) {
// The anchor is partially below of the bottom of the screen, let's make the menu completely visible.
availableHeightToBottom - containerHeight
} else {
-containerHeight
}
showAsDropDown(anchor, xOffset, yOffset)
}

private fun PopupWindow.showPopupWithDownOrientation(anchor: View) {
val xOffset = if (anchor.isRTL) -anchor.width else 0
animationStyle = R.style.Mozac_Browser_Menu_Animation_OverflowMenuTop
// Menu should overlay the anchor.
showAsDropDown(anchor, xOffset, -anchor.height)
}

private fun PopupWindow.showAtAnchorLocation(anchor: View) {
val anchorPosition = IntArray(2)
anchor.getLocationOnScreen(anchorPosition)
val x = anchorPosition[0]
val y = anchorPosition[1]
PopupWindowCompat.setOverlapAnchor(this, true)
showAtLocation(anchor, Gravity.START or Gravity.TOP, x, y)
}

private fun getMaxAvailableHeightToTopAndBottom(anchor: View): Pair<Int, Int> {
val anchorPosition = IntArray(2)
val displayFrame = Rect()

val appView = anchor.rootView
appView.getWindowVisibleDisplayFrame(displayFrame)

anchor.getLocationOnScreen(anchorPosition)

val bottomEdge = displayFrame.bottom

val distanceToBottom = bottomEdge - (anchorPosition[1] + anchor.height)
val distanceToTop = anchorPosition[1] - displayFrame.top

return distanceToTop to distanceToBottom
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ import android.content.Context
import android.view.Gravity
import android.view.View
import android.widget.Button
import android.widget.PopupWindow
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider
import mozilla.components.browser.menu.item.SimpleBrowserMenuItem
import mozilla.components.support.test.any
import mozilla.components.support.test.mock
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
Expand All @@ -20,8 +22,13 @@ import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
import org.mockito.Mockito.doAnswer
import org.mockito.Mockito.doReturn
import org.mockito.Mockito.spy
import org.mockito.Mockito.verify
import org.robolectric.RobolectricTestRunner
import org.robolectric.Shadows
import org.robolectric.shadows.ShadowDisplay

@RunWith(RobolectricTestRunner::class)
class BrowserMenuTest {
Expand Down Expand Up @@ -165,4 +172,130 @@ class BrowserMenuTest {
BrowserMenu.determineMenuOrientation(view)
)
}

@Test
fun `displayPopup that fitsDown with preferredOrientation DOWN`() {
val menuContentView = createMockViewWith(y = 0)
val anchor = createMockViewWith(y = 10)
val popupWindow = spy(PopupWindow())

// Makes the availableHeightToBottom bigger than the menuContentView
setScreenHeight(200)
doReturn(11).`when`(menuContentView).measuredHeight
doReturn(-10).`when`(anchor).height

popupWindow.displayPopup(menuContentView, anchor, BrowserMenu.Orientation.DOWN)

assertEquals(popupWindow.animationStyle, R.style.Mozac_Browser_Menu_Animation_OverflowMenuTop)
verify(popupWindow).showAsDropDown(anchor, 0, 10)
}

@Test
fun `displayPopup that fitsDown with preferredOrientation UP`() {
val menuContentView = createMockViewWith(y = 0)
val anchor = createMockViewWith(y = 10)
val popupWindow = spy(PopupWindow())

// Makes the availableHeightToBottom bigger than the menuContentView
setScreenHeight(200)
doReturn(11).`when`(menuContentView).measuredHeight
doReturn(-10).`when`(anchor).height

popupWindow.displayPopup(menuContentView, anchor, BrowserMenu.Orientation.UP)

assertEquals(popupWindow.animationStyle, R.style.Mozac_Browser_Menu_Animation_OverflowMenuTop)
verify(popupWindow).showAsDropDown(anchor, 0, 10)
}

@Test
fun `displayPopup that fitsUp with preferredOrientation UP`() {
val containerView = createMockViewWith(y = 0)
// Makes the availableHeightToTop 10
val anchor = createMockViewWith(y = 10)
val popupWindow = spy(PopupWindow())

// Makes the availableHeightToBottom smaller than the availableHeightToTop
setScreenHeight(0)
doReturn(-10).`when`(anchor).height

// Makes the content of the menu smaller than the availableHeightToTop
doReturn(9).`when`(containerView).measuredHeight

popupWindow.displayPopup(containerView, anchor, BrowserMenu.Orientation.UP)

assertEquals(popupWindow.animationStyle, R.style.Mozac_Browser_Menu_Animation_OverflowMenuBottom)
verify(popupWindow).showAsDropDown(anchor, 0, -9)
}

@Test
fun `displayPopup that fitsUp with preferredOrientation DOWN`() {
val containerView = createMockViewWith(y = 0)
// Makes the availableHeightToTop 10
val anchor = createMockViewWith(y = 10)
val popupWindow = spy(PopupWindow())
val contentHeight = 9

// Makes the availableHeightToBottom smaller than the availableHeightToTop
setScreenHeight(0)
doReturn(-10).`when`(anchor).height

// Makes the content of the menu smaller than the availableHeightToTop
doReturn(contentHeight).`when`(containerView).measuredHeight

popupWindow.displayPopup(containerView, anchor, BrowserMenu.Orientation.DOWN)

assertEquals(popupWindow.animationStyle, R.style.Mozac_Browser_Menu_Animation_OverflowMenuBottom)
verify(popupWindow).showAsDropDown(anchor, 0, -contentHeight)
}

@Test
fun `displayPopup that fitsUp when anchor is partially below of the bottom of the screen`() {
val containerView = createMockViewWith(y = 0)
// Makes the availableHeightToTop 10
val anchor = createMockViewWith(y = 10)
val popupWindow = spy(PopupWindow())
val screenHeight = -1
val contentHeight = -9

// Makes the availableHeightToBottom smaller than the availableHeightToTop
setScreenHeight(screenHeight)
doReturn(-10).`when`(anchor).height

// Makes the content of the menu smaller than the availableHeightToTop
doReturn(contentHeight).`when`(containerView).measuredHeight

popupWindow.displayPopup(containerView, anchor, BrowserMenu.Orientation.UP)

assertEquals(popupWindow.animationStyle, R.style.Mozac_Browser_Menu_Animation_OverflowMenuBottom)
verify(popupWindow).showAsDropDown(anchor, 0, screenHeight - contentHeight)
}

@Test
fun `displayPopup that don't fitUp neither fitDown`() {
val containerView = createMockViewWith(y = 0)
val anchor = createMockViewWith(y = 0)
val popupWindow = spy(PopupWindow())
doReturn(Int.MAX_VALUE).`when`(containerView).measuredHeight

popupWindow.displayPopup(containerView, anchor, BrowserMenu.Orientation.DOWN)
assertEquals(popupWindow.animationStyle, -1)
verify(popupWindow).showAtLocation(anchor, Gravity.START or Gravity.TOP, 0, 0)
}

private fun createMockViewWith(y: Int): View {
val view = spy(View(context))
doAnswer { invocation ->
val locationInWindow = (invocation.getArgument(0) as IntArray)
locationInWindow[0] = 0
locationInWindow[1] = y
locationInWindow
}.`when`(view).getLocationInWindow(any())
return view
}

private fun setScreenHeight(value: Int) {
val display = ShadowDisplay.getDefaultDisplay()
val shadow = Shadows.shadowOf(display)
shadow.setHeight(value)
}
}

0 comments on commit 0e99af6

Please sign in to comment.