Skip to content

Commit

Permalink
Improve scroll speed and add scrollToIndex (#2854)
Browse files Browse the repository at this point in the history
  • Loading branch information
jonathanmos authored Jul 22, 2021
1 parent 000bcb7 commit 2c6478d
Show file tree
Hide file tree
Showing 11 changed files with 225 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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.
*/
Expand All @@ -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
*/
Expand All @@ -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<String>() {
private final TakeViewScreenshotAction action = new TakeViewScreenshotAction();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<View> {
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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())
}
}
}
Expand Down Expand Up @@ -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") {
Expand Down
8 changes: 8 additions & 0 deletions detox/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,14 @@ declare global {
startPositionY?: number,
): Promise<void>;

/**
* Scroll to index.
* @example await element(by.id('scrollView')).scrollToIndex(10);
*/
scrollToIndex(
index: Number
): Promise<void>;

/**
* Scroll to edge.
* @example await element(by.id('scrollView')).scrollTo('bottom');
Expand Down
1 change: 1 addition & 0 deletions detox/src/android/AndroidExpect.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 8 additions & 0 deletions detox/src/android/actions/native.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -140,4 +147,5 @@ module.exports = {
ScrollEdgeAction,
SwipeAction,
TakeElementScreenshot,
ScrollToIndex,
};
5 changes: 5 additions & 0 deletions detox/src/android/core/NativeElement.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
15 changes: 15 additions & 0 deletions detox/src/android/espressoapi/DetoxAction.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
32 changes: 32 additions & 0 deletions detox/test/e2e/03.actions-scroll.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
12 changes: 11 additions & 1 deletion docs/APIRef.ActionsOnElement.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 <br/>

```js
await element(by.id('scrollView')).scrollToIndex(0);
```
### `scroll(offset, direction, startPositionX, startPositionY)`

Simulates a scroll on the element with the provided options.
Expand Down Expand Up @@ -318,4 +328,4 @@ Simulates a pinch on the element with the provided options.

```js
await element(by.id('PinchableScrollView')).pinchWithAngle('outward', 'slow', 0);
```
```

0 comments on commit 2c6478d

Please sign in to comment.