Add fake dragging to ViewPager2
Adds fake dragging functionality to ViewPager2, similar to the fake
dragging functionality in ViewPager. Tests can be found in FakeDragTest
and a sample in FakeDragActivity in the testapp.
Bug: 122655918
Test: ./gradlew viewpager2:connectedCheck
Change-Id: I0363b825b23e9e2de2aad9b2d338289c51e5de9d
diff --git a/viewpager2/api/1.0.0-alpha03.txt b/viewpager2/api/1.0.0-alpha03.txt
index 5607352..4b1a6aa 100644
--- a/viewpager2/api/1.0.0-alpha03.txt
+++ b/viewpager2/api/1.0.0-alpha03.txt
@@ -32,9 +32,14 @@
ctor public ViewPager2(android.content.Context, android.util.AttributeSet?);
ctor public ViewPager2(android.content.Context, android.util.AttributeSet?, int);
ctor @RequiresApi(21) public ViewPager2(android.content.Context, android.util.AttributeSet?, int, int);
+ method public boolean beginFakeDrag();
+ method public boolean endFakeDrag();
+ method public boolean fakeDragBy(float);
method public androidx.recyclerview.widget.RecyclerView.Adapter? getAdapter();
method public int getCurrentItem();
method @androidx.viewpager2.widget.ViewPager2.Orientation public int getOrientation();
+ method @androidx.viewpager2.widget.ViewPager2.ScrollState public int getScrollState();
+ method public boolean isFakeDragging();
method public boolean isUserInputEnabled();
method public void registerOnPageChangeCallback(androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback);
method public void setAdapter(androidx.recyclerview.widget.RecyclerView.Adapter?);
diff --git a/viewpager2/api/current.txt b/viewpager2/api/current.txt
index 5607352..4b1a6aa 100644
--- a/viewpager2/api/current.txt
+++ b/viewpager2/api/current.txt
@@ -32,9 +32,14 @@
ctor public ViewPager2(android.content.Context, android.util.AttributeSet?);
ctor public ViewPager2(android.content.Context, android.util.AttributeSet?, int);
ctor @RequiresApi(21) public ViewPager2(android.content.Context, android.util.AttributeSet?, int, int);
+ method public boolean beginFakeDrag();
+ method public boolean endFakeDrag();
+ method public boolean fakeDragBy(float);
method public androidx.recyclerview.widget.RecyclerView.Adapter? getAdapter();
method public int getCurrentItem();
method @androidx.viewpager2.widget.ViewPager2.Orientation public int getOrientation();
+ method @androidx.viewpager2.widget.ViewPager2.ScrollState public int getScrollState();
+ method public boolean isFakeDragging();
method public boolean isUserInputEnabled();
method public void registerOnPageChangeCallback(androidx.viewpager2.widget.ViewPager2.OnPageChangeCallback);
method public void setAdapter(androidx.recyclerview.widget.RecyclerView.Adapter?);
diff --git a/viewpager2/integration-tests/testapp/src/androidTest/java/com/example/androidx/viewpager2/BaseTest.kt b/viewpager2/integration-tests/testapp/src/androidTest/java/com/example/androidx/viewpager2/BaseTest.kt
index c6f68d6..ab76cb1 100644
--- a/viewpager2/integration-tests/testapp/src/androidTest/java/com/example/androidx/viewpager2/BaseTest.kt
+++ b/viewpager2/integration-tests/testapp/src/androidTest/java/com/example/androidx/viewpager2/BaseTest.kt
@@ -19,17 +19,24 @@
import android.view.View
import androidx.annotation.LayoutRes
import androidx.fragment.app.FragmentActivity
+import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.Espresso.onIdle
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
+import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.rule.ActivityTestRule
import androidx.viewpager2.widget.ViewPager2
+import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL
+import androidx.viewpager2.widget.ViewPager2.ORIENTATION_VERTICAL
import com.example.androidx.viewpager2.test.ViewPagerIdleWatcher
import com.example.androidx.viewpager2.test.onCurrentPage
import com.example.androidx.viewpager2.test.onViewPager
import com.example.androidx.viewpager2.test.swipeNext
import com.example.androidx.viewpager2.test.swipePrevious
+import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.Matcher
import org.junit.After
import org.junit.Before
@@ -65,6 +72,17 @@
idleWatcher.unregister()
}
+ fun selectOrientation(@ViewPager2.Orientation orientation: Int) {
+ onView(withId(R.id.orientation_spinner)).perform(click())
+ onData(equalTo(
+ when (orientation) {
+ ORIENTATION_HORIZONTAL -> "horizontal"
+ ORIENTATION_VERTICAL -> "vertical"
+ else -> throw IllegalArgumentException("Orientation $orientation doesn't exist")
+ }
+ )).perform(click())
+ }
+
fun swipeToNextPage() {
onViewPager().perform(swipeNext())
idleWatcher.waitForIdle()
diff --git a/viewpager2/integration-tests/testapp/src/androidTest/java/com/example/androidx/viewpager2/FakeDragTest.kt b/viewpager2/integration-tests/testapp/src/androidTest/java/com/example/androidx/viewpager2/FakeDragTest.kt
new file mode 100644
index 0000000..825f04c
--- /dev/null
+++ b/viewpager2/integration-tests/testapp/src/androidTest/java/com/example/androidx/viewpager2/FakeDragTest.kt
@@ -0,0 +1,114 @@
+/*
+ * 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 com.example.androidx.viewpager2
+
+import android.content.res.Configuration.ORIENTATION_LANDSCAPE
+import android.content.res.Configuration.ORIENTATION_PORTRAIT
+import androidx.test.espresso.Espresso.onIdle
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.ViewAction
+import androidx.test.espresso.ViewInteraction
+import androidx.test.espresso.action.ViewActions.swipeDown
+import androidx.test.espresso.action.ViewActions.swipeLeft
+import androidx.test.espresso.action.ViewActions.swipeRight
+import androidx.test.espresso.action.ViewActions.swipeUp
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.filters.LargeTest
+import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation
+import androidx.viewpager2.widget.ViewPager2
+import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL
+import androidx.viewpager2.widget.ViewPager2.ORIENTATION_VERTICAL
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+
+@LargeTest
+@RunWith(Parameterized::class)
+class FakeDragTest(private val config: TestConfig) :
+ BaseTest<FakeDragActivity>(FakeDragActivity::class.java) {
+ data class TestConfig(
+ @ViewPager2.Orientation val orientation: Int
+ )
+
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{0}")
+ fun spec(): List<TestConfig> {
+ return listOf(ORIENTATION_HORIZONTAL, ORIENTATION_VERTICAL).map { orientation ->
+ TestConfig(orientation)
+ }
+ }
+ }
+
+ private val twoOfSpadesPage = "2\n♣"
+ private val threeOfSpadesPage = "3\n♣"
+
+ override val layoutId get() = R.id.viewPager
+
+ private val phoneOrientation
+ get() = getInstrumentation().targetContext.resources.configuration.orientation
+
+ @Before
+ override fun setUp() {
+ super.setUp()
+ selectOrientation(config.orientation)
+ }
+
+ @Test
+ fun testFakeDragging() {
+ // test if ViewPager2 goes to the next page when fake dragging
+ fakeDragForward()
+ verifyCurrentPage(threeOfSpadesPage)
+
+ // test if ViewPager2 goes back to the first page when fake dragging the other way
+ fakeDragBackward()
+ verifyCurrentPage(twoOfSpadesPage)
+ }
+
+ private fun fakeDragForward() {
+ onTouchpad().perform(swipeNext())
+ idleWatcher.waitForIdle()
+ onIdle()
+ }
+
+ private fun fakeDragBackward() {
+ onTouchpad().perform(swipePrevious())
+ idleWatcher.waitForIdle()
+ onIdle()
+ }
+
+ private fun onTouchpad(): ViewInteraction {
+ return onView(withId(R.id.touchpad))
+ }
+
+ private fun swipeNext(): ViewAction {
+ return when (phoneOrientation) {
+ ORIENTATION_LANDSCAPE -> swipeDown()
+ ORIENTATION_PORTRAIT -> swipeLeft()
+ else -> throw RuntimeException("Orientation should be landscape or portrait")
+ }
+ }
+
+ private fun swipePrevious(): ViewAction {
+ return when (phoneOrientation) {
+ ORIENTATION_LANDSCAPE -> swipeUp()
+ ORIENTATION_PORTRAIT -> swipeRight()
+ else -> throw RuntimeException("Orientation should be landscape or portrait")
+ }
+ }
+}
diff --git a/viewpager2/integration-tests/testapp/src/androidTest/java/com/example/androidx/viewpager2/ViewPagerBaseTest.kt b/viewpager2/integration-tests/testapp/src/androidTest/java/com/example/androidx/viewpager2/ViewPagerBaseTest.kt
index 38cf9f2..3fdb56f 100644
--- a/viewpager2/integration-tests/testapp/src/androidTest/java/com/example/androidx/viewpager2/ViewPagerBaseTest.kt
+++ b/viewpager2/integration-tests/testapp/src/androidTest/java/com/example/androidx/viewpager2/ViewPagerBaseTest.kt
@@ -16,7 +16,6 @@
package com.example.androidx.viewpager2
-import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.matcher.ViewMatchers.isNotChecked
@@ -26,7 +25,6 @@
import androidx.viewpager2.widget.ViewPager2.ORIENTATION_VERTICAL
import com.example.androidx.viewpager2.test.AnimationVerifier
import org.hamcrest.CoreMatchers.allOf
-import org.hamcrest.CoreMatchers.equalTo
import org.junit.Before
import org.junit.Test
import org.junit.runners.Parameterized
@@ -73,7 +71,7 @@
@Before
override fun setUp() {
super.setUp()
- selectOrientation()
+ selectOrientation(config.orientation)
if (config.animateRotate) check(R.id.rotate_checkbox)
if (config.animateTranslate) check(R.id.translate_checkbox)
if (config.animateScale) check(R.id.scale_checkbox)
@@ -96,17 +94,6 @@
verifyCurrentPage(twoOfSpades)
}
- private fun selectOrientation() {
- onView(withId(R.id.orientation_spinner)).perform(click())
- onData(equalTo(
- when (config.orientation) {
- ORIENTATION_HORIZONTAL -> "horizontal"
- ORIENTATION_VERTICAL -> "vertical"
- else -> "unknown"
- }
- )).perform(click())
- }
-
private fun check(id: Int) {
onView(allOf(withId(id), isNotChecked())).perform(click())
}
diff --git a/viewpager2/integration-tests/testapp/src/main/AndroidManifest.xml b/viewpager2/integration-tests/testapp/src/main/AndroidManifest.xml
index c7c6848..a6fea0b 100644
--- a/viewpager2/integration-tests/testapp/src/main/AndroidManifest.xml
+++ b/viewpager2/integration-tests/testapp/src/main/AndroidManifest.xml
@@ -59,6 +59,13 @@
</intent-filter>
</activity>
+ <activity android:name=".FakeDragActivity">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN"/>
+ <category android:name="android.intent.category.SAMPLE_CODE"/>
+ </intent-filter>
+ </activity>
+
<activity android:name=".BrowseActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
diff --git a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/BaseCardActivity.kt b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/BaseCardActivity.kt
index f90425b..291cdda3f 100644
--- a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/BaseCardActivity.kt
+++ b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/BaseCardActivity.kt
@@ -17,8 +17,6 @@
package com.example.androidx.viewpager2
import android.os.Bundle
-import android.view.View
-import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.CheckBox
@@ -36,20 +34,17 @@
*/
abstract class BaseCardActivity : FragmentActivity() {
- lateinit var viewPager: ViewPager2
+ protected lateinit var viewPager: ViewPager2
private lateinit var cardSelector: Spinner
private lateinit var smoothScrollCheckBox: CheckBox
private lateinit var rotateCheckBox: CheckBox
private lateinit var translateCheckBox: CheckBox
private lateinit var scaleCheckBox: CheckBox
private lateinit var gotoPage: Button
- private lateinit var orientationSelector: Spinner
- private lateinit var disableUserInputCheckBox: CheckBox
- private var orientation: Int = ORIENTATION_HORIZONTAL
- private val translateX get() = orientation == ORIENTATION_VERTICAL &&
+ private val translateX get() = viewPager.orientation == ORIENTATION_VERTICAL &&
translateCheckBox.isChecked
- private val translateY get() = orientation == ORIENTATION_HORIZONTAL &&
+ private val translateY get() = viewPager.orientation == ORIENTATION_HORIZONTAL &&
translateCheckBox.isChecked
protected open val layoutId: Int = R.layout.activity_no_tablayout
@@ -76,8 +71,6 @@
setContentView(layoutId)
viewPager = findViewById(R.id.view_pager)
- orientationSelector = findViewById(R.id.orientation_spinner)
- disableUserInputCheckBox = findViewById(R.id.disable_user_input_checkbox)
cardSelector = findViewById(R.id.card_spinner)
smoothScrollCheckBox = findViewById(R.id.smooth_scroll_checkbox)
rotateCheckBox = findViewById(R.id.rotate_checkbox)
@@ -85,32 +78,12 @@
scaleCheckBox = findViewById(R.id.scale_checkbox)
gotoPage = findViewById(R.id.jump_button)
- disableUserInputCheckBox.setOnCheckedChangeListener { _, isDisabled ->
- viewPager.isUserInputEnabled = !isDisabled
- }
-
- orientationSelector.adapter = createOrientationAdapter()
+ UserInputController(viewPager, findViewById(R.id.disable_user_input_checkbox)).setup()
+ OrientationController(viewPager, findViewById(R.id.orientation_spinner)).setup()
cardSelector.adapter = createCardAdapter()
viewPager.setPageTransformer(mAnimator)
- orientationSelector.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
- override fun onItemSelected(
- parent: AdapterView<*>,
- view: View?,
- position: Int,
- id: Long
- ) {
- when (parent.selectedItem.toString()) {
- HORIZONTAL -> orientation = ORIENTATION_HORIZONTAL
- VERTICAL -> orientation = ORIENTATION_VERTICAL
- }
- viewPager.orientation = orientation
- }
-
- override fun onNothingSelected(adapterView: AdapterView<*>) {}
- }
-
gotoPage.setOnClickListener {
val card = cardSelector.selectedItemPosition
val smoothScroll = smoothScrollCheckBox.isChecked
@@ -123,17 +96,4 @@
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
return adapter
}
-
- private fun createOrientationAdapter(): SpinnerAdapter {
- val adapter = ArrayAdapter(this,
- android.R.layout.simple_spinner_item, arrayOf(HORIZONTAL, VERTICAL))
- adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
- return adapter
- }
-
- companion object {
- val cards = Card.DECK
- private const val HORIZONTAL = "horizontal"
- private const val VERTICAL = "vertical"
- }
}
diff --git a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/BrowseActivity.kt b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/BrowseActivity.kt
index ca544d3..b98a912 100644
--- a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/BrowseActivity.kt
+++ b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/BrowseActivity.kt
@@ -49,6 +49,8 @@
"intent" to activityToIntent(MutableCollectionFragmentActivity::class.java.name)))
myData.add(mapOf("title" to "ViewPager2 with a TabLayout (Views)",
"intent" to activityToIntent(CardViewTabLayoutActivity::class.java.name)))
+ myData.add(mapOf("title" to "ViewPager2 with Fake Dragging",
+ "intent" to activityToIntent(FakeDragActivity::class.java.name)))
return myData
}
diff --git a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardFragmentActivity.kt b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardFragmentActivity.kt
index 4049be5..794104d 100644
--- a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardFragmentActivity.kt
+++ b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardFragmentActivity.kt
@@ -40,11 +40,11 @@
viewPager.adapter = object : FragmentStateAdapter(supportFragmentManager) {
override fun getItem(position: Int): Fragment {
- return CardFragment.create(cards[position])
+ return CardFragment.create(Card.DECK[position])
}
override fun getItemCount(): Int {
- return cards.size
+ return Card.DECK.size
}
}
}
diff --git a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardViewActivity.kt b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardViewActivity.kt
index 71fc7d3..2508f92 100644
--- a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardViewActivity.kt
+++ b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardViewActivity.kt
@@ -17,12 +17,8 @@
package com.example.androidx.viewpager2
import android.os.Bundle
-import android.view.ViewGroup
-import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2
-
-import com.example.androidx.viewpager2.cards.Card
-import com.example.androidx.viewpager2.cards.CardView
+import com.example.androidx.viewpager2.cards.CardViewAdapter
/**
* Shows how to use [ViewPager2.setAdapter] with Views.
@@ -30,34 +26,8 @@
* @see CardFragmentActivity for an example of using {@link ViewPager2} with Fragments.
*/
open class CardViewActivity : BaseCardActivity() {
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
-
- viewPager.adapter = object : RecyclerView.Adapter<CardViewHolder>() {
- override fun onCreateViewHolder(
- parent: ViewGroup,
- viewType: Int
- ): CardViewHolder {
- return CardViewHolder(CardView(layoutInflater, parent))
- }
-
- override fun onBindViewHolder(holder: CardViewHolder, position: Int) {
- holder.bind(cards[position])
- }
-
- override fun getItemCount(): Int {
- return cards.size
- }
- }
- }
-
- class CardViewHolder internal constructor(
- private val cardView: CardView
- ) : RecyclerView.ViewHolder(cardView.view) {
-
- internal fun bind(card: Card) {
- cardView.bind(card)
- }
+ viewPager.adapter = CardViewAdapter()
}
}
diff --git a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardViewTabLayoutActivity.kt b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardViewTabLayoutActivity.kt
index a34b936..75d3a82 100644
--- a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardViewTabLayoutActivity.kt
+++ b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/CardViewTabLayoutActivity.kt
@@ -17,6 +17,7 @@
package com.example.androidx.viewpager2
import android.os.Bundle
+import com.example.androidx.viewpager2.cards.Card
import com.google.android.material.tabs.TabLayout
class CardViewTabLayoutActivity : CardViewActivity() {
@@ -30,7 +31,7 @@
tabLayout = findViewById(R.id.tabs)
TabLayoutMediator(tabLayout, viewPager) { tab, position ->
- tab.text = cards[position].toString()
+ tab.text = Card.DECK[position].toString()
}.attach()
}
}
diff --git a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/FakeDragActivity.kt b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/FakeDragActivity.kt
new file mode 100644
index 0000000..a1b27b5
--- /dev/null
+++ b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/FakeDragActivity.kt
@@ -0,0 +1,73 @@
+/*
+ * 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 com.example.androidx.viewpager2
+
+import android.content.res.Configuration
+import android.os.Bundle
+import android.view.MotionEvent
+import android.view.View
+import androidx.fragment.app.FragmentActivity
+import androidx.viewpager2.widget.ViewPager2
+import com.example.androidx.viewpager2.cards.CardViewAdapter
+
+class FakeDragActivity : FragmentActivity() {
+
+ private lateinit var viewPager: ViewPager2
+ private var landscape = false
+ private var lastValue: Float = 0f
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContentView(R.layout.activity_fakedrag)
+ landscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
+
+ viewPager = findViewById(R.id.viewPager)
+ viewPager.adapter = CardViewAdapter()
+ viewPager.isUserInputEnabled = false
+ UserInputController(viewPager, findViewById(R.id.disable_user_input_checkbox)).setup()
+ OrientationController(viewPager, findViewById(R.id.orientation_spinner)).setup()
+
+ findViewById<View>(R.id.touchpad).setOnTouchListener { _, event ->
+ handleOnTouchEvent(event)
+ }
+ }
+
+ private fun getValue(event: MotionEvent): Float {
+ return if (landscape) event.y else event.x
+ }
+
+ private fun handleOnTouchEvent(event: MotionEvent): Boolean {
+ when (event.action) {
+ MotionEvent.ACTION_DOWN -> {
+ lastValue = getValue(event)
+ viewPager.beginFakeDrag()
+ }
+
+ MotionEvent.ACTION_MOVE -> {
+ val value = getValue(event)
+ val delta = value - lastValue
+ viewPager.fakeDragBy(delta)
+ lastValue = value
+ }
+
+ MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
+ viewPager.endFakeDrag()
+ }
+ }
+ return true
+ }
+}
\ No newline at end of file
diff --git a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/OrientationController.kt b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/OrientationController.kt
new file mode 100644
index 0000000..4f7e295
--- /dev/null
+++ b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/OrientationController.kt
@@ -0,0 +1,76 @@
+/*
+ * 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 com.example.androidx.viewpager2
+
+import android.view.View
+import android.widget.AdapterView
+import android.widget.ArrayAdapter
+import android.widget.Spinner
+import androidx.viewpager2.widget.ViewPager2
+
+/**
+ * It configures a spinner to show orientations and sets the orientation of a ViewPager2 when an orientation is selected
+ */
+class OrientationController(private val viewPager: ViewPager2, private val spinner: Spinner) {
+ fun setup() {
+ val orientation = viewPager.orientation
+ val adapter = ArrayAdapter(spinner.context, android.R.layout.simple_spinner_item,
+ arrayOf(HORIZONTAL, VERTICAL))
+ adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
+ spinner.adapter = adapter
+
+ val initialPosition = adapter.getPosition(orientationToString(orientation))
+ if (initialPosition >= 0) {
+ spinner.setSelection(initialPosition)
+ }
+
+ spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
+ override fun onItemSelected(
+ parent: AdapterView<*>,
+ view: View?,
+ position: Int,
+ id: Long
+ ) {
+ viewPager.orientation = stringToOrientation(parent.selectedItem.toString())
+ }
+
+ override fun onNothingSelected(adapterView: AdapterView<*>) {}
+ }
+ }
+
+ private fun orientationToString(@ViewPager2.Orientation orientation: Int): String {
+ return when (orientation) {
+ ViewPager2.ORIENTATION_HORIZONTAL -> HORIZONTAL
+ ViewPager2.ORIENTATION_VERTICAL -> VERTICAL
+ else -> throw IllegalArgumentException("Orientation $orientation doesn't exist")
+ }
+ }
+
+ @ViewPager2.Orientation
+ internal fun stringToOrientation(string: String): Int {
+ return when (string) {
+ HORIZONTAL -> ViewPager2.ORIENTATION_HORIZONTAL
+ VERTICAL -> ViewPager2.ORIENTATION_VERTICAL
+ else -> throw IllegalArgumentException("Orientation $string doesn't exist")
+ }
+ }
+
+ companion object {
+ private const val HORIZONTAL = "horizontal"
+ private const val VERTICAL = "vertical"
+ }
+}
\ No newline at end of file
diff --git a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/UserInputController.kt b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/UserInputController.kt
new file mode 100644
index 0000000..c4c1e26
--- /dev/null
+++ b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/UserInputController.kt
@@ -0,0 +1,29 @@
+/*
+ * 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 com.example.androidx.viewpager2
+
+import android.widget.CheckBox
+import androidx.viewpager2.widget.ViewPager2
+
+class UserInputController(private val viewPager: ViewPager2, private val disableBox: CheckBox) {
+ fun setup() {
+ disableBox.isChecked = !viewPager.isUserInputEnabled
+ disableBox.setOnCheckedChangeListener { _, isDisabled ->
+ viewPager.isUserInputEnabled = !isDisabled
+ }
+ }
+}
\ No newline at end of file
diff --git a/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/cards/CardViewAdapter.kt b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/cards/CardViewAdapter.kt
new file mode 100644
index 0000000..90739f0
--- /dev/null
+++ b/viewpager2/integration-tests/testapp/src/main/java/com/example/androidx/viewpager2/cards/CardViewAdapter.kt
@@ -0,0 +1,42 @@
+/*
+ * 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 com.example.androidx.viewpager2.cards
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+
+class CardViewAdapter : RecyclerView.Adapter<CardViewHolder>() {
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CardViewHolder {
+ return CardViewHolder(CardView(LayoutInflater.from(parent.context), parent))
+ }
+
+ override fun onBindViewHolder(holder: CardViewHolder, position: Int) {
+ holder.bind(Card.DECK[position])
+ }
+
+ override fun getItemCount(): Int {
+ return Card.DECK.size
+ }
+}
+
+class CardViewHolder internal constructor(private val cardView: CardView) :
+ RecyclerView.ViewHolder(cardView.view) {
+ internal fun bind(card: Card) {
+ cardView.bind(card)
+ }
+}
diff --git a/viewpager2/integration-tests/testapp/src/main/res/layout-land/activity_fakedrag.xml b/viewpager2/integration-tests/testapp/src/main/res/layout-land/activity_fakedrag.xml
new file mode 100644
index 0000000..47b1d78
--- /dev/null
+++ b/viewpager2/integration-tests/testapp/src/main/res/layout-land/activity_fakedrag.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="horizontal"
+ tools:background="#FFFFFF">
+
+ <include layout="@layout/controls_fakedrag" />
+
+ <androidx.viewpager2.widget.ViewPager2
+ android:id="@+id/viewPager"
+ android:layout_width="0dp"
+ android:layout_height="match_parent"
+ android:layout_weight="1" />
+
+</LinearLayout>
diff --git a/viewpager2/integration-tests/testapp/src/main/res/layout-land/controls_fakedrag.xml b/viewpager2/integration-tests/testapp/src/main/res/layout-land/controls_fakedrag.xml
new file mode 100644
index 0000000..0934069
--- /dev/null
+++ b/viewpager2/integration-tests/testapp/src/main/res/layout-land/controls_fakedrag.xml
@@ -0,0 +1,60 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<merge xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <LinearLayout
+ android:layout_width="wrap_content"
+ android:layout_height="match_parent"
+ android:layout_margin="16dp"
+ android:gravity="center_horizontal"
+ android:orientation="vertical">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/label_orientation"
+ android:textAppearance="@android:style/TextAppearance.Medium" />
+
+ <Spinner
+ android:id="@+id/orientation_spinner"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ <CheckBox
+ android:id="@+id/disable_user_input_checkbox"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/disable_user_input"
+ android:textAppearance="@android:style/TextAppearance.Medium" />
+
+ <View
+ android:id="@+id/touchpad"
+ android:layout_width="64dp"
+ android:layout_height="0dp"
+ android:layout_gravity="center_horizontal"
+ android:layout_marginTop="16dp"
+ android:layout_weight="1"
+ android:background="#EBEBEB" />
+
+ </LinearLayout>
+
+ <View
+ android:layout_width="1dp"
+ android:layout_height="match_parent"
+ android:background="#000000" />
+
+</merge>
diff --git a/viewpager2/integration-tests/testapp/src/main/res/layout/activity_fakedrag.xml b/viewpager2/integration-tests/testapp/src/main/res/layout/activity_fakedrag.xml
new file mode 100644
index 0000000..6f943ee
--- /dev/null
+++ b/viewpager2/integration-tests/testapp/src/main/res/layout/activity_fakedrag.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:tools="http://schemas.android.com/tools"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ android:orientation="vertical"
+ tools:background="#FFFFFF">
+
+ <include layout="@layout/controls_fakedrag" />
+
+ <androidx.viewpager2.widget.ViewPager2
+ android:id="@+id/viewPager"
+ android:layout_width="match_parent"
+ android:layout_height="0dp"
+ android:layout_weight="1" />
+
+</LinearLayout>
diff --git a/viewpager2/integration-tests/testapp/src/main/res/layout/controls_fakedrag.xml b/viewpager2/integration-tests/testapp/src/main/res/layout/controls_fakedrag.xml
new file mode 100644
index 0000000..78db760
--- /dev/null
+++ b/viewpager2/integration-tests/testapp/src/main/res/layout/controls_fakedrag.xml
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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.
+-->
+
+<merge xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <LinearLayout
+ android:layout_width="match_parent"
+ android:layout_height="wrap_content"
+ android:layout_marginLeft="16dp"
+ android:layout_marginRight="16dp"
+ android:layout_marginTop="16dp"
+ android:gravity="center_vertical|start"
+ android:orientation="horizontal">
+
+ <TextView
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:text="@string/label_orientation"
+ android:textAppearance="@android:style/TextAppearance.Medium" />
+
+ <Spinner
+ android:id="@+id/orientation_spinner"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content" />
+
+ </LinearLayout>
+
+ <CheckBox
+ android:id="@+id/disable_user_input_checkbox"
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ android:layout_marginStart="16dp"
+ android:layout_marginLeft="16dp"
+ android:text="@string/disable_user_input"
+ android:textAppearance="@android:style/TextAppearance.Medium" />
+
+ <View
+ android:id="@+id/touchpad"
+ android:layout_width="match_parent"
+ android:layout_height="64dp"
+ android:layout_margin="16dp"
+ android:background="#EBEBEB" />
+
+ <View
+ android:layout_width="match_parent"
+ android:layout_height="1dp"
+ android:background="#000000" />
+
+</merge>
diff --git a/viewpager2/src/androidTest/java/androidx/viewpager2/widget/BaseTest.kt b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/BaseTest.kt
index 192bc62..c70085e 100644
--- a/viewpager2/src/androidTest/java/androidx/viewpager2/widget/BaseTest.kt
+++ b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/BaseTest.kt
@@ -45,10 +45,13 @@
import androidx.viewpager2.adapter.FragmentStateAdapter
import androidx.viewpager2.test.R
import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL
+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.FragmentAdapter
import androidx.viewpager2.widget.swipe.PageSwiper
import androidx.viewpager2.widget.swipe.PageSwiperEspresso
+import androidx.viewpager2.widget.swipe.PageSwiperFakeDrag
import androidx.viewpager2.widget.swipe.PageSwiperManual
import androidx.viewpager2.widget.swipe.SelfChecking
import androidx.viewpager2.widget.swipe.TestActivity
@@ -145,7 +148,8 @@
enum class SwipeMethod {
ESPRESSO,
- MANUAL
+ MANUAL,
+ FAKE_DRAG
}
fun swipe(currentPageIx: Int, nextPageIx: Int, method: SwipeMethod = SwipeMethod.ESPRESSO) {
@@ -192,11 +196,9 @@
private fun swiper(method: SwipeMethod = SwipeMethod.ESPRESSO): PageSwiper {
return when (method) {
- SwipeMethod.ESPRESSO -> PageSwiperEspresso(
- viewPager.orientation,
- isRtl
- )
+ SwipeMethod.ESPRESSO -> PageSwiperEspresso(viewPager.orientation, isRtl)
SwipeMethod.MANUAL -> PageSwiperManual(viewPager, isRtl)
+ SwipeMethod.FAKE_DRAG -> PageSwiperFakeDrag(viewPager)
}
}
@@ -281,11 +283,15 @@
}
fun ViewPager2.addWaitForIdleLatch(): CountDownLatch {
+ return addWaitForStateLatch(SCROLL_STATE_IDLE)
+ }
+
+ fun ViewPager2.addWaitForStateLatch(targetState: Int): CountDownLatch {
val latch = CountDownLatch(1)
registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageScrollStateChanged(state: Int) {
- if (state == SCROLL_STATE_IDLE) {
+ if (state == targetState) {
latch.countDown()
post { unregisterOnPageChangeCallback(this) }
}
@@ -333,14 +339,16 @@
/**
* Checks:
* 1. Expected page is the current ViewPager2 page
- * 2. Expected text is displayed
- * 3. Internal activity state is valid (as per activity self-test)
+ * 2. Expected state is SCROLL_STATE_IDLE
+ * 3. Expected text is displayed
+ * 4. Internal activity state is valid (as per activity self-test)
*/
fun Context.assertBasicState(pageIx: Int, value: String = pageIx.toString()) {
assertThat<Int>(
"viewPager.getCurrentItem() should return $pageIx",
viewPager.currentItem, equalTo(pageIx)
)
+ assertThat(viewPager.scrollState, equalTo(SCROLL_STATE_IDLE))
onView(allOf<View>(withId(R.id.text_view), isDisplayed())).check(
matches(withText(value))
)
@@ -490,3 +498,19 @@
get() {
return this == fragmentAdapterProvider
}
+
+fun scrollStateToString(@ViewPager2.ScrollState state: Int): String {
+ return when (state) {
+ SCROLL_STATE_IDLE -> "IDLE"
+ SCROLL_STATE_DRAGGING -> "DRAGGING"
+ SCROLL_STATE_SETTLING -> "SETTLING"
+ else -> throw IllegalArgumentException("Scroll state $state doesn't exist")
+ }
+}
+
+fun scrollStateGlossary(): String {
+ return "Scroll states: " +
+ "$SCROLL_STATE_IDLE=${scrollStateToString(SCROLL_STATE_IDLE)}, " +
+ "$SCROLL_STATE_DRAGGING=${scrollStateToString(SCROLL_STATE_DRAGGING)}, " +
+ "$SCROLL_STATE_SETTLING=${scrollStateToString(SCROLL_STATE_SETTLING)})"
+}
diff --git a/viewpager2/src/androidTest/java/androidx/viewpager2/widget/DragWhileSmoothScrollTest.kt b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/DragWhileSmoothScrollTest.kt
index e4ba599..5d2638c 100644
--- a/viewpager2/src/androidTest/java/androidx/viewpager2/widget/DragWhileSmoothScrollTest.kt
+++ b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/DragWhileSmoothScrollTest.kt
@@ -95,8 +95,7 @@
// and check the result
callback.apply {
assertThat(
- "Unexpected sequence of state changes (0=IDLE, 1=DRAGGING, 2=SETTLING)" +
- dumpEvents(),
+ "Unexpected sequence of state changes:" + dumpEvents(),
stateEvents.map { it.state },
equalTo(
if (expectIdleAfterDrag()) {
@@ -207,7 +206,7 @@
}
fun dumpEvents(): String {
- return events.joinToString("\n- ", "\n- ")
+ return events.joinToString("\n- ", "\n(${scrollStateGlossary()})\n- ")
}
}
}
diff --git a/viewpager2/src/androidTest/java/androidx/viewpager2/widget/FakeDragTest.kt b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/FakeDragTest.kt
new file mode 100644
index 0000000..7b6e5df
--- /dev/null
+++ b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/FakeDragTest.kt
@@ -0,0 +1,503 @@
+/*
+ * 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.graphics.Path
+import android.view.animation.DecelerateInterpolator
+import android.view.animation.Interpolator
+import android.view.animation.LinearInterpolator
+import androidx.core.view.animation.PathInterpolatorCompat
+import androidx.test.filters.LargeTest
+import androidx.viewpager2.LocaleTestUtils
+import androidx.viewpager2.widget.BaseTest.Context.SwipeMethod
+import androidx.viewpager2.widget.FakeDragTest.Event.OnPageScrollStateChangedEvent
+import androidx.viewpager2.widget.FakeDragTest.Event.OnPageScrolledEvent
+import androidx.viewpager2.widget.FakeDragTest.Event.OnPageSelectedEvent
+import androidx.viewpager2.widget.FakeDragTest.TestConfig
+import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL
+import androidx.viewpager2.widget.ViewPager2.ORIENTATION_VERTICAL
+import androidx.viewpager2.widget.swipe.PageSwiperFakeDrag
+import org.hamcrest.CoreMatchers.equalTo
+import org.hamcrest.CoreMatchers.notNullValue
+import org.hamcrest.Matchers.greaterThan
+import org.junit.Assert.assertThat
+import org.junit.Assume.assumeThat
+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.MILLISECONDS
+import java.util.concurrent.TimeUnit.SECONDS
+import kotlin.math.roundToInt
+import androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_DRAGGING as DRAGGING
+import androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_IDLE as IDLE
+import androidx.viewpager2.widget.ViewPager2.SCROLL_STATE_SETTLING as SETTLING
+
+@RunWith(Parameterized::class)
+@LargeTest
+class FakeDragTest(private val config: TestConfig) : BaseTest() {
+ data class TestConfig(
+ @ViewPager2.Orientation val orientation: Int,
+ val rtl: Boolean,
+ val enableUserInput: Boolean
+ )
+
+ companion object {
+ @JvmStatic
+ @Parameterized.Parameters(name = "{0}")
+ fun spec(): List<TestConfig> = createTestSet()
+ }
+
+ private val pageCount = 10
+ private lateinit var test: Context
+ private lateinit var adapterProvider: AdapterProvider
+ private lateinit var fakeDragger: PageSwiperFakeDrag
+
+ override fun setUp() {
+ super.setUp()
+ if (config.rtl) {
+ localeUtil.resetLocale()
+ localeUtil.setLocale(LocaleTestUtils.RTL_LANGUAGE)
+ }
+ adapterProvider = viewAdapterProvider(stringSequence(pageCount))
+ test = setUpTest(config.orientation).also {
+ fakeDragger = PageSwiperFakeDrag(it.viewPager)
+ it.viewPager.isUserInputEnabled = config.enableUserInput
+ it.setAdapterSync(adapterProvider)
+ it.assertBasicState(0)
+ }
+ }
+
+ @Test
+ fun test_flingToNextPage() {
+ basicFakeDragTest(.2f, 100, 1)
+ }
+
+ @Test
+ fun test_peekNextPage() {
+ basicFakeDragTest(.1f, 200, 0, DecelerateInterpolator())
+ }
+
+ @Test
+ fun test_flingCompletelyToNextPage() {
+ basicFakeDragTest(1f, 100, 1)
+ }
+
+ @Test
+ fun test_peekNextAndMoveBack() {
+ // Roughly interpolates like this:
+ // |
+ // 3 | .-.
+ // | / ',
+ // 1 | / '-.___
+ // |/
+ // 0 +--------------
+ // 0 1
+ basicFakeDragTest(.2f, 500, 0, PathInterpolatorCompat.create(Path().also {
+ it.moveTo(0f, 0f)
+ it.cubicTo(.4f, 6f, .5f, 1f, .8f, 1f)
+ it.lineTo(1f, 1f)
+ }))
+ }
+
+ @Test
+ fun test_dragAlmostToNextPageAndFlingBack() {
+ // Roughly interpolates like this:
+ // |
+ // | .-.
+ // 1 | / '
+ // | /
+ // |/
+ // 0 +-------
+ // 0 1
+ basicFakeDragTest(.7f, 400, 0, PathInterpolatorCompat.create(Path().also {
+ it.moveTo(0f, 0f)
+ it.cubicTo(.4f, 1.3f, .7f, 1.5f, 1f, 1f)
+ }))
+ }
+
+ @Test
+ fun test_startFakeDragDuringManualDrag() {
+ // Skip tests where manual dragging is disabled
+ assumeThat(config.enableUserInput, equalTo(true))
+
+ // start manual drag
+ val latch = test.viewPager.addWaitForStateLatch(DRAGGING)
+ // Perform manual swipe in separate thread, because the SwipeMethod.MANUAL blocks while
+ // injecting events, and we need to interrupt it
+ newSingleThreadExecutor().execute { test.swipeForward(SwipeMethod.MANUAL) }
+ assertThat(latch.await(1, SECONDS), equalTo(true))
+
+ // start fake drag
+ assertThat(test.viewPager.beginFakeDrag(), equalTo(false))
+ }
+
+ @Test
+ fun test_startFakeDragToTargetPageWhileSettling() {
+ // Run the test two times to verify that state doesn't linger
+ repeat(2) {
+ val targetPage = test.viewPager.currentItem + 1
+ startFakeDragWhileSettling(targetPage, .2f, .2f, targetPage)
+ }
+ }
+
+ @Test
+ fun test_startFakeDragExactlyToTargetPageWhileSettling() {
+ // Run the test two times to verify that state doesn't linger
+ repeat(2) {
+ val tracker = PositionTracker().also { test.viewPager.registerOnPageChangeCallback(it) }
+ val targetPage = test.viewPager.currentItem + 1
+ startFakeDragWhileSettling(targetPage, .4f,
+ { targetPage - tracker.lastPosition }, targetPage, true)
+ test.viewPager.unregisterOnPageChangeCallback(tracker)
+ }
+ }
+
+ @Test
+ fun test_startFakeDragToNextPageWhileSettling() {
+ // Run the test two times to verify that state doesn't linger
+ repeat(2) {
+ val targetPage = test.viewPager.currentItem + 1
+ startFakeDragWhileSettling(targetPage, .5f, 1f, targetPage + 1)
+ }
+ }
+
+ @Test
+ fun test_startFakeDragExactlyToNextPageWhileSettling() {
+ // Run the test two times to verify that state doesn't linger
+ repeat(2) {
+ val tracker = PositionTracker().also { test.viewPager.registerOnPageChangeCallback(it) }
+ val targetPage = test.viewPager.currentItem + 1
+ val nextPage = targetPage + 1
+ startFakeDragWhileSettling(targetPage, .5f,
+ { nextPage - tracker.lastPosition }, nextPage, true)
+ test.viewPager.unregisterOnPageChangeCallback(tracker)
+ }
+ }
+
+ @Test
+ fun test_setCurrentItemDuringFakeDrag() {
+ setCurrentItemDuringFakeDrag(false)
+ }
+
+ @Test
+ fun test_smoothScrollDuringFakeDrag() {
+ setCurrentItemDuringFakeDrag(true)
+ }
+
+ @Test
+ fun test_startManualDragDuringFakeDrag() {
+ // Skip tests where manual dragging is disabled
+ assumeThat(config.enableUserInput, equalTo(true))
+
+ // Run the test two times to verify that state doesn't linger
+ repeat(2) {
+ val initialPage = test.viewPager.currentItem
+ val expectedFinalPage = initialPage + 1
+ val recorder = test.viewPager.addNewRecordingCallback()
+
+ // start fake drag
+ val fakeDragLatch = test.viewPager.addWaitForDistanceToTarget(expectedFinalPage, .9f)
+ val idleLatch = test.viewPager.addWaitForIdleLatch()
+ fakeDragger.fakeDrag(.5f, 500)
+ assertThat(fakeDragLatch.await(1, SECONDS), equalTo(true))
+
+ // start manual drag
+ test.swipeForward(SwipeMethod.MANUAL)
+ assertThat(idleLatch.await(2, SECONDS), equalTo(true))
+
+ // test assertions
+ test.assertBasicState(expectedFinalPage)
+ recorder.apply {
+ scrollEvents.assertValueSanity(0, pageCount - 1, test.viewPager.pageSize)
+ assertFirstEvents(DRAGGING)
+ assertLastEvents(expectedFinalPage)
+ assertPageSelectedEvents(initialPage, expectedFinalPage)
+ assertStateChanges(
+ listOf(DRAGGING, SETTLING, IDLE),
+ listOf(DRAGGING, IDLE)
+ )
+ }
+
+ test.viewPager.unregisterOnPageChangeCallback(recorder)
+ }
+ }
+
+ private fun basicFakeDragTest(
+ relativeDragDistance: Float,
+ duration: Long,
+ expectedFinalPage: Int,
+ interpolator: Interpolator = LinearInterpolator()
+ ) {
+ val startPage = test.viewPager.currentItem
+ // Run the test two times to verify that state doesn't linger
+ repeat(2) {
+ val initialPage = test.viewPager.currentItem
+ val expectedFinalPageWithOffset = expectedFinalPage + initialPage - startPage
+ val recorder = test.viewPager.addNewRecordingCallback()
+
+ val latch = test.viewPager.addWaitForIdleLatch()
+ fakeDragger.fakeDrag(relativeDragDistance, duration, interpolator)
+ latch.await(2000 + duration, MILLISECONDS)
+
+ // test assertions
+ test.assertBasicState(expectedFinalPageWithOffset)
+ recorder.apply {
+ scrollEvents.assertValueSanity(0, pageCount - 1, test.viewPager.pageSize)
+ assertFirstEvents(DRAGGING)
+ assertLastEvents(expectedFinalPageWithOffset)
+ assertPageSelectedEvents(initialPage, expectedFinalPageWithOffset)
+ assertStateChanges(
+ listOf(DRAGGING, SETTLING, IDLE),
+ listOf(DRAGGING, IDLE)
+ )
+ }
+
+ test.viewPager.unregisterOnPageChangeCallback(recorder)
+ }
+ }
+
+ private fun startFakeDragWhileSettling(
+ settleTarget: Int,
+ settleDistance: Float,
+ dragDistance: Float,
+ expectedFinalPage: Int
+ ) {
+ startFakeDragWhileSettling(settleTarget, settleDistance,
+ { dragDistance }, expectedFinalPage, false)
+ }
+
+ private fun startFakeDragWhileSettling(
+ settleTarget: Int,
+ settleDistance: Float,
+ dragDistance: () -> Float,
+ expectedFinalPage: Int,
+ fakeDragMustEndSnapped: Boolean
+ ) {
+ val initialPage = test.viewPager.currentItem
+ val recorder = test.viewPager.addNewRecordingCallback()
+
+ // start smooth scroll
+ val threshold = 1f - settleDistance
+ val scrollLatch = test.viewPager.addWaitForDistanceToTarget(settleTarget, threshold)
+ test.runOnUiThread { test.viewPager.setCurrentItem(settleTarget, true) }
+ assertThat(scrollLatch.await(1, SECONDS), equalTo(true))
+
+ // start fake drag
+ val idleLatch = test.viewPager.addWaitForIdleLatch()
+ fakeDragger.fakeDrag(dragDistance(), 200)
+ assertThat(idleLatch.await(2, SECONDS), equalTo(true))
+
+ // test assertions
+ test.assertBasicState(expectedFinalPage)
+ recorder.apply {
+ scrollEvents.assertValueSanity(0, pageCount - 1, test.viewPager.pageSize)
+ assertFirstEvents(SETTLING)
+ assertLastEvents(expectedFinalPage)
+ assertPageSelectedEvents(initialPage, settleTarget, expectedFinalPage)
+ if (fakeDragMustEndSnapped) {
+ assertThat("When a fake drag should end in a snapped position, we expect the last" +
+ " scroll event after the FAKE_DRAG event to be snapped. ${dumpEvents()}",
+ expectSettlingAfterState(DRAGGING), equalTo(false))
+ }
+ assertStateChanges(
+ listOf(SETTLING, DRAGGING, SETTLING, IDLE),
+ listOf(SETTLING, DRAGGING, IDLE)
+ )
+ }
+
+ test.viewPager.unregisterOnPageChangeCallback(recorder)
+ }
+
+ private fun setCurrentItemDuringFakeDrag(smoothScroll: Boolean) {
+ val initialPage = test.viewPager.currentItem
+ // start fake drag
+ val latch = test.viewPager.addWaitForStateLatch(DRAGGING)
+ fakeDragger.fakeDrag(.5f, 500)
+ assertThat(latch.await(1, SECONDS), equalTo(true))
+
+ // start smooth scroll
+ doIllegalAction("Cannot change current item when ViewPager2 is fake dragging") {
+ test.viewPager.setCurrentItem(initialPage + 1, smoothScroll)
+ }
+ }
+
+ private fun doIllegalAction(errorMessage: String, action: () -> Unit) {
+ val executionLatch = CountDownLatch(1)
+ var exception: IllegalStateException? = null
+ test.runOnUiThread {
+ try {
+ action()
+ } catch (e: IllegalStateException) {
+ exception = e
+ } finally {
+ executionLatch.countDown()
+ }
+ }
+ assertThat(executionLatch.await(1, SECONDS), equalTo(true))
+ assertThat(exception, notNullValue())
+ assertThat(exception!!.message, equalTo(errorMessage))
+ }
+
+ private fun ViewPager2.addNewRecordingCallback(): RecordingCallback {
+ return RecordingCallback().also { registerOnPageChangeCallback(it) }
+ }
+
+ 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()
+ }
+
+ private class RecordingCallback : ViewPager2.OnPageChangeCallback() {
+ private val events = mutableListOf<Event>()
+
+ val allEvents get() = events.toList()
+ val scrollEvents get() = events.mapNotNull { it as? OnPageScrolledEvent }
+ val stateEvents get() = events.mapNotNull { it as? OnPageScrollStateChangedEvent }
+ val selectEvents get() = events.mapNotNull { it as? OnPageSelectedEvent }
+
+ val eventCount get() = events.size
+ val firstEvent get() = events.firstOrNull()
+ val lastEvent get() = events.lastOrNull()
+
+ override fun onPageScrolled(
+ position: Int,
+ positionOffset: Float,
+ positionOffsetPixels: Int
+ ) {
+ synchronized(events) {
+ events.add(OnPageScrolledEvent(position, positionOffset, positionOffsetPixels))
+ }
+ }
+
+ override fun onPageSelected(position: Int) {
+ synchronized(events) {
+ events.add(OnPageSelectedEvent(position))
+ }
+ }
+
+ override fun onPageScrollStateChanged(state: Int) {
+ synchronized(events) {
+ events.add(OnPageScrollStateChangedEvent(state))
+ }
+ }
+
+ fun expectSettlingAfterState(state: Int): Boolean {
+ val changeToStateEvent = OnPageScrollStateChangedEvent(state)
+ val lastScrollEvent = events
+ .dropWhile { it != changeToStateEvent }
+ .dropWhile { it !is OnPageScrolledEvent }
+ .takeWhile { it is OnPageScrolledEvent }
+ .lastOrNull() as? OnPageScrolledEvent
+ return lastScrollEvent?.let { it.positionOffsetPixels != 0 } ?: true
+ }
+
+ fun dumpEvents(): String {
+ return events.joinToString("\n- ", "\n(${scrollStateGlossary()})\n- ")
+ }
+ }
+
+ private fun RecordingCallback.assertFirstEvents(expectedFirstState: Int) {
+ assertThat("There should be events", eventCount, greaterThan(0))
+ assertThat("First event should be state change to " +
+ "${scrollStateToString(expectedFirstState)}: ${dumpEvents()}",
+ firstEvent, equalTo(OnPageScrollStateChangedEvent(expectedFirstState) as Event))
+ }
+
+ private fun RecordingCallback.assertLastEvents(expectedFinalPage: Int) {
+ assertThat("Last event should be state change to IDLE: ${dumpEvents()}",
+ lastEvent, equalTo(OnPageScrollStateChangedEvent(IDLE) as Event))
+ assertThat("Scroll events don't end in snapped position: ${dumpEvents()}",
+ scrollEvents.last().positionOffsetPixels, equalTo(0))
+ assertThat("Scroll events don't end at page $expectedFinalPage: ${dumpEvents()}",
+ scrollEvents.last().position, equalTo(expectedFinalPage))
+ }
+
+ private fun RecordingCallback.assertPageSelectedEvents(vararg visitedPages: Int) {
+ val expectedPageSelects = visitedPages.toList().zipWithNext().mapNotNull { pair ->
+ // If visited page is same as previous page, no page selected event should be fired
+ if (pair.first == pair.second) null else pair.second
+ }
+ assertThat("Sequence of selected pages should be $expectedPageSelects: ${dumpEvents()}",
+ selectEvents.map { it.position }, equalTo(expectedPageSelects))
+
+ val settleEvent = OnPageScrollStateChangedEvent(SETTLING)
+ val idleEvent = OnPageScrollStateChangedEvent(IDLE)
+ val events = allEvents
+ events.forEachIndexed { i, event ->
+ if (event is OnPageSelectedEvent) {
+ assertThat("OnPageSelectedEvents cannot be the first or last event: " +
+ dumpEvents(), i, isBetweenInEx(1, eventCount - 1))
+ val isAfterSettleEvent = events[i - 1] == settleEvent
+ val isBeforeIdleEvent = events[i + 1] == idleEvent
+ assertThat("OnPageSelectedEvent at index $i must follow a SETTLE event or precede" +
+ " an IDLE event, but not both: ${dumpEvents()}",
+ isAfterSettleEvent.xor(isBeforeIdleEvent), equalTo(true))
+ }
+ }
+ }
+
+ private fun RecordingCallback.assertStateChanges(
+ statesWithSettling: List<Int>,
+ statesWithoutSettling: List<Int>
+ ) {
+ assertThat(
+ "Unexpected sequence of state changes:" + dumpEvents(),
+ stateEvents.map { it.state },
+ equalTo(
+ if (expectSettlingAfterState(DRAGGING)) {
+ statesWithSettling
+ } else {
+ statesWithoutSettling
+ }
+ )
+ )
+ }
+
+ 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 class PositionTracker : ViewPager2.OnPageChangeCallback() {
+ var lastPosition = 0f
+ override fun onPageScrolled(position: Int, offset: Float, offsetPx: Int) {
+ lastPosition = position + offset
+ }
+ }
+}
+
+private fun createTestSet(): List<TestConfig> {
+ return listOf(ORIENTATION_HORIZONTAL, ORIENTATION_VERTICAL).flatMap { orientation ->
+ listOf(false, true).flatMap { rtl ->
+ listOf(true, false).map { enableUserInput ->
+ TestConfig(orientation, rtl, enableUserInput)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/viewpager2/src/androidTest/java/androidx/viewpager2/widget/PageChangeCallbackTest.kt b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/PageChangeCallbackTest.kt
index b021caf..e1e7909 100644
--- a/viewpager2/src/androidTest/java/androidx/viewpager2/widget/PageChangeCallbackTest.kt
+++ b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/PageChangeCallbackTest.kt
@@ -25,6 +25,7 @@
import androidx.testutils.PollingCheck
import androidx.viewpager.widget.ViewPager
import androidx.viewpager2.LocaleTestUtils
+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
@@ -46,6 +47,7 @@
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
+import java.util.concurrent.Executors.newSingleThreadExecutor
import java.util.concurrent.TimeUnit.MILLISECONDS
import java.util.concurrent.TimeUnit.SECONDS
import java.util.concurrent.atomic.AtomicBoolean
@@ -731,6 +733,52 @@
}
@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.runOnUiThread { 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)
+ }
+
+ @Test
fun test_setCurrentItem_noAdapter() {
val test = setUpTest(config.orientation)
assertThat(test.viewPager.adapter, nullValue())
diff --git a/viewpager2/src/androidTest/java/androidx/viewpager2/widget/swipe/ManualSwipeInjector.java b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/swipe/ManualSwipeInjector.java
index 4a0f586..e041112 100644
--- a/viewpager2/src/androidTest/java/androidx/viewpager2/widget/swipe/ManualSwipeInjector.java
+++ b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/swipe/ManualSwipeInjector.java
@@ -29,9 +29,14 @@
import java.util.List;
/**
- * Performs a swipe on a view from the center of that view to on of its edges.
+ * Performs a swipe on a view from the center of that view to on of its edges. Mostly the same as
+ * Espresso's swipe ViewActions, but since this is not a ViewAction, it is not performed on the UI
+ * thread. It is still synchronous though, with sleeps between the injection of each MotionEvent. If
+ * you need asynchronous injection, run it in a separate thread. Another difference is that this
+ * injector swipes from the center of the targeted View to the center of an edge, instead of from
+ * the center of one edge to the center of another edge.
*
- * Obtain a new instance of this class for each swipe you want to perform, with one of the {@link
+ * <p>Obtain a new instance of this class for each swipe you want to perform, with one of the {@link
* #swipeLeft() swipe methods}. Inject the motion events by calling {@link #perform(Instrumentation,
* View)}.
*/
diff --git a/viewpager2/src/androidTest/java/androidx/viewpager2/widget/swipe/PageSwiperFakeDrag.kt b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/swipe/PageSwiperFakeDrag.kt
new file mode 100644
index 0000000..c08b9cb
--- /dev/null
+++ b/viewpager2/src/androidTest/java/androidx/viewpager2/widget/swipe/PageSwiperFakeDrag.kt
@@ -0,0 +1,83 @@
+/*
+ * 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.swipe
+
+import android.os.SystemClock
+import android.view.animation.AccelerateInterpolator
+import android.view.animation.Interpolator
+import android.view.animation.LinearInterpolator
+import androidx.core.view.ViewCompat
+import androidx.viewpager2.widget.ViewPager2
+import androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL
+import kotlin.math.max
+import kotlin.math.roundToInt
+
+class PageSwiperFakeDrag(private val viewPager: ViewPager2) : PageSwiper {
+ companion object {
+ // 60 fps
+ private const val FRAME_LENGTH_MS = 1000L / 60
+ private const val FLING_DURATION_MS = 100L
+ }
+
+ private val ViewPager2.pageSize: Int
+ get() {
+ return if (orientation == ORIENTATION_HORIZONTAL) {
+ measuredWidth - paddingLeft - paddingRight
+ } else {
+ measuredHeight - paddingTop - paddingBottom
+ }
+ }
+
+ private val needsRtlModifier
+ get() = viewPager.orientation == ORIENTATION_HORIZONTAL &&
+ ViewCompat.getLayoutDirection(viewPager) == ViewCompat.LAYOUT_DIRECTION_RTL
+
+ override fun swipeNext() {
+ fakeDrag(.5f, interpolator = AccelerateInterpolator())
+ }
+
+ override fun swipePrevious() {
+ fakeDrag(-.5f, interpolator = AccelerateInterpolator())
+ }
+
+ fun fakeDrag(
+ relativeDragDistance: Float,
+ duration: Long = FLING_DURATION_MS,
+ interpolator: Interpolator = LinearInterpolator()
+ ) {
+ // Generate the deltas to feed to fakeDragBy()
+ val rtlModifier = if (needsRtlModifier) -1 else 1
+ val steps = max(1, (duration / FRAME_LENGTH_MS.toFloat()).roundToInt())
+ val distancePx = viewPager.pageSize * -relativeDragDistance * rtlModifier
+ val deltas = List(steps) { i ->
+ val currDistance = interpolator.getInterpolation((i + 1f) / steps) * distancePx
+ val prevDistance = interpolator.getInterpolation((i + 0f) / steps) * distancePx
+ currDistance - prevDistance
+ }
+
+ // Send the fakeDrag events
+ var eventTime = SystemClock.uptimeMillis()
+ val delayMs = { eventTime - SystemClock.uptimeMillis() }
+ viewPager.post { viewPager.beginFakeDrag() }
+ for (delta in deltas) {
+ eventTime += FRAME_LENGTH_MS
+ viewPager.postDelayed({ viewPager.fakeDragBy(delta) }, delayMs())
+ }
+ eventTime++
+ viewPager.postDelayed({ viewPager.endFakeDrag() }, delayMs())
+ }
+}
\ No newline at end of file
diff --git a/viewpager2/src/main/java/androidx/viewpager2/widget/FakeDrag.java b/viewpager2/src/main/java/androidx/viewpager2/widget/FakeDrag.java
new file mode 100644
index 0000000..ac9d3c4
--- /dev/null
+++ b/viewpager2/src/main/java/androidx/viewpager2/widget/FakeDrag.java
@@ -0,0 +1,138 @@
+/*
+ * 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 static androidx.viewpager2.widget.ViewPager2.ORIENTATION_HORIZONTAL;
+
+import android.os.SystemClock;
+import android.view.MotionEvent;
+import android.view.VelocityTracker;
+import android.view.ViewConfiguration;
+
+import androidx.annotation.UiThread;
+import androidx.recyclerview.widget.RecyclerView;
+
+/**
+ * Provides fake dragging functionality to {@link ViewPager2}.
+ */
+final class FakeDrag {
+ private final ViewPager2 mViewPager;
+ private final ScrollEventAdapter mScrollEventAdapter;
+ private final RecyclerView mRecyclerView;
+
+ private VelocityTracker mVelocityTracker;
+ private int mMaximumVelocity;
+ private float mRequestedDragDistance;
+ private int mActualDraggedDistance;
+ private long mFakeDragBeginTime;
+
+ FakeDrag(ViewPager2 viewPager, ScrollEventAdapter scrollEventAdapter,
+ RecyclerView recyclerView) {
+ mViewPager = viewPager;
+ mScrollEventAdapter = scrollEventAdapter;
+ mRecyclerView = recyclerView;
+ }
+
+ boolean isFakeDragging() {
+ return mScrollEventAdapter.isFakeDragging();
+ }
+
+ @UiThread
+ boolean beginFakeDrag() {
+ if (mScrollEventAdapter.isDragging()) {
+ return false;
+ }
+ mRequestedDragDistance = mActualDraggedDistance = 0;
+ mFakeDragBeginTime = SystemClock.uptimeMillis();
+ beginFakeVelocityTracker();
+
+ mScrollEventAdapter.notifyBeginFakeDrag();
+ if (!mScrollEventAdapter.isIdle()) {
+ // Stop potentially running settling animation
+ mRecyclerView.stopScroll();
+ }
+ addFakeMotionEvent(mFakeDragBeginTime, MotionEvent.ACTION_DOWN, 0, 0);
+ return true;
+ }
+
+ @UiThread
+ boolean fakeDragBy(float offsetPxFloat) {
+ if (!mScrollEventAdapter.isFakeDragging()) {
+ // Can happen legitimately if user started dragging during fakeDrag and app is still
+ // sending fakeDragBy commands
+ return false;
+ }
+ // Subtract the offset, because content scrolls in the opposite direction of finger motion
+ mRequestedDragDistance -= offsetPxFloat;
+ // Calculate amount of pixels to scroll ...
+ int offsetPx = Math.round(mRequestedDragDistance - mActualDraggedDistance);
+ // ... and keep track of pixels scrolled so we don't get rounding errors
+ mActualDraggedDistance += offsetPx;
+ long time = SystemClock.uptimeMillis();
+
+ boolean isHorizontal = mViewPager.getOrientation() == ORIENTATION_HORIZONTAL;
+ // Scroll deltas use pixels:
+ final int offsetX = isHorizontal ? offsetPx : 0;
+ final int offsetY = isHorizontal ? 0 : offsetPx;
+ // Motion events get the raw float distance:
+ final float x = isHorizontal ? mRequestedDragDistance : 0;
+ final float y = isHorizontal ? 0 : mRequestedDragDistance;
+
+ mRecyclerView.scrollBy(offsetX, offsetY);
+ addFakeMotionEvent(time, MotionEvent.ACTION_MOVE, x, y);
+ return true;
+ }
+
+ @UiThread
+ boolean endFakeDrag() {
+ if (!mScrollEventAdapter.isFakeDragging()) {
+ // Happens legitimately if user started dragging during fakeDrag
+ return false;
+ }
+
+ mScrollEventAdapter.notifyEndFakeDrag();
+
+ // Compute the velocity of the fake drag
+ final int pixelsPerSecond = 1000;
+ final VelocityTracker velocityTracker = mVelocityTracker;
+ velocityTracker.computeCurrentVelocity(pixelsPerSecond, mMaximumVelocity);
+ int xVelocity = (int) velocityTracker.getXVelocity();
+ int yVelocity = (int) velocityTracker.getYVelocity();
+ // And fling or snap the ViewPager2 to its destination
+ if (!mRecyclerView.fling(xVelocity, yVelocity)) {
+ // Velocity too low, trigger snap to page manually
+ mViewPager.snapToPage();
+ }
+ return true;
+ }
+
+ private void beginFakeVelocityTracker() {
+ if (mVelocityTracker == null) {
+ mVelocityTracker = VelocityTracker.obtain();
+ final ViewConfiguration configuration = ViewConfiguration.get(mViewPager.getContext());
+ mMaximumVelocity = configuration.getScaledMaximumFlingVelocity();
+ } else {
+ mVelocityTracker.clear();
+ }
+ }
+
+ private void addFakeMotionEvent(long time, int action, float x, float y) {
+ final MotionEvent ev = MotionEvent.obtain(mFakeDragBeginTime, time, action, x, y, 0);
+ mVelocityTracker.addMovement(ev);
+ ev.recycle();
+ }
+}
diff --git a/viewpager2/src/main/java/androidx/viewpager2/widget/ScrollEventAdapter.java b/viewpager2/src/main/java/androidx/viewpager2/widget/ScrollEventAdapter.java
index f493cb4..3e2b758 100644
--- a/viewpager2/src/main/java/androidx/viewpager2/widget/ScrollEventAdapter.java
+++ b/viewpager2/src/main/java/androidx/viewpager2/widget/ScrollEventAdapter.java
@@ -54,7 +54,7 @@
@Retention(SOURCE)
@IntDef({STATE_IDLE, STATE_IN_PROGRESS_MANUAL_DRAG, STATE_IN_PROGRESS_SMOOTH_SCROLL,
- STATE_IN_PROGRESS_IMMEDIATE_SCROLL})
+ STATE_IN_PROGRESS_IMMEDIATE_SCROLL, STATE_IN_PROGRESS_FAKE_DRAG})
private @interface AdapterState {
}
@@ -62,6 +62,7 @@
private static final int STATE_IN_PROGRESS_MANUAL_DRAG = 1;
private static final int STATE_IN_PROGRESS_SMOOTH_SCROLL = 2;
private static final int STATE_IN_PROGRESS_IMMEDIATE_SCROLL = 3;
+ private static final int STATE_IN_PROGRESS_FAKE_DRAG = 4;
private static final int NO_POSITION = -1;
@@ -76,6 +77,7 @@
private int mTarget;
private boolean mDispatchSelected;
private boolean mScrollHappened;
+ private boolean mFakeDragging;
ScrollEventAdapter(@NonNull LinearLayoutManager layoutManager) {
mLayoutManager = layoutManager;
@@ -91,6 +93,7 @@
mTarget = NO_POSITION;
mDispatchSelected = false;
mScrollHappened = false;
+ mFakeDragging = false;
}
/**
@@ -102,26 +105,13 @@
// User started a drag (not dragging -> dragging)
if (mAdapterState != STATE_IN_PROGRESS_MANUAL_DRAG
&& newState == RecyclerView.SCROLL_STATE_DRAGGING) {
- // Remember we're performing a drag
- mAdapterState = STATE_IN_PROGRESS_MANUAL_DRAG;
- if (mTarget != NO_POSITION) {
- // Target was set means programmatic scroll was in progress
- // Update "drag start page" to reflect the page that ViewPager2 thinks it is at
- mDragStartPosition = mTarget;
- // Reset target because drags have no target until released
- mTarget = NO_POSITION;
- } else {
- // ViewPager2 was at rest, set "drag start page" to current page
- mDragStartPosition = getPosition();
- }
- dispatchStateChanged(SCROLL_STATE_DRAGGING);
+ startDrag(false);
return;
}
// Drag is released, RecyclerView is snapping to page (dragging -> settling)
// Note that mAdapterState is not updated, to remember we were dragging when settling
- if (mAdapterState == STATE_IN_PROGRESS_MANUAL_DRAG
- && newState == RecyclerView.SCROLL_STATE_SETTLING) {
+ if (isInAnyDraggingState() && newState == RecyclerView.SCROLL_STATE_SETTLING) {
// Only go through the settling phase if the drag actually moved the page
if (mScrollHappened) {
dispatchStateChanged(SCROLL_STATE_SETTLING);
@@ -132,8 +122,7 @@
}
// Drag is finished (dragging || settling -> idle)
- if (mAdapterState == STATE_IN_PROGRESS_MANUAL_DRAG
- && newState == RecyclerView.SCROLL_STATE_IDLE) {
+ if (isInAnyDraggingState() && newState == RecyclerView.SCROLL_STATE_IDLE) {
boolean dispatchIdle = false;
updateScrollEventValues();
if (!mScrollHappened) {
@@ -169,7 +158,7 @@
@Override
public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
mScrollHappened = true;
- ScrollEventValues values = updateScrollEventValues();
+ updateScrollEventValues();
if (mDispatchSelected) {
// Drag started settling, need to calculate target page and dispatch onPageSelected now
@@ -178,19 +167,19 @@
// "&& values.mOffsetPx != 0": filters special case where we're scrolling forward and
// the first scroll event after settling already got us at the target
- mTarget = scrollingForward && values.mOffsetPx != 0
- ? values.mPosition + 1 : values.mPosition;
+ mTarget = scrollingForward && mScrollValues.mOffsetPx != 0
+ ? mScrollValues.mPosition + 1 : mScrollValues.mPosition;
if (mDragStartPosition != mTarget) {
dispatchSelected(mTarget);
}
}
- dispatchScrolled(values.mPosition, values.mOffset, values.mOffsetPx);
+ dispatchScrolled(mScrollValues.mPosition, mScrollValues.mOffset, mScrollValues.mOffsetPx);
// Dispatch idle in onScrolled instead of in onScrollStateChanged because RecyclerView
// doesn't send IDLE event when using setCurrentItem(x, false)
- if ((values.mPosition == mTarget || mTarget == NO_POSITION) && values.mOffsetPx == 0
- && mScrollState != SCROLL_STATE_DRAGGING) {
+ if ((mScrollValues.mPosition == mTarget || mTarget == NO_POSITION)
+ && mScrollValues.mOffsetPx == 0 && !(mScrollState == SCROLL_STATE_DRAGGING)) {
// When the target page is reached and the user is not dragging anymore, we're settled,
// so go to idle.
// Special case and a bit of a hack when mTarget == NO_POSITION: RecyclerView is being
@@ -206,16 +195,18 @@
* Calculates the current position and the offset (as a percentage and in pixels) of that
* position from the center.
*/
- private ScrollEventValues updateScrollEventValues() {
+ private void updateScrollEventValues() {
ScrollEventValues values = mScrollValues;
values.mPosition = mLayoutManager.findFirstVisibleItemPosition();
if (values.mPosition == RecyclerView.NO_POSITION) {
- return values.reset();
+ values.reset();
+ return;
}
View firstVisibleView = mLayoutManager.findViewByPosition(values.mPosition);
if (firstVisibleView == null) {
- return values.reset();
+ values.reset();
+ return;
}
// TODO(123350297): automated test for this
@@ -244,7 +235,22 @@
+ "positive amount, not by %d", values.mOffsetPx));
}
values.mOffset = sizePx == 0 ? 0 : (float) values.mOffsetPx / sizePx;
- return values;
+ }
+
+ private void startDrag(boolean isFakeDrag) {
+ mFakeDragging = isFakeDrag;
+ mAdapterState = isFakeDrag ? STATE_IN_PROGRESS_FAKE_DRAG : STATE_IN_PROGRESS_MANUAL_DRAG;
+ if (mTarget != NO_POSITION) {
+ // Target was set means programmatic scroll was in progress
+ // Update "drag start page" to reflect the page that ViewPager2 thinks it is at
+ mDragStartPosition = mTarget;
+ // Reset target because drags have no target until released
+ mTarget = NO_POSITION;
+ } else {
+ // ViewPager2 was at rest, set "drag start page" to current page
+ mDragStartPosition = getPosition();
+ }
+ dispatchStateChanged(SCROLL_STATE_DRAGGING);
}
/**
@@ -263,7 +269,7 @@
}
/**
- * Let the adapter know that mCurrentItem was restored in onRestoreInstanceState
+ * Let the adapter know that mCurrentItem was restored in onRestoreInstanceState.
*/
void notifyRestoreCurrentItem(int currentItem) {
// Don't send page selected event for page 0 for consistency with ViewPager
@@ -272,6 +278,37 @@
}
}
+ /**
+ * Let the adapter know that a fake drag has started.
+ */
+ void notifyBeginFakeDrag() {
+ mAdapterState = STATE_IN_PROGRESS_FAKE_DRAG;
+ startDrag(true);
+ }
+
+ /**
+ * Let the adapter know that a fake drag has ended.
+ */
+ void notifyEndFakeDrag() {
+ if (isDragging() && !mFakeDragging) {
+ // Real drag has already taken over, no need to post process the fake drag
+ return;
+ }
+ mFakeDragging = false;
+ updateScrollEventValues();
+ if (mScrollValues.mOffsetPx == 0) {
+ // We're snapped, so dispatch an IDLE event
+ if (mScrollValues.mPosition != mDragStartPosition) {
+ dispatchSelected(mScrollValues.mPosition);
+ }
+ dispatchStateChanged(SCROLL_STATE_IDLE);
+ resetState();
+ } else {
+ // We're not snapped, so dispatch a SETTLING event
+ dispatchStateChanged(SCROLL_STATE_SETTLING);
+ }
+ }
+
private boolean isLayoutRTL() {
return mLayoutManager.getLayoutDirection() == LAYOUT_DIRECTION_RTL;
}
@@ -280,11 +317,41 @@
mCallback = callback;
}
+ int getScrollState() {
+ return mScrollState;
+ }
+
/**
- * @return true if there is no known scroll in progress
+ * @return {@code true} if there is no known scroll in progress
*/
boolean isIdle() {
- return mAdapterState == STATE_IDLE;
+ return mScrollState == SCROLL_STATE_IDLE;
+ }
+
+ /**
+ * @return {@code true} if the ViewPager2 is being dragged. Returns {@code false} from the
+ * moment the ViewPager2 starts settling or goes idle.
+ */
+ boolean isDragging() {
+ return mScrollState == SCROLL_STATE_DRAGGING;
+ }
+
+ /**
+ * @return {@code true} if a fake drag is ongoing. Returns {@code false} from the moment the
+ * {@link ViewPager2#endFakeDrag()} is called.
+ */
+ boolean isFakeDragging() {
+ return mFakeDragging;
+ }
+
+ /**
+ * Checks if the adapter state (not the scroll state) is in the manual or fake dragging state.
+ * @return {@code true} if {@link #mAdapterState} is either {@link
+ * #STATE_IN_PROGRESS_MANUAL_DRAG} or {@link #STATE_IN_PROGRESS_FAKE_DRAG}
+ */
+ private boolean isInAnyDraggingState() {
+ return mAdapterState == STATE_IN_PROGRESS_MANUAL_DRAG
+ || mAdapterState == STATE_IN_PROGRESS_FAKE_DRAG;
}
/**
@@ -346,11 +413,10 @@
ScrollEventValues() {
}
- ScrollEventValues reset() {
+ void reset() {
mPosition = RecyclerView.NO_POSITION;
mOffset = 0f;
mOffsetPx = 0;
- return this;
}
}
}
diff --git a/viewpager2/src/main/java/androidx/viewpager2/widget/ViewPager2.java b/viewpager2/src/main/java/androidx/viewpager2/widget/ViewPager2.java
index 9b3cb41..13a3cfd 100644
--- a/viewpager2/src/main/java/androidx/viewpager2/widget/ViewPager2.java
+++ b/viewpager2/src/main/java/androidx/viewpager2/widget/ViewPager2.java
@@ -86,7 +86,9 @@
int mCurrentItem;
private RecyclerView mRecyclerView;
private LinearLayoutManager mLayoutManager;
+ private PagerSnapHelper mPagerSnapHelper;
private ScrollEventAdapter mScrollEventAdapter;
+ private FakeDrag mFakeDragger;
private PageTransformerAdapter mPageTransformerAdapter;
private CompositeOnPageChangeCallback mPageChangeEventDispatcher;
private boolean mUserInputEnabled = true;
@@ -124,9 +126,16 @@
mRecyclerView.setLayoutParams(
new ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
mRecyclerView.addOnChildAttachStateChangeListener(enforceChildFillListener());
- new PagerSnapHelper().attachToRecyclerView(mRecyclerView);
+ // Create ScrollEventAdapter before attaching PagerSnapHelper to RecyclerView, because the
+ // attach process calls PagerSnapHelperImpl.findSnapView, which uses the mScrollEventAdapter
mScrollEventAdapter = new ScrollEventAdapter(mLayoutManager);
+ // Create FakeDrag before attaching PagerSnapHelper, same reason as above
+ mFakeDragger = new FakeDrag(this, mScrollEventAdapter, mRecyclerView);
+ mPagerSnapHelper = new PagerSnapHelperImpl();
+ mPagerSnapHelper.attachToRecyclerView(mRecyclerView);
+ // Add mScrollEventAdapter after attaching mPagerSnapHelper to mRecyclerView, because we
+ // don't want to respond on the events sent out during the attach process
mRecyclerView.addOnScrollListener(mScrollEventAdapter);
mPageChangeEventDispatcher = new CompositeOnPageChangeCallback(3);
@@ -421,6 +430,10 @@
* @param smoothScroll True to smoothly scroll to the new item, false to transition immediately
*/
public void setCurrentItem(int item, boolean smoothScroll) {
+ if (isFakeDragging()) {
+ throw new IllegalStateException("Cannot change current item when ViewPager2 is fake "
+ + "dragging");
+ }
Adapter adapter = getAdapter();
if (adapter == null || adapter.getItemCount() <= 0) {
return;
@@ -473,6 +486,109 @@
}
/**
+ * Returns the current scroll state of the ViewPager2. Returned value is one of can be one of
+ * {@link #SCROLL_STATE_IDLE}, {@link #SCROLL_STATE_DRAGGING} or {@link #SCROLL_STATE_SETTLING}.
+ *
+ * @return The scroll state that was last dispatched to {@link
+ * OnPageChangeCallback#onPageScrollStateChanged(int)}
+ */
+ @ScrollState
+ public int getScrollState() {
+ return mScrollEventAdapter.getScrollState();
+ }
+
+ /**
+ * Start a fake drag of the pager.
+ *
+ * <p>A fake drag can be useful if you want to synchronize the motion of the ViewPager2 with the
+ * touch scrolling of another view, while still letting the ViewPager2 control the snapping
+ * motion and fling behavior. (e.g. parallax-scrolling tabs.) Call {@link #fakeDragBy(float)} to
+ * simulate the actual drag motion. Call {@link #endFakeDrag()} to complete the fake drag and
+ * fling as necessary.
+ *
+ * <p>A fake drag can be interrupted by a real drag. From that point on, all calls to {@code
+ * fakeDragBy} and {@code endFakeDrag} will be ignored until the next fake drag is started by
+ * calling {@code beginFakeDrag}. If you need the ViewPager2 to ignore touch events and other
+ * user input during a fake drag, use {@link #setUserInputEnabled(boolean)}. If a real or fake
+ * drag is already in progress, this method will return {@code false}.
+ *
+ * @return {@code true} if the fake drag began successfully, {@code false} if it could not be
+ * started
+ *
+ * @see #fakeDragBy(float)
+ * @see #endFakeDrag()
+ * @see #isFakeDragging()
+ */
+ public boolean beginFakeDrag() {
+ return mFakeDragger.beginFakeDrag();
+ }
+
+ /**
+ * Fake drag by an offset in pixels. You must have called {@link #beginFakeDrag()} first. Drag
+ * happens in the direction of the orientation. Positive offsets will drag to the previous page,
+ * negative values to the next page, with one exception: if layout direction is set to RTL and
+ * the ViewPager2's orientation is horizontal, then the behavior will be inverted. This matches
+ * the deltas of touch events that would cause the same real drag.
+ *
+ * <p>If the pager is not in the fake dragging state anymore, it ignores this call and returns
+ * {@code false}.
+ *
+ * @param offsetPxFloat Offset in pixels to drag by
+ * @return {@code true} if the fake drag was executed. If {@code false} is returned, it means
+ * there was no fake drag to end.
+ *
+ * @see #beginFakeDrag()
+ * @see #endFakeDrag()
+ * @see #isFakeDragging()
+ */
+ public boolean fakeDragBy(float offsetPxFloat) {
+ return mFakeDragger.fakeDragBy(offsetPxFloat);
+ }
+
+ /**
+ * End a fake drag of the pager.
+ *
+ * @return {@code true} if the fake drag was ended. If {@code false} is returned, it means there
+ * was no fake drag to end.
+ *
+ * @see #beginFakeDrag()
+ * @see #fakeDragBy(float)
+ * @see #isFakeDragging()
+ */
+ public boolean endFakeDrag() {
+ return mFakeDragger.endFakeDrag();
+ }
+
+ /**
+ * Returns {@code true} if a fake drag is in progress.
+ *
+ * @return {@code true} if currently in a fake drag, {@code false} otherwise.
+ * @see #beginFakeDrag()
+ * @see #fakeDragBy(float)
+ * @see #endFakeDrag()
+ */
+ public boolean isFakeDragging() {
+ return mFakeDragger.isFakeDragging();
+ }
+
+ /**
+ * Snaps the ViewPager2 to the closest page
+ */
+ void snapToPage() {
+ // Method copied from PagerSnapHelper#snapToTargetExistingView
+ // When fixing something here, make sure to update that method as well
+ View view = mPagerSnapHelper.findSnapView(mLayoutManager);
+ if (view == null) {
+ return;
+ }
+ int[] snapDistance = mPagerSnapHelper.calculateDistanceToFinalSnap(mLayoutManager, view);
+ //noinspection ConstantConditions
+ if (snapDistance[0] != 0 || snapDistance[1] != 0) {
+ mRecyclerView.smoothScrollBy(snapDistance[0], snapDistance[1]);
+ }
+ }
+
+ /**
* Enable or disable user initiated scrolling. This includes touch input (scroll and fling
* gestures) and accessibility input. Disabling keyboard input is not yet supported. When user
* initiated scrolling is disabled, programmatic scrolls through {@link #setCurrentItem(int,
@@ -599,6 +715,21 @@
}
}
+ private class PagerSnapHelperImpl extends PagerSnapHelper {
+ PagerSnapHelperImpl() {
+ }
+
+ @Nullable
+ @Override
+ public View findSnapView(RecyclerView.LayoutManager layoutManager) {
+ // When interrupting a smooth scroll with a fake drag, we stop RecyclerView's scroll
+ // animation, which fires a scroll state change to IDLE. PagerSnapHelper then kicks in
+ // to snap to a page, which we need to prevent here.
+ // Simplifying that case: during a fake drag, no snapping should occur.
+ return isFakeDragging() ? null : super.findSnapView(layoutManager);
+ }
+ }
+
private static class SmoothScrollToPosition implements Runnable {
private final int mPosition;
private final RecyclerView mRecyclerView;
@@ -641,9 +772,10 @@
}
/**
- * Called when the scroll state changes. Useful for discovering when the user
- * begins dragging, when the pager is automatically settling to the current page,
- * or when it is fully stopped/idle.
+ * Called when the scroll state changes. Useful for discovering when the user begins
+ * dragging, when a fake drag is started, when the pager is automatically settling to the
+ * current page, or when it is fully stopped/idle. {@code state} can be one of {@link
+ * #SCROLL_STATE_IDLE}, {@link #SCROLL_STATE_DRAGGING} or {@link #SCROLL_STATE_SETTLING}.
*/
public void onPageScrollStateChanged(@ScrollState int state) {
}