diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java index 2cced13260..00a2c3a415 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java @@ -7,6 +7,7 @@ import com.wix.detox.espresso.action.DetoxMultiTap; import com.wix.detox.espresso.action.RNClickAction; import com.wix.detox.espresso.action.ScreenshotResult; +import com.wix.detox.espresso.action.ScrollToIndexAction; import com.wix.detox.espresso.action.TakeViewScreenshotAction; import com.wix.detox.espresso.action.GetAttributesAction; import com.wix.detox.action.common.MotionDir; @@ -97,8 +98,8 @@ public void perform(UiController uiController, View view) { /** * Scrolls the View in a direction by the Density Independent Pixel amount. * - * @param direction Direction to scroll (see {@link MotionDir}) - * @param amountInDP Density Independent Pixels + * @param direction Direction to scroll (see {@link MotionDir}) + * @param amountInDP Density Independent Pixels * @param startOffsetPercentX Percentage denoting where X-swipe should start, with respect to the scrollable view. * @param startOffsetPercentY Percentage denoting where Y-swipe should start, with respect to the scrollable view. */ @@ -114,8 +115,8 @@ public static ViewAction scrollInDirection(final int direction, final double amo * where the scrolling-edge is reached, by throwing the {@link StaleActionException} exception (i.e. * so as to make this use case manageable by the user). * - * @param direction Direction to scroll (see {@link MotionDir}) - * @param amountInDP Density Independent Pixels + * @param direction Direction to scroll (see {@link MotionDir}) + * @param amountInDP Density Independent Pixels * @param startOffsetPercentX Percentage denoting where X-swipe should start, with respect to the scrollable view. * @param startOffsetPercentY Percentage denoting where Y-swipe should start, with respect to the scrollable view. */ @@ -128,9 +129,9 @@ public static ViewAction scrollInDirectionStaleAtEdge(final int direction, final /** * Swipes the View in a direction. * - * @param direction Direction to swipe (see {@link MotionDir}) - * @param fast true if fast, false if slow - * @param normalizedOffset or "swipe amount" between 0.0 and 1.0, relative to the screen width/height + * @param direction Direction to swipe (see {@link MotionDir}) + * @param fast true if fast, false if slow + * @param normalizedOffset or "swipe amount" between 0.0 and 1.0, relative to the screen width/height * @param normalizedStartingPointX X coordinate of swipe starting point (between 0.0 and 1.0), relative to the view width * @param normalizedStartingPointY Y coordinate of swipe starting point (between 0.0 and 1.0), relative to the view height */ @@ -143,6 +144,10 @@ public static ViewAction getAttributes() { return new GetAttributesAction(); } + public static ViewAction scrollToIndex(int index) { + return new ScrollToIndexAction(index); + } + public static ViewAction takeViewScreenshot() { return new ViewActionWithResult() { private final TakeViewScreenshotAction action = new TakeViewScreenshotAction(); diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/action/ScrollToIndexAction.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/action/ScrollToIndexAction.kt new file mode 100644 index 0000000000..65f37cd804 --- /dev/null +++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/action/ScrollToIndexAction.kt @@ -0,0 +1,126 @@ +package com.wix.detox.espresso.action + +import android.view.View +import android.view.ViewGroup +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.matcher.ViewMatchers +import com.facebook.react.views.scroll.ReactHorizontalScrollView +import com.facebook.react.views.scroll.ReactScrollView +import com.wix.detox.action.common.MOTION_DIR_DOWN +import com.wix.detox.action.common.MOTION_DIR_LEFT +import com.wix.detox.action.common.MOTION_DIR_RIGHT +import com.wix.detox.action.common.MOTION_DIR_UP +import com.wix.detox.espresso.scroll.ScrollEdgeException +import com.wix.detox.espresso.scroll.ScrollHelper +import org.hamcrest.Matcher +import org.hamcrest.Matchers + +class ScrollToIndexAction(private val index: Int) : ViewAction { + override fun getConstraints(): Matcher { + return Matchers.anyOf( + Matchers.allOf( + ViewMatchers.isAssignableFrom( + View::class.java + ), Matchers.instanceOf( + ReactScrollView::class.java + ) + ), + Matchers.allOf( + ViewMatchers.isAssignableFrom( + View::class.java + ), Matchers.instanceOf(ReactHorizontalScrollView::class.java) + ) + ) + } + + override fun getDescription(): String { + return "scrollToIndex" + } + + override fun perform(uiController: UiController?, view: View?) { + if (index < 0) return + + val offsetPercent = 0.4f + val reactScrollView = view as ViewGroup + val internalContainer = reactScrollView.getChildAt(0) as ViewGroup + val childCount = internalContainer.childCount + if (index >= childCount) return + + val isHorizontalScrollView = getIsHorizontalScrollView(reactScrollView) + val targetPosition = getTargetPosition(isHorizontalScrollView, internalContainer, index) + var currentPosition = getCurrentPosition(isHorizontalScrollView, reactScrollView) + val jumpSize = getTargetDimension(isHorizontalScrollView, internalContainer, index) + val scrollDirection = + getScrollDirection(isHorizontalScrollView, currentPosition, targetPosition) + + // either we'll find the target view or we'll hit the edge of the scrollview + + // either we'll find the target view or we'll hit the edge of the scrollview + while (true) { + if (Math.abs(currentPosition - targetPosition) < jumpSize) { + // we found the target view + return + } + currentPosition = try { + ScrollHelper.perform( + uiController, + view, + scrollDirection, + jumpSize.toDouble(), + offsetPercent, + offsetPercent + ) + getCurrentPosition(isHorizontalScrollView, reactScrollView) + } catch (e: ScrollEdgeException) { + // we hit the edge + return + } + } + } + +} + +private fun getScrollDirection( + isHorizontalScrollView: Boolean, + currentPosition: Int, + targetPosition: Int +): Int { + return if (isHorizontalScrollView) { + if (currentPosition < targetPosition) MOTION_DIR_RIGHT else MOTION_DIR_LEFT + } else { + if (currentPosition < targetPosition) MOTION_DIR_DOWN else MOTION_DIR_UP + } +} + +private fun getIsHorizontalScrollView(scrollView: ViewGroup): Boolean { + return scrollView.canScrollHorizontally(1) || scrollView.canScrollHorizontally(-1) +} + +private fun getCurrentPosition(isHorizontalScrollView: Boolean, scrollView: ViewGroup): Int { + return if (isHorizontalScrollView) scrollView.scrollX else scrollView.scrollY +} + +private fun getTargetDimension( + isHorizontalScrollView: Boolean, + internalContainer: ViewGroup, + index: Int +): Int { + return if (isHorizontalScrollView) internalContainer.getChildAt(index).measuredWidth else internalContainer.getChildAt( + index + ).measuredHeight +} + +private fun getTargetPosition( + isHorizontalScrollView: Boolean, + internalContainer: ViewGroup, + index: Int +): Int { + var necessaryTarget = 0 + for (childIndex in 0 until index) { + necessaryTarget += if (isHorizontalScrollView) internalContainer.getChildAt(childIndex).measuredWidth else internalContainer.getChildAt( + childIndex + ).measuredHeight + } + return necessaryTarget +} \ No newline at end of file diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/FlinglessSwiper.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/FlinglessSwiper.kt index 3a7f271f2d..c43c35ce1e 100644 --- a/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/FlinglessSwiper.kt +++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/FlinglessSwiper.kt @@ -92,7 +92,7 @@ class FlinglessSwiper @JvmOverloads constructor( companion object { // private const val LOG_TAG = "DetoxBatchedSwiper" - private const val VELOCITY_SAFETY_RATIO = .85f + private const val VELOCITY_SAFETY_RATIO = .99f private const val FAST_EVENTS_RATIO = .75f } } diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/scroll/FlinglessSwiperSpec.kt b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/scroll/FlinglessSwiperSpec.kt index ba6a24ca88..a790df23b0 100644 --- a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/scroll/FlinglessSwiperSpec.kt +++ b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/scroll/FlinglessSwiperSpec.kt @@ -66,10 +66,10 @@ object FlinglessSwiperSpec: Spek({ } describe("move") { - val SWIPER_VELOCITY = 85f + val SWIPER_VELOCITY = 99f beforeEachTest { - whenever(viewConfig.scaledMinimumFlingVelocity).doReturn(100) // i.e. we expect the swiper to apply a safety margin of 85%, hence actual velocity = 85 px/sec + whenever(viewConfig.scaledMinimumFlingVelocity).doReturn(100) // i.e. we expect the swiper to apply a safety margin of 99%, hence actual velocity = 99 px/sec } it("should obtain a move event") { @@ -121,7 +121,7 @@ object FlinglessSwiperSpec: Spek({ with(uut()) { startAt(0f, 0f) - moveTo(0f, 42.5f) + moveTo(0f, SWIPER_VELOCITY/2) } verify(motionEvents).obtainMoveEvent(any(), eq(expectedEventTime), any(), any()) @@ -162,7 +162,7 @@ object FlinglessSwiperSpec: Spek({ } verify(motionEvents, times(3)).obtainMoveEvent(any(), eq(swipeStartTime + 10L), any(), any()) - verify(motionEvents, times(1)).obtainMoveEvent(any(), eq(swipeStartTime + 1000L), any(), any()) + verify(motionEvents, times(1)).obtainMoveEvent(any(), eq(swipeStartTime + 858), any(), any()) } } } @@ -190,9 +190,9 @@ object FlinglessSwiperSpec: Spek({ with(uut()) { startAt(666f, 999f) - finishAt(666f + 85f, 999f + 85f) + finishAt(666f + 99f, 999f + 99f) } - verify(motionEvents).obtainUpEvent(downEvent, expectedEventTime, 666f + 85f, 999f + 85f) + verify(motionEvents).obtainUpEvent(downEvent, expectedEventTime, 666f + 99f, 999f + 99f) } it("should finish by flushing all events to ui controller") { diff --git a/detox/index.d.ts b/detox/index.d.ts index caf66cf2a5..1cb26a88a0 100644 --- a/detox/index.d.ts +++ b/detox/index.d.ts @@ -1032,6 +1032,14 @@ declare global { startPositionY?: number, ): Promise; + /** + * Scroll to index. + * @example await element(by.id('scrollView')).scrollToIndex(10); + */ + scrollToIndex( + index: Number + ): Promise; + /** * Scroll to edge. * @example await element(by.id('scrollView')).scrollTo('bottom'); diff --git a/detox/src/android/AndroidExpect.test.js b/detox/src/android/AndroidExpect.test.js index ea1562e03a..0ff4a8570d 100644 --- a/detox/src/android/AndroidExpect.test.js +++ b/detox/src/android/AndroidExpect.test.js @@ -189,6 +189,7 @@ describe('AndroidExpect', () => { await e.element(e.by.id('ScrollView161')).scrollTo('top'); await e.element(e.by.id('ScrollView161')).scrollTo('left'); await e.element(e.by.id('ScrollView161')).scrollTo('right'); + await e.element(e.by.id('ScrollView161')).scrollToIndex(0); }); it('should not scroll given bad args', async () => { diff --git a/detox/src/android/actions/native.js b/detox/src/android/actions/native.js index cc9ba88774..6543b766ea 100644 --- a/detox/src/android/actions/native.js +++ b/detox/src/android/actions/native.js @@ -117,6 +117,13 @@ class GetAttributes extends Action { } } +class ScrollToIndex extends Action { + constructor(index) { + super(); + this._call = invoke.callDirectly(DetoxActionApi.scrollToIndex(index)); + } +} + class TakeElementScreenshot extends Action { constructor() { super(); @@ -140,4 +147,5 @@ module.exports = { ScrollEdgeAction, SwipeAction, TakeElementScreenshot, + ScrollToIndex, }; diff --git a/detox/src/android/core/NativeElement.js b/detox/src/android/core/NativeElement.js index 5c5da7fea1..b8cae37c26 100644 --- a/detox/src/android/core/NativeElement.js +++ b/detox/src/android/core/NativeElement.js @@ -79,6 +79,11 @@ class NativeElement { return await new ActionInteraction(this._invocationManager, this, new actions.ScrollEdgeAction(edge)).execute(); } + async scrollToIndex(index) { + this._selectElementWithMatcher(this._originalMatcher._extendToDescendantScrollViews()); + return await new ActionInteraction(this._invocationManager, this, new actions.ScrollToIndex(index)).execute(); + } + /** * @param {'up' | 'right' | 'down' | 'left'} direction * @param {'slow' | 'fast'} [speed] diff --git a/detox/src/android/espressoapi/DetoxAction.js b/detox/src/android/espressoapi/DetoxAction.js index 0992e62d56..104f7549cb 100644 --- a/detox/src/android/espressoapi/DetoxAction.js +++ b/detox/src/android/espressoapi/DetoxAction.js @@ -179,6 +179,21 @@ class DetoxAction { }; } + static scrollToIndex(index) { + if (typeof index !== "number") throw new Error("index should be a number, but got " + (index + (" (" + (typeof index + ")")))); + return { + target: { + type: "Class", + value: "com.wix.detox.espresso.DetoxAction" + }, + method: "scrollToIndex", + args: [{ + type: "Integer", + value: index + }] + }; + } + static takeViewScreenshot() { return { target: { diff --git a/detox/test/e2e/03.actions-scroll.test.js b/detox/test/e2e/03.actions-scroll.test.js index 0a2e76b700..738c5bb8f9 100644 --- a/detox/test/e2e/03.actions-scroll.test.js +++ b/detox/test/e2e/03.actions-scroll.test.js @@ -71,4 +71,36 @@ describe('Actions - Scroll', () => { await element(by.id('toggleScrollOverlays')).tap(); await expect(element(by.text('HText6'))).not.toBeVisible(); }); + + it(':android: should be able to scrollToIndex on horizontal scrollviews', async () => { + // should ignore out of bounds children + await element(by.id('ScrollViewH')).scrollToIndex(3000); + await element(by.id('ScrollViewH')).scrollToIndex(-1); + await expect(element(by.text('HText1'))).toBeVisible(); + + await expect(element(by.text('HText8'))).not.toBeVisible(); + await element(by.id('ScrollViewH')).scrollToIndex(7); + await expect(element(by.text('HText8'))).toBeVisible(); + await expect(element(by.text('HText1'))).not.toBeVisible(); + + await element(by.id('ScrollViewH')).scrollToIndex(0); + await expect(element(by.text('HText1'))).toBeVisible(); + await expect(element(by.text('HText8'))).not.toBeVisible(); + }); + + it(':android: should be able to scrollToIndex on vertical scrollviews', async () => { + // should ignore out of bounds children + await element(by.id('ScrollView161')).scrollToIndex(3000); + await element(by.id('ScrollView161')).scrollToIndex(-1); + await expect(element(by.text('Text1'))).toBeVisible(); + + await element(by.id('ScrollView161')).scrollToIndex(11); + await expect(element(by.text('Text12'))).toBeVisible(); + + await element(by.id('ScrollView161')).scrollToIndex(0); + await expect(element(by.text('Text1'))).toBeVisible(); + + await element(by.id('ScrollView161')).scrollToIndex(7); + await expect(element(by.text('Text8'))).toBeVisible(); + }); }); diff --git a/docs/APIRef.ActionsOnElement.md b/docs/APIRef.ActionsOnElement.md index 2c7be12670..e0e1bea184 100644 --- a/docs/APIRef.ActionsOnElement.md +++ b/docs/APIRef.ActionsOnElement.md @@ -12,6 +12,7 @@ Use [expectations](APIRef.Expect.md) to verify element states. - [`.longPressAndDrag()`](#longpressanddragduration-normalizedpositionx-normalizedpositiony-targetelement-normalizedtargetpositionx-normalizedtargetpositiony-speed-holdduration--ios-only) **iOS only** - [`.swipe()`](#swipedirection-speed-normalizedoffset-normalizedstartingpointx-normalizedstartingpointy) - [`.pinch()`](#pinchscale-speed-angle--ios-only) **iOS only** +- [`.scrollToIndex()`](#scrolltoindexindex--android-only) **Android only** - [`.scroll()`](#scrolloffset-direction-startpositionx-startpositiony) - [`whileElement()`](#whileelementelement) - [`.scrollTo()`](#scrolltoedge) @@ -106,6 +107,15 @@ await element(by.id('PinchableScrollView')).pinch(1.1); //Zooms in a little bit await element(by.id('PinchableScrollView')).pinch(2.0); //Zooms in a lot await element(by.id('PinchableScrollView')).pinch(0.001); //Zooms out a lot ``` +### `scrollToIndex(index)` Android only + +Scrolls until it reaches the element with the provided index. This works for ReactScrollView and ReactHorizontalScrollView. + +`index`—the index of the target element
+ +```js +await element(by.id('scrollView')).scrollToIndex(0); +``` ### `scroll(offset, direction, startPositionX, startPositionY)` Simulates a scroll on the element with the provided options. @@ -318,4 +328,4 @@ Simulates a pinch on the element with the provided options. ```js await element(by.id('PinchableScrollView')).pinchWithAngle('outward', 'slow', 0); -``` \ No newline at end of file +```