blob: 00719bf4a269022ac96220d3adb2cbadd43896af [file] [log] [blame]
/*
* Copyright 2018 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.content.Intent
import android.os.Build
import android.util.Log
import android.view.View
import android.view.View.OVER_SCROLL_NEVER
import android.view.ViewConfiguration
import android.view.accessibility.AccessibilityNodeInfo
import androidx.core.view.ViewCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ApplicationProvider
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.CoordinatesProvider
import androidx.test.espresso.action.GeneralLocation
import androidx.test.espresso.action.GeneralSwipeAction
import androidx.test.espresso.action.Press
import androidx.test.espresso.action.Swipe
import androidx.test.espresso.action.ViewActions.actionWithAssertions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom
import androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.rule.ActivityTestRule
import androidx.testutils.LocaleTestUtils
import androidx.testutils.recreate
import androidx.testutils.waitForExecution
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
import androidx.viewpager2.widget.swipe.ViewAdapter
import androidx.viewpager2.widget.swipe.WaitForInjectMotionEventsAction.Companion.waitForInjectMotionEvents
import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.Matcher
import org.hamcrest.Matchers.allOf
import org.hamcrest.Matchers.greaterThanOrEqualTo
import org.hamcrest.Matchers.lessThan
import org.hamcrest.Matchers.lessThanOrEqualTo
import org.junit.After
import org.junit.Assert.assertThat
import org.junit.Before
import org.junit.Rule
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import kotlin.math.abs
open class BaseTest {
companion object {
const val TAG = "VP2_TESTS"
const val ACTION_ID_PAGE_LEFT = android.R.id.accessibilityActionPageLeft
const val ACTION_ID_PAGE_RIGHT = android.R.id.accessibilityActionPageRight
const val ACTION_ID_PAGE_UP = android.R.id.accessibilityActionPageUp
const val ACTION_ID_PAGE_DOWN = android.R.id.accessibilityActionPageDown
}
lateinit var localeUtil: LocaleTestUtils
@get:Rule
val activityTestRule = ActivityTestRule<TestActivity>(TestActivity::class.java, false, false)
@Before
open fun setUp() {
localeUtil = LocaleTestUtils(
ApplicationProvider.getApplicationContext() as android.content.Context
)
// Ensure a predictable test environment by explicitly setting a locale
localeUtil.setLocale(LocaleTestUtils.DEFAULT_TEST_LANGUAGE)
}
@After
open fun tearDown() {
localeUtil.resetLocale()
}
fun setUpTest(@ViewPager2.Orientation orientation: Int): Context {
val intent = Intent()
if (localeUtil.isLocaleChangedAndLock()) {
intent.putExtra(TestActivity.EXTRA_LANGUAGE, localeUtil.getLocale().toString())
}
activityTestRule.launchActivity(intent)
onView(withId(R.id.view_pager)).perform(waitForInjectMotionEvents())
val viewPager: ViewPager2 = activityTestRule.activity.findViewById(R.id.view_pager)
activityTestRule.runOnUiThread { viewPager.orientation = orientation }
onView(withId(R.id.view_pager)).check(matches(isDisplayed()))
// animations getting in the way on API < 16
if (Build.VERSION.SDK_INT < 16) {
viewPager.recyclerView.overScrollMode = OVER_SCROLL_NEVER
}
return Context(activityTestRule)
}
data class Context(val activityTestRule: ActivityTestRule<TestActivity>) {
fun recreateActivity(
adapterProvider: AdapterProvider,
onCreateCallback: ((ViewPager2) -> Unit) = { }
) {
val orientation = viewPager.orientation
val isUserInputEnabled = viewPager.isUserInputEnabled
TestActivity.onCreateCallback = { activity ->
val viewPager = activity.findViewById<ViewPager2>(R.id.view_pager)
viewPager.orientation = orientation
viewPager.isUserInputEnabled = isUserInputEnabled
viewPager.adapter = adapterProvider(activity)
onCreateCallback(viewPager)
}
activity = activityTestRule.recreate()
TestActivity.onCreateCallback = { }
onView(withId(R.id.view_pager)).perform(waitForInjectMotionEvents())
}
var activity: TestActivity = activityTestRule.activity
private set(value) {
field = value
}
fun runOnUiThreadSync(f: () -> Unit) {
var thrownError: Throwable? = null
activityTestRule.runOnUiThread {
try {
f()
} catch (t: Throwable) {
thrownError = t
}
}
val caughtError = thrownError
if (caughtError != null) {
throw caughtError
}
}
val viewPager: ViewPager2 get() = activity.findViewById(R.id.view_pager)
fun peekForward() {
peek(adjustForRtl(adjustForTouchSlop(-50f)))
}
fun peekBackward() {
peek(adjustForRtl(adjustForTouchSlop(50f)))
}
enum class SwipeMethod {
ESPRESSO,
MANUAL,
FAKE_DRAG
}
fun swipe(currentPageIx: Int, nextPageIx: Int, method: SwipeMethod = SwipeMethod.ESPRESSO) {
val lastPageIx = viewPager.adapter!!.itemCount - 1
if (nextPageIx > lastPageIx) {
throw IllegalArgumentException("Invalid next page: beyond last page.")
}
if (currentPageIx == nextPageIx) { // dedicated for testing edge behaviour
if (nextPageIx == 0) {
swipeBackward(method) // bounce off the "left" edge
return
}
if (nextPageIx == lastPageIx) { // bounce off the "right" edge
swipeForward(method)
return
}
throw IllegalArgumentException(
"Invalid sequence. Not on an edge, and current page = next page."
)
}
if (Math.abs(nextPageIx - currentPageIx) > 1) {
throw IllegalArgumentException(
"Specified next page not adjacent to the current page."
)
}
if (nextPageIx > currentPageIx) {
swipeForward(method)
} else {
swipeBackward(method)
}
}
fun swipeForward(method: SwipeMethod = SwipeMethod.ESPRESSO) {
swiper(method).swipeNext()
}
fun swipeBackward(method: SwipeMethod = SwipeMethod.ESPRESSO) {
swiper(method).swipePrevious()
}
private fun swiper(method: SwipeMethod = SwipeMethod.ESPRESSO): PageSwiper {
return when (method) {
SwipeMethod.ESPRESSO -> PageSwiperEspresso(viewPager)
SwipeMethod.MANUAL -> PageSwiperManual(viewPager)
SwipeMethod.FAKE_DRAG -> PageSwiperFakeDrag(viewPager) { viewPager.pageSize }
}
}
private fun adjustForTouchSlop(offset: Float): Float {
val touchSlop = ViewConfiguration.get(viewPager.context).scaledPagingTouchSlop
return when {
offset < 0 -> offset - touchSlop
offset > 0 -> offset + touchSlop
else -> 0f
}
}
private fun adjustForRtl(offset: Float): Float {
return if (viewPager.isHorizontal && viewPager.isRtl) -offset else offset
}
private fun peek(offset: Float) {
onView(allOf(isDisplayed(), isAssignableFrom(ViewPager2::class.java)))
.perform(
actionWithAssertions(
GeneralSwipeAction(
Swipe.SLOW, GeneralLocation.CENTER,
CoordinatesProvider { view ->
val coordinates = GeneralLocation.CENTER.calculateCoordinates(view)
if (viewPager.orientation == ORIENTATION_HORIZONTAL) {
coordinates[0] += offset
} else {
coordinates[1] += offset
}
coordinates
}, Press.FINGER
)
)
)
}
fun assertPageActions() {
if (!ViewPager2.sFeatureEnhancedA11yEnabled) {
return // these assertions only apply to enhanced a11y
}
var customActions = getActionList(viewPager)
var currentPage = viewPager.currentItem
var numPages = viewPager.adapter!!.itemCount
var isUserInputEnabled = viewPager.isUserInputEnabled
var isHorizontalOrientation = viewPager.orientation == ViewPager2.ORIENTATION_HORIZONTAL
var isVerticalOrientation = viewPager.orientation == ViewPager2.ORIENTATION_VERTICAL
val expectPageLeftAction = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP &&
isUserInputEnabled && isHorizontalOrientation &&
(if (viewPager.isRtl) currentPage < numPages - 1 else currentPage > 0)
val expectPageRightAction = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP &&
isUserInputEnabled && isHorizontalOrientation &&
(if (viewPager.isRtl) currentPage > 0 else currentPage < numPages - 1)
val expectPageUpAction = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP &&
isUserInputEnabled && isVerticalOrientation &&
currentPage > 0
val expectPageDownAction = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP &&
isUserInputEnabled && isVerticalOrientation &&
currentPage < numPages - 1
val expectScrollBackwardAction =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && isUserInputEnabled &&
currentPage > 0
val expectScrollForwardAction =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN && isUserInputEnabled &&
currentPage < numPages - 1
assertThat("Left action expected: $expectPageLeftAction",
hasPageAction(customActions, ACTION_ID_PAGE_LEFT),
equalTo(expectPageLeftAction)
)
assertThat("Right action expected: $expectPageRightAction",
hasPageAction(customActions, ACTION_ID_PAGE_RIGHT),
equalTo(expectPageRightAction)
)
assertThat("Up action expected: $expectPageUpAction",
hasPageAction(customActions, ACTION_ID_PAGE_UP),
equalTo(expectPageUpAction)
)
assertThat("Down action expected: $expectPageDownAction",
hasPageAction(customActions, ACTION_ID_PAGE_DOWN),
equalTo(expectPageDownAction)
)
var node = AccessibilityNodeInfo.obtain()
runOnUiThreadSync { viewPager.onInitializeAccessibilityNodeInfo(node) }
@Suppress("DEPRECATION") var standardActions = node.actions
assertThat("scroll backward action expected: $expectScrollBackwardAction",
hasScrollAction(standardActions, ACTION_SCROLL_BACKWARD),
equalTo(expectScrollBackwardAction)
)
assertThat("Scroll forward action expected: $expectScrollForwardAction",
hasScrollAction(standardActions, ACTION_SCROLL_FORWARD),
equalTo(expectScrollForwardAction)
)
}
private fun hasScrollAction(
actions: Int,
accessibilityActionId: Int
): Boolean {
return actions and accessibilityActionId != 0
}
private fun hasPageAction(
actions: List<AccessibilityNodeInfoCompat.AccessibilityActionCompat>,
accessibilityActionId: Int
): Boolean {
return actions.any { it.id == accessibilityActionId }
}
@Suppress("UNCHECKED_CAST")
private fun getActionList(view: View):
List<AccessibilityNodeInfoCompat.AccessibilityActionCompat> {
return view.getTag(R.id.tag_accessibility_actions) as?
ArrayList<AccessibilityNodeInfoCompat.AccessibilityActionCompat> ?: ArrayList()
}
}
/**
* Note: returned latch relies on the tested API, so it's critical to check that the final
* visible page is correct using [assertBasicState].
*/
fun ViewPager2.addWaitForScrolledLatch(
targetPage: Int,
waitForIdle: Boolean = true
): CountDownLatch {
val latch = CountDownLatch(if (waitForIdle) 2 else 1)
var lastScrollFired = false
registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageScrollStateChanged(state: Int) {
if (lastScrollFired && state == SCROLL_STATE_IDLE) {
latch.countDown()
}
}
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int
) {
if (position == targetPage && positionOffsetPixels == 0) {
latch.countDown()
lastScrollFired = true
}
}
})
return latch
}
fun Context.setAdapterSync(adapterProvider: AdapterProvider) {
lateinit var waitForRenderLatch: CountDownLatch
runOnUiThreadSync {
waitForRenderLatch = viewPager.addWaitForLayoutChangeLatch()
viewPager.adapter = adapterProvider(activity)
}
waitForRenderLatch.await(5, TimeUnit.SECONDS)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.ICE_CREAM_SANDWICH_MR1) {
// Give slow devices some time to warm up,
// to prevent severe frame drops in the smooth scroll
Thread.sleep(1000)
}
}
fun ViewPager2.addWaitForLayoutChangeLatch(): CountDownLatch {
return CountDownLatch(1).also {
addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> it.countDown() }
}
}
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 == targetState) {
latch.countDown()
post { unregisterOnPageChangeCallback(this) }
}
}
})
return latch
}
fun ViewPager2.addWaitForDistanceToTarget(target: Int, distance: Float): CountDownLatch {
val latch = CountDownLatch(1)
registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageScrolled(
position: Int,
positionOffset: Float,
positionOffsetPixels: Int
) {
if (abs(target - position - positionOffset) <= distance) {
latch.countDown()
post { unregisterOnPageChangeCallback(this) }
}
}
})
return latch
}
fun ViewPager2.addWaitForFirstScrollEventLatch(): CountDownLatch {
val latch = CountDownLatch(1)
registerOnPageChangeCallback(object : ViewPager2.OnPageChangeCallback() {
override fun onPageScrolled(position: Int, offset: Float, offsetPx: Int) {
latch.countDown()
post { unregisterOnPageChangeCallback(this) }
}
})
return latch
}
val ViewPager2.linearLayoutManager: LinearLayoutManager
get() = recyclerView.layoutManager as LinearLayoutManager
val ViewPager2.recyclerView: RecyclerView
get() {
return getChildAt(0) as RecyclerView
}
val ViewPager2.currentCompletelyVisibleItem: Int
get() {
var position = RecyclerView.NO_POSITION
activityTestRule.runOnUiThread {
position = linearLayoutManager.findFirstCompletelyVisibleItemPosition()
}
return position
}
/**
* Checks:
* 1. Expected page is the current ViewPager2 page
* 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(),
performSelfCheck: Boolean = true
) {
assertThat<Int>(
"viewPager.getCurrentItem() should return $pageIx",
viewPager.currentItem, equalTo(pageIx)
)
assertThat("viewPager should be IDLE", viewPager.scrollState, equalTo(SCROLL_STATE_IDLE))
if (value != null) {
onView(allOf<View>(withId(R.id.text_view), isCompletelyDisplayed())).check(
matches(withText(value))
)
}
// TODO(b/130153109): Wire offscreenPageLimit into FragmentAdapter, remove performSelfCheck
if (performSelfCheck && viewPager.adapter is SelfChecking) {
(viewPager.adapter as SelfChecking).selfCheck()
}
assertPageActions()
}
fun Context.resetViewPagerTo(page: Int) {
viewPager.setCurrentItemSync(page, false, 2, TimeUnit.SECONDS)
// VP2 was potentially settling while the RetryException was raised,
// in which case we must wait until the IDLE event has been fired
activityTestRule.waitForExecution(1)
}
fun Context.modifyDataSetSync(block: () -> Unit) {
val layoutChangedLatch = viewPager.addWaitForLayoutChangeLatch()
runOnUiThreadSync {
block()
}
layoutChangedLatch.await(1, TimeUnit.SECONDS)
// Let animations run
val animationLatch = CountDownLatch(1)
viewPager.recyclerView.itemAnimator!!.isRunning {
animationLatch.countDown()
}
animationLatch.await(1, TimeUnit.SECONDS)
}
fun ViewPager2.setCurrentItemSync(
targetPage: Int,
smoothScroll: Boolean,
timeout: Long,
unit: TimeUnit,
expectEvents: Boolean = (targetPage != currentItem)
) {
val latch =
if (expectEvents)
addWaitForScrolledLatch(targetPage, smoothScroll)
else
CountDownLatch(1)
post {
setCurrentItem(targetPage, smoothScroll)
if (!expectEvents) {
latch.countDown()
}
}
latch.await(timeout, unit)
}
enum class SortOrder(val sign: Int) {
ASC(1),
DESC(-1)
}
fun <T, R : Comparable<R>> List<T>.assertSorted(selector: (T) -> R) {
assertThat(this, equalTo(this.sortedBy(selector)))
}
/**
* Returns the slice between the first and second element. First and second element are not
* included in the results. Search for the second element starts on the element after the first
* element. If first element is not found, an empty list is returned. If second element is not
* found, all elements after the first are returned.
*
* @return A list with all elements between the first and the second element
*/
fun <T> List<T>.slice(first: T, second: T): List<T> {
return dropWhile { it != first }.drop(1).takeWhile { it != second }
}
/**
* Is between [min, max)
* @param min - inclusive
* @param max - exclusive
*/
fun <T : Comparable<T>> isBetweenInEx(min: T, max: T): Matcher<T> {
return allOf(greaterThanOrEqualTo<T>(min), lessThan<T>(max))
}
/**
* Is between [min, max]
* @param min - inclusive
* @param max - inclusive
*/
fun <T : Comparable<T>> isBetweenInIn(min: T, max: T): Matcher<T> {
return allOf(greaterThanOrEqualTo<T>(min), lessThanOrEqualTo<T>(max))
}
/**
* Is between [min(a, b), max(a, b)]
* @param a - inclusive
* @param b - inclusive
*/
fun <T : Comparable<T>> isBetweenInInMinMax(a: T, b: T): Matcher<T> {
return isBetweenInIn(minOf(a, b), maxOf(a, b))
}
}
typealias AdapterProvider = (TestActivity) -> RecyclerView.Adapter<out RecyclerView.ViewHolder>
typealias AdapterProviderForItems = (items: List<String>) -> AdapterProvider
val fragmentAdapterProvider: AdapterProviderForItems = { items ->
{ activity: TestActivity -> FragmentAdapter(activity, items) }
}
/**
* Same as [fragmentAdapterProvider] but with a custom implementation of
* [FragmentStateAdapter.getItemId] and [FragmentStateAdapter.containsItem].
* Not suitable for testing [RecyclerView.Adapter.notifyDataSetChanged].
*/
val fragmentAdapterProviderCustomIds: AdapterProviderForItems = { items ->
{ activity ->
fragmentAdapterProvider(items)(activity).also {
// more than position can represent, so a good test if ids are used consistently
val offset = 3L * Int.MAX_VALUE
val adapter = it as FragmentAdapter
adapter.positionToItemId = { position -> position + offset }
adapter.itemIdToContains = { itemId ->
val position = itemId - offset
position in (0 until adapter.itemCount)
}
}
}
}
/**
* Same as [fragmentAdapterProvider] but with a custom implementation of
* [FragmentStateAdapter.getItemId] and [FragmentStateAdapter.containsItem].
* Suitable for testing [RecyclerView.Adapter.notifyDataSetChanged].
*/
val fragmentAdapterProviderValueId: AdapterProviderForItems = { items ->
{ activity ->
fragmentAdapterProvider(items)(activity).also {
val adapter = it as FragmentAdapter
adapter.positionToItemId = { position -> items[position].getId() }
adapter.itemIdToContains = { itemId -> items.any { item -> item.getId() == itemId } }
}
}
}
/** Extracts the sole number from a [String] and converts it to a [Long] */
private fun (String).getId(): Long {
val matches = Regex("[0-9]+").findAll(this).toList()
if (matches.size != 1) {
throw IllegalStateException("There should be exactly one number in the input string")
}
return matches.first().value.toLong()
}
/**
* Same as [viewAdapterProvider] but with a custom implementation of
* [RecyclerView.Adapter.getItemId].
* Suitable for testing [RecyclerView.Adapter.notifyDataSetChanged].mu
*/
val viewAdapterProviderValueId: AdapterProviderForItems = { items ->
{ activity ->
viewAdapterProvider(items)(activity).also {
val adapter = it as ViewAdapter
adapter.positionToItemId = { position -> items[position].getId() }
adapter.setHasStableIds(true)
}
}
}
val viewAdapterProvider: AdapterProviderForItems = { items -> { ViewAdapter(items) } }
fun stringSequence(pageCount: Int) = (0 until pageCount).map { it.toString() }
val AdapterProviderForItems.supportsMutations: Boolean
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)})"
}
class RetryException(msg: String) : Exception(msg)
fun tryNTimes(n: Int, resetBlock: () -> Unit, tryBlock: () -> Unit) {
repeat(n) { i ->
try {
tryBlock()
return
} catch (e: RetryException) {
if (i < n - 1) {
Log.w(BaseTest.TAG, "Bad state, retrying block", e)
} else {
throw AssertionError("Block hit bad state $n times", e)
}
resetBlock()
}
}
}
val View.isRtl: Boolean
get() = ViewCompat.getLayoutDirection(this) == ViewCompat.LAYOUT_DIRECTION_RTL
val ViewPager2.isHorizontal: Boolean get() = orientation == ORIENTATION_HORIZONTAL