diff --git a/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenu.kt b/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenu.kt index 5d8be371ba6..05d0f041fb4 100644 --- a/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenu.kt +++ b/components/browser/menu/src/main/java/mozilla/components/browser/menu/BrowserMenu.kt @@ -6,21 +6,27 @@ 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 /** * A popup menu composed of BrowserMenuItem objects. */ +@Suppress("UNUSED_PARAMETER") class BrowserMenu internal constructor( private val adapter: BrowserMenuAdapter ) { @@ -28,7 +34,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 @@ -39,15 +45,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() @@ -56,9 +58,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 } @@ -82,12 +82,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 } } } @@ -98,14 +98,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 { + 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 +} diff --git a/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuTest.kt b/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuTest.kt index 585b93f8473..7b9251e4956 100644 --- a/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuTest.kt +++ b/components/browser/menu/src/test/java/mozilla/components/browser/menu/BrowserMenuTest.kt @@ -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 @@ -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 { @@ -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) + } } \ No newline at end of file