Commit d6ab5d4b authored by Mugurell's avatar Mugurell
Browse files

For #8185 - Add a new VerticalSwipeRefreshLayout

This comes to resolve many issues with SwipeRefreshLayout but based on a lot of
debugging. As a result the code is somewhat brittle.

Because we'd control responding to touches and updating the UI we need to be as
swift as possible and as such to differentiate between possible gestures I am
some simple layman's ways of doing this and GestureDetector / ScaleDetector
instances since they would add to the execution time and overall complexity,
and their callbacks would be hard to synchronize.
parent 7bec3d6e
......@@ -33,6 +33,7 @@ dependencies {
implementation Dependencies.androidx_constraintlayout
implementation Dependencies.androidx_core_ktx
implementation Dependencies.google_material
implementation Dependencies.androidx_swiperefreshlayout
testImplementation project(":support-test")
......
/* 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
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import androidx.annotation.VisibleForTesting
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import kotlin.math.abs
/**
* [SwipeRefreshLayout] that filters only vertical scrolls for triggering pull to refresh.
*
* Following situations will not trigger pull to refresh:
* - a scroll happening more on the horizontal axis
* - a scale in/out gesture
* - a quick scale gesture
*
* To control responding to scrolls and showing the pull to refresh throbber or not
* use the [View.isEnabled] property.
*/
class VerticalSwipeRefreshLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : SwipeRefreshLayout(context, attrs) {
@VisibleForTesting
internal var isQuickScaleInProgress = false
@VisibleForTesting
internal var quickScaleEvents = QuickScaleEvents()
private var previousX = 0f
private var previousY = 0f
private val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout()
private val doubleTapSlop = ViewConfiguration.get(context).scaledDoubleTapSlop
private val doubleTapSlopSquare = doubleTapSlop * doubleTapSlop
@Suppress("ComplexMethod", "ReturnCount")
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
// Setting "isEnabled = false" is recommended for users of this ViewGroup
// who who are not interested in the pull to refresh functionality
// Setting this easily avoids executing code unneededsly before the check for "canChildScrollUp".
if (!isEnabled) {
return false
}
// Layman's scale gesture (with two fingers) detector.
// Allows for quick, serial inference as opposed to using ScaleGestureDetector
// which uses callbacks and would be hard to synchronize in the little time we have.
if (event.pointerCount > 1) {
return false
}
val eventAction = event.action
// Cleanup if the gesture has been aborted or quick scale just ended/
if (MotionEvent.ACTION_CANCEL == eventAction ||
(MotionEvent.ACTION_UP == eventAction && isQuickScaleInProgress)) {
forgetQuickScaleEvents()
return callSuperOnInterceptTouchEvent(event)
}
// Disable pull to refresh if quick scale is in progress.
maybeAddDoubleTapEvent(event)
if (isQuickScaleInProgress(quickScaleEvents)) {
isQuickScaleInProgress = true
return false
}
// Disable pull to refresh if the move was more on the X axis.
if (MotionEvent.ACTION_DOWN == eventAction) {
previousX = event.x
previousY = event.y
} else if (MotionEvent.ACTION_MOVE == eventAction) {
val xDistance = abs(event.x - previousX)
val yDistance = abs(event.y - previousY)
previousX = event.x
previousY = event.y
if (xDistance > yDistance) {
return false
}
}
return callSuperOnInterceptTouchEvent(event)
}
override fun onStartNestedScroll(child: View, target: View, nestedScrollAxes: Int): Boolean {
// Ignoring nested scrolls from descendants.
// Allowing descendants to trigger nested scrolls would defeat the purpose of this class
// and result in pull to refresh to happen for all movements on the Y axis
// (even as part of scale/quick scale gestures) while also doubling the throbber with the overscroll shadow.
return if (isEnabled) {
return false
} else {
callSuperOnStartNestedScroll(child, target, nestedScrollAxes)
}
}
@SuppressLint("Recycle") // we do recycle the events in forgetQuickScaleEvents()
@VisibleForTesting
internal fun maybeAddDoubleTapEvent(event: MotionEvent) {
val currentEventAction = event.action
// A double tap event must follow the order:
// ACTION_DOWN - ACTION_UP - ACTION_DOWN
// all these events happening in an interval defined by a system constant - DOUBLE_TAP_TIMEOUT
if (MotionEvent.ACTION_DOWN == currentEventAction) {
if (quickScaleEvents.upEvent != null) {
if (event.eventTime - quickScaleEvents.upEvent!!.eventTime > doubleTapTimeout) {
// Too much time passed for the MotionEvents sequence to be considered
// a quick scale gesture. Restart counting.
forgetQuickScaleEvents()
quickScaleEvents.firstDownEvent = MotionEvent.obtain(event)
} else {
quickScaleEvents.secondDownEvent = MotionEvent.obtain(event)
}
} else {
// This may be the first time the user touches the screen or
// the gesture was not finished with ACTION_UP.
forgetQuickScaleEvents()
quickScaleEvents.firstDownEvent = MotionEvent.obtain(event)
}
}
// For the double tap events series we need ACTION_DOWN first
// and then ACTION_UP second.
else if (MotionEvent.ACTION_UP == currentEventAction && quickScaleEvents.firstDownEvent != null) {
quickScaleEvents.upEvent = MotionEvent.obtain(event)
}
}
@VisibleForTesting
internal fun forgetQuickScaleEvents() {
quickScaleEvents.firstDownEvent?.recycle()
quickScaleEvents.upEvent?.recycle()
quickScaleEvents.secondDownEvent?.recycle()
quickScaleEvents.firstDownEvent = null
quickScaleEvents.upEvent = null
quickScaleEvents.secondDownEvent = null
isQuickScaleInProgress = false
}
@VisibleForTesting
internal fun isQuickScaleInProgress(events: QuickScaleEvents): Boolean {
return if (events.isNotNull()) {
isQuickScaleInProgress(events.firstDownEvent!!, events.upEvent!!, events.secondDownEvent!!)
} else {
false
}
}
// Method closely following GestureDetectorCompat#isConsideredDoubleTap.
// Allows for serial inference of double taps as opposed to using callbacks.
@VisibleForTesting
internal fun isQuickScaleInProgress(
firstDown: MotionEvent,
firstUp: MotionEvent,
secondDown: MotionEvent
): Boolean {
if (secondDown.eventTime - firstUp.eventTime > doubleTapTimeout) {
return false
}
val deltaX = firstDown.x.toInt() - secondDown.x.toInt()
val deltaY = firstDown.y.toInt() - secondDown.y.toInt()
return deltaX * deltaX + deltaY * deltaY < doubleTapSlopSquare
}
@VisibleForTesting
internal fun callSuperOnInterceptTouchEvent(event: MotionEvent) =
super.onInterceptTouchEvent(event)
@VisibleForTesting
internal fun callSuperOnStartNestedScroll(child: View, target: View, nestedScrollAxes: Int) =
super.onStartNestedScroll(child, target, nestedScrollAxes)
private fun QuickScaleEvents.isNotNull(): Boolean {
return firstDownEvent != null && upEvent != null && secondDownEvent != null
}
/**
* Wrapper over the MotionEvents that compose a quickScale gesture.
*/
@VisibleForTesting
internal data class QuickScaleEvents(
var firstDownEvent: MotionEvent? = null,
var upEvent: MotionEvent? = null,
var secondDownEvent: MotionEvent? = null
)
}
/* 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
import android.view.MotionEvent
object TestUtils {
fun getMotionEvent(
action: Int,
x: Float = 0f,
y: Float = 0f,
eventTime: Long = System.currentTimeMillis(),
previousEvent: MotionEvent? = null
): MotionEvent {
val downTime = previousEvent?.downTime ?: System.currentTimeMillis()
var pointerCount = previousEvent?.pointerCount ?: 0
if (action == MotionEvent.ACTION_POINTER_DOWN) {
pointerCount++
} else if (action == MotionEvent.ACTION_DOWN) {
pointerCount = 1
}
val properties = Array(pointerCount,
TestUtils::getPointerProperties
)
val pointerCoords =
getPointerCoords(
x,
y,
pointerCount,
previousEvent
)
return MotionEvent.obtain(
downTime, eventTime,
action, pointerCount, properties,
pointerCoords, 0, 0, 1f, 1f, 0, 0, 0, 0
)
}
private fun getPointerCoords(
x: Float,
y: Float,
pointerCount: Int,
previousEvent: MotionEvent? = null
): Array<MotionEvent.PointerCoords?> {
val currentEventCoords = MotionEvent.PointerCoords().apply {
this.x = x; this.y = y; pressure = 1f; size = 1f
}
return if (pointerCount > 1 && previousEvent != null) {
arrayOf(
MotionEvent.PointerCoords().apply {
this.x = previousEvent.x; this.y = previousEvent.y; pressure = 1f; size = 1f
},
currentEventCoords
)
} else {
arrayOf(currentEventCoords)
}
}
private fun getPointerProperties(id: Int): MotionEvent.PointerProperties =
MotionEvent.PointerProperties().apply {
this.id = id; this.toolType = MotionEvent.TOOL_TYPE_FINGER
}
}
/* 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
import android.view.MotionEvent
import android.view.MotionEvent.ACTION_CANCEL
import android.view.MotionEvent.ACTION_DOWN
import android.view.MotionEvent.ACTION_MOVE
import android.view.MotionEvent.ACTION_POINTER_DOWN
import android.view.MotionEvent.ACTION_UP
import android.view.View
import androidx.test.ext.junit.runners.AndroidJUnit4
import mozilla.components.support.test.mock
import mozilla.components.support.test.robolectric.testContext
import mozilla.components.ui.widgets.VerticalSwipeRefreshLayout.QuickScaleEvents
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.spy
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
@RunWith(AndroidJUnit4::class)
class VerticalSwipeRefreshLayoutTest {
private lateinit var swipeLayout: VerticalSwipeRefreshLayout
@Before
fun setup() {
swipeLayout = VerticalSwipeRefreshLayout(testContext)
}
@Test
fun `onInterceptTouchEvent should abort pull to refresh and return false if the View is disabled`() {
swipeLayout = spy(swipeLayout)
val secondFingerEvent = TestUtils.getMotionEvent(ACTION_POINTER_DOWN)
swipeLayout.isEnabled = false
assertFalse(swipeLayout.onInterceptTouchEvent(secondFingerEvent))
verify(swipeLayout, times(0)).callSuperOnInterceptTouchEvent(secondFingerEvent)
}
@Test
fun `onInterceptTouchEvent should abort pull to refresh and return false if zoom is in progress`() {
swipeLayout = spy(swipeLayout)
val downEvent = TestUtils.getMotionEvent(ACTION_DOWN, 0f, 0f)
val pointerDownEvent =
TestUtils.getMotionEvent(ACTION_POINTER_DOWN, 200f, 200f, previousEvent = downEvent)
swipeLayout.isEnabled = true
swipeLayout.setOnChildScrollUpCallback { _, _ -> true }
swipeLayout.onInterceptTouchEvent(downEvent)
verify(swipeLayout, times(1)).callSuperOnInterceptTouchEvent(downEvent)
swipeLayout.onInterceptTouchEvent(pointerDownEvent)
assertFalse(swipeLayout.onInterceptTouchEvent(pointerDownEvent))
verify(swipeLayout, times(0)).callSuperOnInterceptTouchEvent(pointerDownEvent)
}
@Test
fun `onInterceptTouchEvent should cleanup if ACTION_CANCEL`() {
swipeLayout = spy(swipeLayout)
val cancelEvent = TestUtils.getMotionEvent(
ACTION_CANCEL,
previousEvent = TestUtils.getMotionEvent(ACTION_DOWN)
)
swipeLayout.isEnabled = true
swipeLayout.setOnChildScrollUpCallback { _, _ -> true }
swipeLayout.onInterceptTouchEvent(cancelEvent)
verify(swipeLayout).forgetQuickScaleEvents()
verify(swipeLayout).callSuperOnInterceptTouchEvent(cancelEvent)
}
@Test
fun `onInterceptTouchEvent should cleanup if quick scale ended`() {
swipeLayout = spy(swipeLayout)
val upEvent = TestUtils.getMotionEvent(
ACTION_CANCEL,
previousEvent = TestUtils.getMotionEvent(ACTION_DOWN)
)
swipeLayout.isEnabled = true
swipeLayout.isQuickScaleInProgress = true
swipeLayout.setOnChildScrollUpCallback { _, _ -> true }
swipeLayout.onInterceptTouchEvent(upEvent)
verify(swipeLayout).forgetQuickScaleEvents()
verify(swipeLayout).callSuperOnInterceptTouchEvent(upEvent)
}
@Test
fun `onInterceptTouchEvent should disable pull to refresh if quick scale is in progress`() {
// default DOUBLE_TAP_TIMEOUT is 300ms
swipeLayout = spy(swipeLayout)
val firstDownEvent = TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 100)
val upEvent =
TestUtils.getMotionEvent(ACTION_UP, eventTime = 200, previousEvent = firstDownEvent)
val newDownEvent =
TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 500, previousEvent = upEvent)
val previousEvents = QuickScaleEvents(firstDownEvent, upEvent, null)
swipeLayout.quickScaleEvents = previousEvents
swipeLayout.isQuickScaleInProgress = false
assertFalse(swipeLayout.onInterceptTouchEvent(newDownEvent))
assertTrue(swipeLayout.isQuickScaleInProgress)
verify(swipeLayout).maybeAddDoubleTapEvent(newDownEvent)
verify(swipeLayout, times(0)).callSuperOnInterceptTouchEvent(newDownEvent)
}
@Test
fun `onInterceptTouchEvent should disable pull to refresh if move was more on the x axys`() {
// default DOUBLE_TAP_TIMEOUT is 300ms
swipeLayout = spy(swipeLayout)
val downEvent = TestUtils.getMotionEvent(ACTION_DOWN, x = 0f, y = 0f, eventTime = 0)
val moveEvent = TestUtils.getMotionEvent(
ACTION_MOVE, x = 1f, y = 0f, eventTime = 100, previousEvent = downEvent
)
swipeLayout.isEnabled = true
swipeLayout.isQuickScaleInProgress = false
swipeLayout.setOnChildScrollUpCallback { _, _ -> false }
swipeLayout.onInterceptTouchEvent(downEvent)
verify(swipeLayout).callSuperOnInterceptTouchEvent(downEvent)
assertFalse(swipeLayout.onInterceptTouchEvent(moveEvent))
verify(swipeLayout, times(0)).callSuperOnInterceptTouchEvent(moveEvent)
}
@Test
fun `onInterceptTouchEvent should allow pull to refresh if move was more on the y axys`() {
// default DOUBLE_TAP_TIMEOUT is 300ms
swipeLayout = spy(swipeLayout)
val downEvent = TestUtils.getMotionEvent(ACTION_DOWN, x = 0f, y = 0f, eventTime = 0)
val moveEvent = TestUtils.getMotionEvent(
ACTION_MOVE, x = 0f, y = 1f, eventTime = 100, previousEvent = downEvent
)
swipeLayout.isEnabled = true
swipeLayout.isQuickScaleInProgress = false
swipeLayout.setOnChildScrollUpCallback { _, _ -> false }
swipeLayout.onInterceptTouchEvent(downEvent)
verify(swipeLayout).callSuperOnInterceptTouchEvent(downEvent)
swipeLayout.onInterceptTouchEvent(moveEvent)
verify(swipeLayout).callSuperOnInterceptTouchEvent(moveEvent)
}
@Test
fun `Should not respond descendants initiated scrolls if this View is enabled`() {
swipeLayout = spy(swipeLayout)
val childView: View = mock()
val targetView: View = mock()
val scrollAxis = 0
swipeLayout.isEnabled = true
assertFalse(swipeLayout.onStartNestedScroll(childView, targetView, scrollAxis))
verify(swipeLayout, times(0)).callSuperOnStartNestedScroll(
childView,
targetView,
scrollAxis
)
}
@Test
fun `Should delegate super#onStartNestedScroll if this View is not enabled`() {
swipeLayout = spy(swipeLayout)
val childView: View = mock()
val targetView: View = mock()
val scrollAxis = 0
swipeLayout.isEnabled = false
swipeLayout.onStartNestedScroll(childView, targetView, scrollAxis)
verify(swipeLayout).callSuperOnStartNestedScroll(childView, targetView, scrollAxis)
}
@Test
fun `maybeAddDoubleTapEvent should not modify quickScaleEvents if not for ACTION_DOWN or ACTION_UP`() {
val emptyListOfEvents = QuickScaleEvents()
swipeLayout.quickScaleEvents = emptyListOfEvents
swipeLayout.maybeAddDoubleTapEvent(TestUtils.getMotionEvent(ACTION_POINTER_DOWN))
assertEquals(emptyListOfEvents, swipeLayout.quickScaleEvents)
}
@Test
fun `maybeAddDoubleTapEvent will add ACTION_UP as second event if there is already one event in sequence`() {
val firstEvent = spy(TestUtils.getMotionEvent(ACTION_DOWN))
val secondEvent =
spy(TestUtils.getMotionEvent(ACTION_UP, eventTime = 133, previousEvent = firstEvent))
val expectedResult = Triple<MotionEvent?, MotionEvent?, MotionEvent?>(
firstEvent, secondEvent, null
)
swipeLayout.quickScaleEvents = QuickScaleEvents(firstEvent, null, null)
swipeLayout.maybeAddDoubleTapEvent(secondEvent)
// A Triple assert or MotionEvent assert fails probably because of the copies made
// Verifying the expected actions and eventTime should be good enough.
assertEquals(expectedResult.first, swipeLayout.quickScaleEvents.firstDownEvent)
assertEquals(
expectedResult.second!!.actionMasked,
swipeLayout.quickScaleEvents.upEvent!!.actionMasked
)
assertEquals(
expectedResult.second!!.eventTime,
swipeLayout.quickScaleEvents.upEvent!!.eventTime
)
assertEquals(null, swipeLayout.quickScaleEvents.secondDownEvent)
}
@Test
fun `maybeAddDoubleTapEvent will not add ACTION_UP if there is not a first event already in sequence`() {
val firstEvent = spy(TestUtils.getMotionEvent(ACTION_DOWN))
val secondEvent =
spy(TestUtils.getMotionEvent(ACTION_UP, eventTime = 133, previousEvent = firstEvent))
val expectedResult = QuickScaleEvents()
swipeLayout.quickScaleEvents = expectedResult
swipeLayout.maybeAddDoubleTapEvent(secondEvent)
assertEquals(null, swipeLayout.quickScaleEvents.firstDownEvent)
assertEquals(null, swipeLayout.quickScaleEvents.upEvent)
assertEquals(null, swipeLayout.quickScaleEvents.secondDownEvent)
}
@Test
fun `maybeAddDoubleTapEvent will add the first ACTION_DOWN if the events list is otherwise empty`() {
swipeLayout = spy(swipeLayout)
val emptyListOfEvents = QuickScaleEvents()
val downEvent = TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 133)
swipeLayout.quickScaleEvents = emptyListOfEvents
swipeLayout.maybeAddDoubleTapEvent(downEvent)
verify(swipeLayout).forgetQuickScaleEvents()
assertEquals(downEvent.actionMasked, swipeLayout.quickScaleEvents.firstDownEvent!!.actionMasked)
assertEquals(downEvent.eventTime, swipeLayout.quickScaleEvents.firstDownEvent!!.eventTime)
assertEquals(null, swipeLayout.quickScaleEvents.upEvent)
assertEquals(null, swipeLayout.quickScaleEvents.secondDownEvent)
}
@Test
fun `maybeAddDoubleTapEvent will reset the first ACTION_DOWN if the events list does not contain other events`() {
swipeLayout = spy(swipeLayout)
val previousDownEvent = TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 111)
val previousEvents = QuickScaleEvents(previousDownEvent, null, null)
val newDownEvent = TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 222)
swipeLayout.quickScaleEvents = previousEvents
swipeLayout.maybeAddDoubleTapEvent(newDownEvent)
verify(swipeLayout).forgetQuickScaleEvents()
assertEquals(newDownEvent.actionMasked, swipeLayout.quickScaleEvents.firstDownEvent!!.actionMasked)
assertEquals(newDownEvent.eventTime, swipeLayout.quickScaleEvents.firstDownEvent!!.eventTime)
assertEquals(null, swipeLayout.quickScaleEvents.upEvent)
assertEquals(null, swipeLayout.quickScaleEvents.secondDownEvent)
}
@Test
fun `maybeAddDoubleTapEvent will reset ACTION_DOWN if timeout was reached`() {
// default DOUBLE_TAP_TIMEOUT is 300ms
swipeLayout = spy(swipeLayout)
val firstDownEvent = TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 100)
val upEvent =
TestUtils.getMotionEvent(ACTION_UP, eventTime = 200, previousEvent = firstDownEvent)
val newDownEvent =
TestUtils.getMotionEvent(ACTION_DOWN, eventTime = 501, previousEvent = upEvent)
val previousEvents = QuickScaleEvents(firstDownEvent, upEvent, null)