blob: cc4bb92239a6396f901d48bd1b2b43076f227280 [file] [log] [blame]
/*
* Copyright 2019 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.viewpager2.widget
import android.os.SystemClock.sleep
import android.view.View
import android.view.ViewConfiguration
import android.view.ViewGroup
import android.view.animation.AccelerateInterpolator
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider
import androidx.test.filters.LargeTest
import androidx.testutils.LocaleTestUtils
import androidx.testutils.PollingCheck
import androidx.viewpager.widget.ViewPager
import androidx.viewpager2.test.ui.SparseAdapter
import androidx.viewpager2.widget.BaseTest.Context.SwipeMethod
import androidx.viewpager2.widget.PageChangeCallbackTest.Event.MarkerEvent
import androidx.viewpager2.widget.PageChangeCallbackTest.Event.OnPageScrollStateChangedEvent
import androidx.viewpager2.widget.PageChangeCallbackTest.Event.OnPageScrolledEvent
import androidx.viewpager2.widget.PageChangeCallbackTest.Event.OnPageSelectedEvent
import androidx.viewpager2.widget.PageChangeCallbackTest.TestConfig
import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL
import androidx.viewpager2.widget.ViewPager2.ORIENTATION_VERTICAL
import androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_DRAGGING
import androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_IDLE
import androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_SETTLING
import androidx.viewpager2.widget.swipe.PageSwiperManual
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.CoreMatchers.not
import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.greaterThan
import org.hamcrest.Matchers.greaterThanOrEqualTo
import org.junit.Assert.assertThat
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import java.util.concurrent.CountDownLatch
import java.util.concurrent.Executors.newSingleThreadExecutor
import java.util.concurrent.TimeUnit.SECONDS
import kotlin.math.roundToInt
@RunWith(Parameterized::class)
@LargeTest
class PageChangeCallbackTest(private val config: TestConfig) : BaseTest() {
data class TestConfig(
@ViewPager2.Orientation val orientation: Int,
val rtl: Boolean
)
companion object {
@JvmStatic
@Parameterized.Parameters(name = "{0}")
fun spec(): List<TestConfig> = createTestSet()
}
override fun setUp() {
super.setUp()
if (config.rtl) {
localeUtil.resetLocale()
localeUtil.setLocale(LocaleTestUtils.RTL_LANGUAGE)
}
}
/*
Sample log to guide the test
1 -> 2
onPageScrollStateChanged,1
onPageScrolled,1,0.019444,21
onPageScrolled,1,0.082407,88
onPageScrolled,1,0.173148,187
onPageScrollStateChanged,2
onPageSelected,2
onPageScrolled,1,0.343518,370
onPageScrolled,1,0.855556,924
onPageScrolled,1,0.984259,1063
onPageScrolled,2,0.000000,0
onPageScrollStateChanged,0
2 -> 1
onPageScrollStateChanged,1
onPageScrolled,1,0.972222,1050
onPageScrolled,1,0.910185,983
onPageScrolled,1,0.835185,902
onPageScrolled,1,0.764815,826
onPageScrollStateChanged,2
onPageSelected,1
onPageScrolled,1,0.616667,666
onPageScrolled,1,0.136111,147
onPageScrolled,1,0.015741,17
onPageScrolled,1,0.000000,0
onPageScrollStateChanged,0
*/
@Test
fun test_swipeBetweenPages() {
setUpTest(config.orientation).apply {
setAdapterSync(viewAdapterProvider(stringSequence(4)))
listOf(1, 2, 3, 2, 1, 0).forEach { targetPage ->
// given
val initialPage = viewPager.currentItem
assertThat(Math.abs(initialPage - targetPage), equalTo(1))
val callback = viewPager.addNewRecordingCallback()
val latch = viewPager.addWaitForScrolledLatch(targetPage)
// when
swipe(initialPage, targetPage)
latch.await(2, SECONDS)
// then
assertBasicState(targetPage)
callback.apply {
// verify all events
assertThat(draggingIx, equalTo(0))
assertThat(settlingIx, isBetweenInEx(firstScrolledIx + 1, lastScrolledIx))
assertThat(idleIx, equalTo(lastIx))
assertThat(pageSelectedIx(targetPage), equalTo(settlingIx + 1))
assertThat(scrollEventCount, equalTo(eventCount - 4))
// dive into scroll events
val sortOrder =
if (targetPage - initialPage > 0) SortOrder.ASC
else SortOrder.DESC
scrollEvents.assertPositionSorted(sortOrder)
scrollEvents.assertOffsetSorted(sortOrder)
scrollEvents.assertValueSanity(initialPage, targetPage, viewPager.pageSize)
scrollEvents.assertLastCorrect(targetPage)
scrollEvents.assertMaxShownPages()
}
viewPager.unregisterOnPageChangeCallback(callback)
}
}
}
/*
Before page 0
onPageScrollStateChanged,1
onPageScrolled,0,0.000000,0
onPageScrolled,0,0.000000,0
onPageScrolled,0,0.000000,0
onPageScrolled,0,0.000000,0
onPageScrollStateChanged,0
After page 2
onPageScrollStateChanged,1
onPageScrolled,2,0.000000,0
onPageScrolled,2,0.000000,0
onPageScrolled,2,0.000000,0
onPageScrollStateChanged,0
*/
@Test
fun test_swipeBeyondEdgePages() {
val totalPages = 3
val edgePages = setOf(0, totalPages - 1)
setUpTest(config.orientation).apply {
setAdapterSync(viewAdapterProvider(stringSequence(totalPages)))
listOf(0, 0, 1, 2, 2, 2, 1, 2, 2, 2, 1, 0, 0, 0).forEach { targetPage ->
// given
val initialPage = viewPager.currentItem
val callback = viewPager.addNewRecordingCallback()
val latch = viewPager.addWaitForScrolledLatch(targetPage)
// when
swipe(initialPage, targetPage)
latch.await(2, SECONDS)
// then
assertBasicState(targetPage)
if (targetPage == initialPage && edgePages.contains(targetPage)) {
callback.apply {
// verify all events
assertThat("Events should start with a state change to DRAGGING",
draggingIx, equalTo(0))
assertThat("Last event should be a state change to IDLE",
idleIx, equalTo(lastIx))
assertThat("All events but the state changes to DRAGGING and IDLE" +
" should be scroll events",
scrollEventCount, equalTo(eventCount - 2))
// dive into scroll events
scrollEvents.forEach {
assertThat("All scroll events should report page $targetPage",
it.position, equalTo(targetPage))
assertThat("All scroll events should report an offset of 0f",
it.positionOffset, equalTo(0f))
assertThat("All scroll events should report an offset of 0px",
it.positionOffsetPixels, equalTo(0))
}
}
}
viewPager.unregisterOnPageChangeCallback(callback)
}
}
}
/*
0 -> 1 (peek) -> 0
onPageScrollStateChanged,1
onPageScrolled,0,0.001852,2
onPageScrolled,0,0.018519,20
onPageScrolled,0,0.032407,35
onPageScrolled,0,0.043519,47
onPageScrollStateChanged,2
onPageScrolled,0,0.045370,49
onPageScrolled,0,0.029630,32
onPageScrolled,0,0.017593,19
onPageScrolled,0,0.010185,11
onPageScrolled,0,0.005556,6
onPageScrolled,0,0.002778,3
onPageScrolled,0,0.000000,0
onPageScrollStateChanged,0
*/
@Test
fun test_peekOnAdjacentPage_next() {
// given
setUpTest(config.orientation).apply {
setAdapterSync(viewAdapterProvider(stringSequence(3)))
val callback = viewPager.addNewRecordingCallback()
val latch = viewPager.addWaitForScrolledLatch(0)
// when
peekForward()
latch.await(5, SECONDS)
// then
callback.apply {
// verify all events
assertThat("There should be exactly 1 dragging event",
stateEvents(SCROLL_STATE_DRAGGING).size, equalTo(1))
assertThat("There should be exactly 1 settling event",
stateEvents(SCROLL_STATE_SETTLING).size, equalTo(1))
assertThat("There should be exactly 1 idle event",
stateEvents(SCROLL_STATE_IDLE).size, equalTo(1))
assertThat("Events should start with a state change to DRAGGING",
draggingIx, equalTo(0))
assertThat("The settling event should be fired between the first and the last" +
" scroll event",
settlingIx, isBetweenInEx(firstScrolledIx + 1, lastScrolledIx))
assertThat("The idle event should be the last global event",
idleIx, equalTo(lastIx))
assertThat("All events other then the state changes should be scroll events",
scrollEventCount, equalTo(eventCount - 3))
// dive into scroll events
scrollEvents.assertPositionSorted(SortOrder.DESC)
scrollEventsBeforeSettling.assertOffsetSorted(SortOrder.ASC)
assertThat(scrollEvents, // sanity check
equalTo(scrollEventsBeforeSettling + scrollEventsAfterSettling))
scrollEvents.assertValueSanity(0, 0, viewPager.pageSize)
scrollEvents.assertLastCorrect(0)
}
viewPager.unregisterOnPageChangeCallback(callback)
}
}
/*
1 -> 0 (peek) -> 1
onPageScrollStateChanged,1
onPageScrolled,0,0.997222,1077
onPageScrolled,0,0.969444,1047
onPageScrolled,0,0.953704,1030
onPageScrolled,0,0.942593,1018
onPageScrollStateChanged,2
onPageScrolled,0,0.939815,1015
onPageScrolled,0,0.975926,1054
onPageScrolled,0,0.992593,1072
onPageScrolled,0,0.999074,1079
onPageScrolled,1,0.000000,0
onPageScrollStateChanged,0
*/
@Test
fun test_peekOnAdjacentPage_previous() {
// given
setUpTest(config.orientation).apply {
setAdapterSync(viewAdapterProvider(stringSequence(3)))
viewPager.setCurrentItemSync(2, false, 1, SECONDS)
// set up test callbacks
val callback = viewPager.addNewRecordingCallback()
val latch = viewPager.addWaitForScrolledLatch(2)
// when
peekBackward()
latch.await(5, SECONDS)
// then
callback.apply {
// verify all events
assertThat("There should be exactly 1 dragging event",
stateEvents(SCROLL_STATE_DRAGGING).size, equalTo(1))
assertThat("There should be exactly 1 settling event",
stateEvents(SCROLL_STATE_SETTLING).size, equalTo(1))
assertThat("There should be exactly 1 idle event",
stateEvents(SCROLL_STATE_IDLE).size, equalTo(1))
assertThat("Events should start with a state change to DRAGGING",
draggingIx, equalTo(0))
assertThat("The settling event should be fired between the first and the last " +
"scroll event",
settlingIx, isBetweenInEx(firstScrolledIx + 1, lastScrolledIx))
assertThat("The idle event should be the last global event",
idleIx, equalTo(lastIx))
assertThat("All events other then the state changes should be scroll events",
scrollEventCount, equalTo(eventCount - 3))
// dive into scroll events
scrollEvents.assertPositionSorted(SortOrder.ASC)
scrollEventsBeforeSettling.assertOffsetSorted(SortOrder.DESC)
scrollEventsAfterSettling.assertOffsetSorted(SortOrder.ASC)
assertThat(scrollEvents, // sanity check
equalTo(scrollEventsBeforeSettling + scrollEventsAfterSettling))
scrollEvents.assertValueSanity(1, 2, viewPager.pageSize)
scrollEvents.dropLast(1).assertValueSanity(1, 1, viewPager.pageSize)
scrollEvents.assertLastCorrect(2)
}
viewPager.unregisterOnPageChangeCallback(callback)
}
}
/*
Sample log to guide the test
0 -> 2
onPageScrollStateChanged,2
onPageSelected,2
onPageScrolled,0,0.192593,208
onPageScrolled,1,0.287963,310
onPageScrolled,1,0.774074,836
onPageScrolled,1,0.949074,1025
onPageScrolled,1,0.994444,1074
onPageScrolled,2,0.000000,0
onPageScrollStateChanged,0
2 -> 2
// nothing
2 -> 0
onPageScrollStateChanged,2
onPageSelected,0
onPageScrolled,0,0.887037,958
onPageScrolled,0,0.298148,322
onPageScrolled,0,0.071296,77
onPageScrolled,0,0.010185,11
onPageScrolled,0,0.000000,0
onPageScrollStateChanged,0
*/
@Test
fun test_selectItemProgrammatically_smoothScroll() {
// given
setUpTest(config.orientation).apply {
setAdapterSync(viewAdapterProvider(stringSequence(1000)))
// when
listOf(6, 5, 6, 3, 10, 0, 0, 999, 999, 0).forEach { targetPage ->
val currentPage = viewPager.currentItem
val callback = viewPager.addNewRecordingCallback()
viewPager.setCurrentItemSync(targetPage, true, 2, SECONDS)
// then
val pageIxDelta = targetPage - currentPage
callback.apply {
when (pageIxDelta) {
0 -> assertThat(eventCount, equalTo(0))
else -> {
// verify all events
assertThat(settlingIx, equalTo(0))
assertThat(pageSelectedIx(targetPage), equalTo(1))
assertThat(idleIx, equalTo(lastIx))
assertThat(scrollEventCount, equalTo(eventCount - 3))
// dive into scroll events
val sortOrder = if (pageIxDelta > 0) SortOrder.ASC else SortOrder.DESC
scrollEvents.assertPositionSorted(sortOrder)
scrollEvents.assertOffsetSorted(sortOrder)
scrollEvents.assertValueSanity(currentPage, targetPage,
viewPager.pageSize)
scrollEvents.assertLastCorrect(targetPage)
scrollEvents.assertMaxShownPages()
}
}
}
viewPager.unregisterOnPageChangeCallback(callback)
}
}
}
@Test
fun test_multiplePageChanges() {
// given
setUpTest(config.orientation).apply {
setAdapterSync(viewAdapterProvider(stringSequence(10)))
val targetPages = listOf(4, 9)
val callback = viewPager.addNewRecordingCallback()
val latch = viewPager.addWaitForScrolledLatch(targetPages.last(), true)
// when
runOnUiThreadSync {
targetPages.forEach {
viewPager.setCurrentItem(it, true)
}
}
latch.await(2, SECONDS)
// then
callback.apply {
val targetPage = targetPages.last()
assertThat(settlingIx, equalTo(0))
assertThat(viewPager.currentItem, equalTo(targetPage))
assertThat(viewPager.currentCompletelyVisibleItem, equalTo(targetPage))
assertAllPagesSelected(targetPages)
assertScrollsAreBetweenSelectedPages()
}
viewPager.unregisterOnPageChangeCallback(callback)
}
}
/**
* Tests the case where setCurrentItem(x, false) is called while the smooth scroll from
* setCurrentItem(x, true) is not yet finished.
*
* Sample log to guide te test:
*
* 0 -> 4 (smooth) -> 4 (not smooth)
* >> setCurrentItem(4, true);
* onPageScrollStateChanged(2)
* onPageSelected(4)
* onPageScrolled(1, 0.000000, 0)
* onPageScrolled(1, 0.271084, 270)
* onPageScrolled(1, 0.557229, 555)
* onPageScrolled(1, 0.843373, 840)
* onPageScrolled(2, 0.129518, 129)
* >> setCurrentItem(4, false);
* onPageScrolled(4, 0.000000, 0)
* onPageScrollStateChanged(0)
*/
@Test
fun test_noSmoothScroll_after_smoothScroll() {
// given
setUpTest(config.orientation).apply {
setAdapterSync(viewAdapterProvider(stringSequence(6)))
val targetPage = 4
val marker = 1
val callback = viewPager.addNewRecordingCallback()
val scrollLatch = viewPager.addWaitForDistanceToTarget(targetPage, 2f)
val idleLatch = viewPager.addWaitForIdleLatch()
// when
runOnUiThreadSync { viewPager.setCurrentItem(targetPage, true) }
scrollLatch.await(2, SECONDS)
runOnUiThreadSync {
viewPager.setCurrentItem(targetPage, false)
callback.markEvent(marker)
}
idleLatch.await(2, SECONDS)
// then
callback.apply {
assertThat(selectEvents.map { it.position }, equalTo(listOf(targetPage)))
assertThat(
stateEvents.map { it.state },
equalTo(listOf(SCROLL_STATE_SETTLING, SCROLL_STATE_IDLE))
)
assertTargetReachedAfterMarker(targetPage, marker)
}
}
}
/**
* Example trace:
*
* 0 -> 4 (smooth)
* >> viewPager.setCurrentItem(4, true)
* onPageScrollStateChanged(2)
* onPageSelected(4)
* onPageScrolled(1, 0.000000, 0)
* >> config change
* onPageScrolled(4, 0.000000, 0)
*/
@Test
fun test_configChangeDuringStartOfFarSmoothScroll() {
test_configChangeDuringFarSmoothScroll(4) {
// no delay
}
}
/**
* Example trace:
*
* 0 -> 4 (smooth)
* >> viewPager.setCurrentItem(4, true)
* onPageScrollStateChanged(2)
* onPageSelected(4)
* onPageScrolled(1, 0.000000, 0)
* onPageScrolled(1, 0.254016, 253)
* onPageScrolled(1, 0.641566, 639)
* onPageScrolled(2, 0.315261, 314)
* >> config change
* onPageScrolled(4, 0.000000, 0)
*/
@Test
fun test_configChangeDuringMiddleOfFarSmoothScroll() {
val targetPage = 4
test_configChangeDuringFarSmoothScroll(targetPage) { viewPager ->
// let it scroll until we're 2 pages away from the target
viewPager.addWaitForDistanceToTarget(targetPage, 2f).await(2, SECONDS)
}
}
/**
* Example trace:
*
* 0 -> 4 (smooth)
* >> viewPager.setCurrentItem(4, true)
* onPageScrollStateChanged(2)
* onPageSelected(4)
* onPageScrolled(1, 0.000000, 0)
* onPageScrolled(1, 0.371486, 370)
* onPageScrolled(1, 0.557229, 555)
* onPageScrolled(2, 0.180723, 180)
* onPageScrolled(2, 0.574297, 572)
* onPageScrolled(2, 0.903614, 900)
* onPageScrolled(3, 0.218875, 218)
* onPageScrolled(3, 0.437751, 436)
* onPageScrolled(3, 0.655622, 653)
* onPageScrolled(3, 0.803213, 800)
* onPageScrolled(3, 0.911647, 908)
* onPageScrolled(3, 0.978916, 975)
* onPageScrolled(4, 0.000000, 0)
* onPageScrollStateChanged(0)
* >> config change
* onPageScrolled(4, 0.000000, 0)
*/
@Test
fun test_configChangeAfterFarSmoothScroll() {
test_configChangeDuringFarSmoothScroll(4) { viewPager ->
// wait until it is finished
viewPager.addWaitForIdleLatch().await(2, SECONDS)
}
}
/**
* Tests what happens when a config change happens during a smooth scroll to any page more then
* 3 pages further. After the config change, the smooth scroll should be interrupted and the
* view pager should instantly skip to the target page instead.
*
* The configuration change is triggered after the delay callback has executed. Thus, the delay
* callback controls when the config change happens by the time it takes to execute.
*
* @param delayCallback The callback that determines when the configuration change is triggered
*/
private fun test_configChangeDuringFarSmoothScroll(
targetPage: Int,
delayCallback: (ViewPager2) -> Unit
) {
// given
assertThat(targetPage, greaterThanOrEqualTo(4))
setUpTest(config.orientation).apply {
val adapterProvider = viewAdapterProvider(stringSequence(5))
setAdapterSync(adapterProvider)
val marker = 1
val callback = viewPager.addNewRecordingCallback()
// when
runOnUiThreadSync { viewPager.setCurrentItem(targetPage, true) }
delayCallback(viewPager)
recreateActivity(adapterProvider) { newViewPager ->
// mark the config change in the callback
callback.markEvent(marker)
// viewPager is recreated, so need to reattach callback
newViewPager.registerOnPageChangeCallback(callback)
}
// wait until we're at the target page. can take a while on stuttering devices.
// viewPager may have fired all events already, so poll the visible page instead
viewPager.waitUntilSnappedOnTargetByPolling(targetPage)
// then
callback.apply {
assertThat("viewPager.getCurrentItem() does not return the target page",
viewPager.currentItem, equalTo(targetPage))
assertThat("Currently shown page is not the target page",
viewPager.currentCompletelyVisibleItem, equalTo(targetPage))
assertThat("First overall event is not a SETTLING event",
settlingIx, equalTo(0))
assertThat("Number of onPageSelected events is not 2",
selectEvents.count(), equalTo(2))
assertThat("First onPageSelected event is not the second overall event",
pageSelectedIx(targetPage), equalTo(1))
assertThat("Unexpected events were fired after the config change",
eventsAfter(marker), equalTo(listOf(
OnPageSelectedEvent(targetPage),
OnPageScrolledEvent(targetPage, 0f, 0)
)))
}
}
}
/*
0 -> 0
// nothing
0 -> 2
onPageSelected,2
onPageScrolled,2,0.000000,0
2 -> 2
// nothing
2 -> 0
onPageSelected,0
onPageScrolled,0,0.000000,0
*/
@Test
fun test_selectItemProgrammatically_noSmoothScroll() {
// given
setUpTest(config.orientation).apply {
setAdapterSync(viewAdapterProvider(stringSequence(3)))
// when
listOf(2, 2, 0, 0, 1, 2, 1, 0).forEach { targetPage ->
val currentPage = viewPager.currentItem
val callback = viewPager.addNewRecordingCallback()
viewPager.setCurrentItemSync(targetPage, false, 1, SECONDS)
// then
val pageIxDelta = targetPage - currentPage
callback.apply {
when (pageIxDelta) {
0 -> assertThat(eventCount, equalTo(0))
else -> {
assertThat(eventCount, equalTo(2))
assertThat(pageSelectedIx(targetPage), equalTo(0))
assertThat(scrollEvents.last(), equalTo(
OnPageScrolledEvent(targetPage, 0f, 0)))
}
}
}
viewPager.unregisterOnPageChangeCallback(callback)
}
}
}
@Test
fun test_swipeReleaseSwipeBack() {
// given
val test = setUpTest(config.orientation)
test.setAdapterSync(viewAdapterProvider(stringSequence(3)))
val currentPage = test.viewPager.currentItem
val halfPage = test.viewPager.pageSize / 2f
val pageSwiper = PageSwiperManual(test.viewPager)
var recorder = test.viewPager.addNewRecordingCallback()
val vc = ViewConfiguration.get(test.viewPager.context)
val touchSlop = vc.scaledPagingTouchSlop
// when
tryNTimes(3, resetBlock = {
test.resetViewPagerTo(currentPage)
test.viewPager.unregisterOnPageChangeCallback(recorder)
recorder = test.viewPager.addNewRecordingCallback()
}) {
val settleLatch = test.viewPager.addWaitForStateLatch(SCROLL_STATE_SETTLING)
val idleLatch = test.viewPager.addWaitForIdleLatch()
// Swipe towards next page
pageSwiper.swipeForward(halfPage + 2 * touchSlop, AccelerateInterpolator())
settleLatch.await(2, SECONDS)
var scrollLatch: CountDownLatch? = null
test.runOnUiThreadSync {
scrollLatch = test.viewPager.addWaitForFirstScrollEventLatch()
}
scrollLatch!!.await(2, SECONDS)
// now catch the settling view pager and swipe back
pageSwiper.swipeBackward(halfPage, AccelerateInterpolator())
idleLatch.await(2, SECONDS)
if (!recorder.wasSettleInterrupted) {
throw RetryException("Settling phase of first swipe was not interrupted in time")
}
}
// then:
// 1) We're at the right page
assertThat(test.viewPager.currentItem, equalTo(0))
assertThat(test.viewPager.currentCompletelyVisibleItem, equalTo(0))
// 2) State sequence was DRAGGING -> SETTLING -> DRAGGING -> SETTLING -> IDLE
assertThat(
recorder.stateEvents.map { it.state },
equalTo(listOf(SCROLL_STATE_DRAGGING, SCROLL_STATE_SETTLING,
SCROLL_STATE_DRAGGING, SCROLL_STATE_SETTLING, SCROLL_STATE_IDLE))
)
// 3) Page selected sequence was select(1) -> select(0)
assertThat(
recorder.selectEvents.map { it.position },
equalTo(listOf(1, 0))
)
val idle = OnPageScrollStateChangedEvent(SCROLL_STATE_IDLE)
val dragging = OnPageScrollStateChangedEvent(SCROLL_STATE_DRAGGING)
val settling = OnPageScrollStateChangedEvent(SCROLL_STATE_SETTLING)
// 4) Scroll events during the first swipe were ascending
recorder.allEvents
.assertScrollEventsBetweenEventsSorted(dragging, dragging, SortOrder.ASC)
// 5) Scroll events during the second swipe were descending
recorder.allEvents.dropWhile { it != settling }
.assertScrollEventsBetweenEventsSorted(dragging, idle, SortOrder.DESC)
}
/**
* Test behavior when no {@link OnPageChangeCallback}s are attached.
* Introduced after finding a regression.
*/
private fun test_selectItemProgrammatically_noCallback(smoothScroll: Boolean) {
// given
setUpTest(config.orientation).apply {
setAdapterSync(viewAdapterProvider(stringSequence(3)))
// when
listOf(2, 2, 0, 0, 1, 2, 1, 0).forEach { targetPage ->
runOnUiThreadSync { viewPager.setCurrentItem(targetPage, smoothScroll) }
// poll the viewpager on the ui thread
viewPager.waitUntilSnappedOnTargetByPolling(targetPage)
// wait until scroll events have propagated in the system
sleep(100)
// then
assertThat(targetPage, equalTo(viewPager.currentItem))
assertThat(targetPage, equalTo(viewPager.currentCompletelyVisibleItem))
}
}
}
@Test
fun test_selectItemProgrammatically_noSmoothScroll_noCallback() {
test_selectItemProgrammatically_noCallback(false)
}
@Test
fun test_selectItemProgrammatically_smoothScroll_noCallback() {
test_selectItemProgrammatically_noCallback(true)
}
@Test
fun test_scrollStateValuesInSync() {
assertThat(ViewPager2.SCROLL_STATE_IDLE, allOf(equalTo(ViewPager.SCROLL_STATE_IDLE),
equalTo(RecyclerView.SCROLL_STATE_IDLE)))
assertThat(ViewPager2.SCROLL_STATE_DRAGGING, allOf(equalTo(ViewPager.SCROLL_STATE_DRAGGING),
equalTo(RecyclerView.SCROLL_STATE_DRAGGING)))
assertThat(ViewPager2.SCROLL_STATE_SETTLING, allOf(equalTo(ViewPager.SCROLL_STATE_SETTLING),
equalTo(RecyclerView.SCROLL_STATE_SETTLING)))
}
@Test
fun test_getScrollState() {
val test = setUpTest(config.orientation)
test.setAdapterSync(viewAdapterProvider(stringSequence(5)))
// Test SCROLL_STATE_SETTLING
test_getScrollState(test, SCROLL_STATE_SETTLING, 1) {
test.runOnUiThreadSync { test.viewPager.setCurrentItem(1, true) }
}
// Test SCROLL_STATE_DRAGGING (real drag)
test_getScrollState(test, SCROLL_STATE_DRAGGING, 2, true) {
// Perform manual swipe in separate thread, because the SwipeMethod.MANUAL blocks while
// injecting events, and we need to check getScrollState() during the swipe.
newSingleThreadExecutor().execute { test.swipeForward(SwipeMethod.MANUAL) }
}
// Test SCROLL_STATE_DRAGGING (fake drag)
test_getScrollState(test, SCROLL_STATE_DRAGGING, 3, true) {
test.swipeForward(SwipeMethod.FAKE_DRAG)
}
}
private fun test_getScrollState(
test: Context,
@ViewPager2.ScrollState state: Int,
expectedTargetPage: Int,
checkSettling: Boolean = false,
viewPagerAction: () -> Unit
) {
val stateLatch = test.viewPager.addWaitForStateLatch(state)
val settlingLatch = test.viewPager.addWaitForStateLatch(SCROLL_STATE_SETTLING)
val idleLatch = test.viewPager.addWaitForIdleLatch()
viewPagerAction()
// Wait for onScrollStateChanged
assertThat(stateLatch.await(1, SECONDS), equalTo(true))
// Check scrollState
assertThat(test.viewPager.scrollState, equalTo(state))
if (checkSettling) {
assertThat(settlingLatch.await(2, SECONDS), equalTo(true))
}
// Let the animation finish
assertThat(idleLatch.await(2, SECONDS), equalTo(true))
test.assertBasicState(expectedTargetPage)
}
/**
* Expected trace (marker events left out):
*
* >> viewPager.setAdapter(adapter)
* onPageSelected(0)
* onPageScrolled(0, 0.000000, 0)
* >> config change
* onPageSelected(0)
* onPageScrolled(0, 0.000000, 0)
* >> viewPager.setCurrentItem(2, false)
* onPageSelected(2)
* onPageScrolled(2, 0.000000, 0)
* >> config change
* onPageSelected(2)
* onPageScrolled(2, 0.000000, 0)
*/
@Test
fun test_initialEvents() {
// given
val test = setUpTest(config.orientation)
val recorder = test.viewPager.addNewRecordingCallback()
val adapterProvider = viewAdapterProvider(stringSequence(3))
val marker = 1
fun expectedEvents(page: Int): List<Event> {
return listOf(
OnPageSelectedEvent(page) as Event,
OnPageScrolledEvent(page, 0f, 0) as Event
)
}
// when
test.setAdapterSync(adapterProvider)
// then
assertThat(recorder.allEvents, equalTo(expectedEvents(0)))
// when
recorder.reset()
test.recreateActivity(adapterProvider) { newViewPager ->
recorder.markEvent(marker)
// viewPager is recreated, so need to reattach callback
newViewPager.registerOnPageChangeCallback(recorder)
}
// then
assertThat(recorder.allEvents, equalTo(
listOf(MarkerEvent(marker))
.plus(expectedEvents(0)))
)
// given
val targetPage = 2
// when
recorder.reset()
test.viewPager.setCurrentItemSync(targetPage, false, 2, SECONDS)
test.recreateActivity(adapterProvider) { newViewPager ->
recorder.markEvent(marker)
// viewPager is recreated, so need to reattach callback
newViewPager.registerOnPageChangeCallback(recorder)
}
// then
assertThat(recorder.allEvents, equalTo(
expectedEvents(targetPage)
.plus(MarkerEvent(marker))
.plus(expectedEvents(targetPage)))
)
}
private fun test_setCurrentItem_outOfBounds(smoothScroll: Boolean) {
val test = setUpTest(config.orientation)
val n = 3
test.setAdapterSync(viewAdapterProvider(stringSequence(n)))
val adapterCount = test.viewPager.adapter!!.itemCount
listOf(-5, -1, n, n + 1, adapterCount, adapterCount + 1).forEach { targetPage ->
assertThat("Test should only test setCurrentItem for pages out of bounds, " +
"bounds are [0, $n)", targetPage, not(isBetweenInEx(0, n)))
// given
val initialPage = test.viewPager.currentItem
val callback = test.viewPager.addNewRecordingCallback()
val targetBoundary = if (targetPage <= 0) 0 else n - 1
// only expect events when we're going to the boundary on the other side
val expectEvents = initialPage != targetBoundary
// when
test.viewPager.setCurrentItemSync(targetPage, smoothScroll, 2, SECONDS, expectEvents)
// then the viewpager must have scrolled to the respective boundary
assertThat(test.viewPager.currentItem, equalTo(targetBoundary))
if (!expectEvents) {
assertThat(callback.eventCount, equalTo(0))
} else {
// make sure the page select events and scroll events are correct
val pageSize = test.viewPager.pageSize
callback.scrollEvents.assertValueSanity(initialPage, targetBoundary, pageSize)
callback.scrollEvents.assertLastCorrect(targetBoundary)
callback.assertAllPagesSelected(listOf(targetBoundary))
}
test.viewPager.unregisterOnPageChangeCallback(callback)
}
}
@Test
fun test_setCurrentItem_outOfBounds_smoothScroll() {
test_setCurrentItem_outOfBounds(true)
}
@Test
fun test_setCurrentItem_outOfBounds_noSmoothScroll() {
test_setCurrentItem_outOfBounds(false)
}
@Test
fun test_setCurrentItemBeforeRender() {
// given
val viewPager =
ViewPager2(ApplicationProvider.getApplicationContext() as android.content.Context)
val noOpAdapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, type: Int): RecyclerView.ViewHolder {
return object : RecyclerView.ViewHolder(View(parent.context)) {}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {}
override fun getItemCount(): Int = 5
}
viewPager.adapter = noOpAdapter
// when
viewPager.registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {})
viewPager.setCurrentItem(2, true)
viewPager.setCurrentItem(3, false)
// then
// no crash
}
@Test
fun test_setCurrentItem_maxIntItems() {
val test = setUpTest(config.orientation)
test.setAdapterSync { SparseAdapter(Int.MAX_VALUE) }
test.assertBasicState(0)
val testPages = listOf(1073742144, (1 shl 25) + 2, Int.MAX_VALUE - 2)
val recorder = test.viewPager.addNewRecordingCallback()
testPages.forEach { targetPage ->
test.viewPager.setCurrentItemSync(targetPage, false, 2, SECONDS)
test.assertBasicState(targetPage)
test.viewPager.setCurrentItemSync(targetPage + 1, true, 2, SECONDS)
test.assertBasicState(targetPage + 1)
}
recorder.assertScrollsAreBetweenSelectedPages()
recorder.assertAllPagesSelected(testPages.flatMap { listOf(it, it + 1) })
}
@Test
fun test_setCurrentItemWhileScrolling_maxIntItems() {
val test = setUpTest(config.orientation)
test.setAdapterSync { SparseAdapter(Int.MAX_VALUE) }
test.assertBasicState(0)
val targetPage = 1073742144
val recorder = test.viewPager.addNewRecordingCallback()
val distanceLatch = test.viewPager.addWaitForDistanceToTarget(targetPage, 1.5f)
test.runOnUiThreadSync {
test.viewPager.setCurrentItem(targetPage, true)
}
distanceLatch.await(2, SECONDS)
test.viewPager.setCurrentItemSync(targetPage + 1, true, 2, SECONDS)
test.assertBasicState(targetPage + 1)
recorder.assertScrollsAreBetweenSelectedPages()
recorder.assertAllPagesSelected(listOf(targetPage, targetPage + 1))
}
private fun ViewPager2.addNewRecordingCallback(): RecordingCallback {
return RecordingCallback().also { registerOnPageChangeCallback(it) }
}
private fun ViewPager2.waitUntilSnappedOnTargetByPolling(targetPage: Int) {
PollingCheck.waitFor(2000) {
currentCompletelyVisibleItem == targetPage
}
}
private sealed class Event {
data class OnPageScrolledEvent(
val position: Int,
val positionOffset: Float,
val positionOffsetPixels: Int
) : Event()
data class OnPageSelectedEvent(val position: Int) : Event()
data class OnPageScrollStateChangedEvent(val state: Int) : Event()
data class MarkerEvent(val id: Int) : Event()
}
private class RecordingCallback : ViewPager2.OnPageChangeCallback() {
private val events = mutableListOf<Event>()
val allEvents get() = events
val scrollEvents get() = events.mapNotNull { it as? OnPageScrolledEvent }
val scrollEventsBeforeSettling get() = scrollEventsBefore(settlingIx)
val scrollEventsAfterSettling get() = scrollEventsAfter(settlingIx)
val selectEvents get() = events.mapNotNull { it as? OnPageSelectedEvent }
val stateEvents get() = events.mapNotNull { it as? OnPageScrollStateChangedEvent }
val scrollAndSelectEvents get() = events.mapNotNull {
it as? OnPageScrolledEvent ?: it as? OnPageSelectedEvent
}
val eventsAfter: (mark: Int) -> List<Event> = { mark ->
events.dropWhile { (it as? MarkerEvent)?.id != mark }.drop(1)
}
val eventCount get() = events.size
val scrollEventCount get() = scrollEvents.size
val lastIx get() = events.size - 1
val firstScrolledIx get() = events.indexOfFirst { it is OnPageScrolledEvent }
val lastScrolledIx get() = events.indexOfLast { it is OnPageScrolledEvent }
val settlingIx get() = events.indexOf(OnPageScrollStateChangedEvent(SCROLL_STATE_SETTLING))
val draggingIx get() = events.indexOf(OnPageScrollStateChangedEvent(SCROLL_STATE_DRAGGING))
val idleIx get() = events.indexOf(OnPageScrollStateChangedEvent(SCROLL_STATE_IDLE))
val pageSelectedIx: (page: Int) -> Int = { events.indexOf(OnPageSelectedEvent(it)) }
val scrollEventsBefore: (ix: Int) -> List<OnPageScrolledEvent> =
{ scrollEventsBetween(0, it) }
val scrollEventsAfter: (ix: Int) -> List<OnPageScrolledEvent> =
{ scrollEventsBetween(it + 1, events.size) }
val scrollEventsBetween: (fromIx: Int, toIx: Int) -> List<OnPageScrolledEvent> = { a, b ->
events.subList(a, b).mapNotNull { it as? OnPageScrolledEvent }
}
val wasSettleInterrupted: Boolean get() {
val changeToSettlingEvent = OnPageScrollStateChangedEvent(SCROLL_STATE_SETTLING)
val lastScrollEvent = events
.dropWhile { it != changeToSettlingEvent }
.dropWhile { it !is OnPageScrolledEvent }
.takeWhile { it is OnPageScrolledEvent }
.lastOrNull() as? OnPageScrolledEvent
return lastScrollEvent?.let { it.positionOffsetPixels != 0 } ?: false
}
fun stateEvents(state: Int): List<OnPageScrollStateChangedEvent> {
return stateEvents.filter { it.state == state }
}
fun reset() {
events.clear()
}
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int
) {
events.add(OnPageScrolledEvent(position, positionOffset, positionOffsetPixels))
}
override fun onPageSelected(position: Int) {
events.add(OnPageSelectedEvent(position))
}
override fun onPageScrollStateChanged(state: Int) {
events.add(OnPageScrollStateChangedEvent(state))
}
fun markEvent(id: Int) {
events.add(MarkerEvent(id))
}
}
private fun RecordingCallback.assertAllPagesSelected(pages: List<Int>) {
assertThat(listOf(1, 2), not(equalTo(listOf(2, 1))))
assertThat(selectEvents.map { it.position }, equalTo(pages))
}
private fun RecordingCallback.assertScrollsAreBetweenSelectedPages() {
var selectedPage = -1
var prevScrollPosition = 0.0
scrollAndSelectEvents.forEach { event ->
when (event) {
is OnPageSelectedEvent -> selectedPage = event.position
is OnPageScrolledEvent -> {
assertThat(selectedPage, not(equalTo(-1)))
val currScrollPosition = event.position + event.positionOffset.toDouble()
assertThat(currScrollPosition,
isBetweenInInMinMax(prevScrollPosition, selectedPage.toDouble()))
prevScrollPosition = currScrollPosition
}
}
}
}
private fun RecordingCallback.assertTargetReachedAfterMarker(targetPage: Int, marker: Int) {
val finalEvents = eventsAfter(marker)
assertThat(finalEvents.size, greaterThan(0))
assertThat(finalEvents[0], equalTo(OnPageScrolledEvent(targetPage, 0f, 0) as Event))
assertThat(
finalEvents[1],
equalTo(OnPageScrollStateChangedEvent(SCROLL_STATE_IDLE) as Event)
)
}
private fun List<OnPageScrolledEvent>.assertPositionSorted(sortOrder: SortOrder) {
map { it.position }.assertSorted { it * sortOrder.sign }
}
private fun List<OnPageScrolledEvent>.assertLastCorrect(targetPage: Int) {
last().apply {
assertThat(position, equalTo(targetPage))
assertThat(positionOffsetPixels, equalTo(0))
}
}
private fun List<OnPageScrolledEvent>.assertValueSanity(
initialPage: Int,
otherPage: Int,
pageSize: Int
) = forEach {
assertThat(it.position, isBetweenInInMinMax(initialPage, otherPage))
assertThat(it.positionOffset, isBetweenInEx(0f, 1f))
assertThat((it.positionOffset * pageSize).roundToInt(), equalTo(it.positionOffsetPixels))
}
private fun List<Event>.assertScrollEventsBetweenEventsSorted(
first: Event,
second: Event,
sortOrder: SortOrder
) {
slice(first, second)
.mapNotNull { it as? OnPageScrolledEvent }
.assertOffsetSorted(sortOrder)
}
private fun List<OnPageScrolledEvent>.assertOffsetSorted(sortOrder: SortOrder) {
map { it.position + it.positionOffset.toDouble() }.assertSorted { it * sortOrder.sign }
}
private fun List<OnPageScrolledEvent>.assertMaxShownPages() {
assertThat(map { it.position }.distinct().size, isBetweenInIn(0, 4))
}
}
// region Test Suite creation
private fun createTestSet(): List<TestConfig> {
return listOf(ORIENTATION_HORIZONTAL, ORIENTATION_VERTICAL).flatMap { orientation ->
listOf(true, false).map { rtl ->
TestConfig(orientation, rtl)
}
}
}
// endregion