Skip to content

Commit

Permalink
Merge pull request #4327 from wix/bugfix/2787-scrollto-gets-stuck-on-…
Browse files Browse the repository at this point in the history
…android

Bugfix/2787 scrollto gets stuck on android 
Fixes #2787 and  #4312
  • Loading branch information
gosha212 authored Jan 10, 2024
2 parents c46ee50 + db80ee7 commit 275fee9
Show file tree
Hide file tree
Showing 3 changed files with 296 additions and 13 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ object DeviceDisplay {
}

@JvmStatic
fun getScreenSizeInPX(): FloatArray? {
fun getScreenSizeInPX(): FloatArray {
val metrics = getDisplayMetrics()
return floatArrayOf(metrics.widthPixels.toFloat(), metrics.heightPixels.toFloat())
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package com.wix.detox.espresso.scroll;

import android.content.Context;
import android.graphics.Insets;
import android.graphics.Point;
import android.os.Build;
import android.util.Log;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.WindowInsets;

import com.wix.detox.action.common.MotionDir;
import com.wix.detox.espresso.DeviceDisplay;

import androidx.annotation.VisibleForTesting;
import androidx.test.espresso.UiController;
import androidx.test.platform.app.InstrumentationRegistry;

Expand Down Expand Up @@ -51,7 +55,7 @@ public static void perform(UiController uiController, View view, @MotionDir int
final int times = amountInPx / safeScrollableRangePx;
final int remainder = amountInPx % safeScrollableRangePx;

Log.d(LOG_TAG, "prescroll amountDP="+amountInDP + " amountPx="+amountInPx + " scrollableRangePx="+safeScrollableRangePx + " times="+times + " remainder="+remainder);
Log.d(LOG_TAG, "prescroll amountDP=" + amountInDP + " amountPx=" + amountInPx + " scrollableRangePx=" + safeScrollableRangePx + " times=" + times + " remainder=" + remainder);

for (int i = 0; i < times; ++i) {
scrollOnce(uiController, view, direction, safeScrollableRangePx, startOffsetPercentX, startOffsetPercentY);
Expand Down Expand Up @@ -115,25 +119,32 @@ private static void waitForFlingToFinish(View view, UiController uiController) {
}
}

private static int getViewSafeScrollableRangePix(View view, @MotionDir int direction) {
@VisibleForTesting
public static int getViewSafeScrollableRangePix(View view, @MotionDir int direction) {
final float[] screenSize = DeviceDisplay.getScreenSizeInPX();
final int[] pos = new int[2];
view.getLocationInWindow(pos);

int range;
switch (direction) {
case MOTION_DIR_LEFT: range = (int) ((screenSize[0] - pos[0]) * SCROLL_RANGE_SAFE_PERCENT); break;
case MOTION_DIR_RIGHT: range = (int) ((pos[0] + view.getWidth()) * SCROLL_RANGE_SAFE_PERCENT); break;
case MOTION_DIR_UP: range = (int) ((screenSize[1] - pos[1]) * SCROLL_RANGE_SAFE_PERCENT); break;
default: range = (int) ((pos[1] + view.getHeight()) * SCROLL_RANGE_SAFE_PERCENT); break;
case MOTION_DIR_LEFT:
range = (int) ((screenSize[0] - pos[0]) * SCROLL_RANGE_SAFE_PERCENT);
break;
case MOTION_DIR_RIGHT:
range = (int) ((pos[0] + view.getWidth()) * SCROLL_RANGE_SAFE_PERCENT);
break;
case MOTION_DIR_UP:
range = (int) ((screenSize[1] - pos[1]) * SCROLL_RANGE_SAFE_PERCENT);
break;
default:
range = (int) ((pos[1] + view.getHeight()) * SCROLL_RANGE_SAFE_PERCENT);
break;
}
return range;
}

private static Point getScrollStartPoint(View view, @MotionDir int direction, Float startOffsetPercentX, Float startOffsetPercentY) {
private static int[] getScrollStartOffsetInView(View view, @MotionDir int direction, Float startOffsetPercentX, Float startOffsetPercentY) {
final int safetyOffset = DeviceDisplay.convertDpiToPx(1);

Point point = getGlobalViewLocation(view);
float offsetFactorX;
float offsetFactorY;
int safetyOffsetX;
Expand Down Expand Up @@ -171,8 +182,87 @@ private static Point getScrollStartPoint(View view, @MotionDir int direction, Fl
int offsetX = ((int) (view.getWidth() * offsetFactorX) + safetyOffsetX);
int offsetY = ((int) (view.getHeight() * offsetFactorY) + safetyOffsetY);

point.offset(offsetX, offsetY);
return point;
return new int[]{offsetX, offsetY};
}

/**
* Calculates the scroll start point, with respect to the global screen coordinates and gesture insets.
* @param view The view to scroll.
* @param direction The scroll direction.
* @param startOffsetPercentX The scroll start offset, as a percentage of the view's width. Null means select automatically.
* @param startOffsetPercentY The scroll start offset, as a percentage of the view's height. Null means select automatically.
* @return a Point object, denoting the scroll start point.
*/
private static Point getScrollStartPoint(View view, @MotionDir int direction, Float startOffsetPercentX, Float startOffsetPercentY) {
Point result = getGlobalViewLocation(view);

// 1. Calculate the scroll start point, with respect to the view's location.
int[] coordinates = getScrollStartOffsetInView(view, direction, startOffsetPercentX, startOffsetPercentY);

// 2. Make sure that the start point is within the scrollable area, taking into account the system gesture insets.
coordinates = applyScreenInsets(view, direction, coordinates[0], coordinates[1]);

result.offset(coordinates[0], coordinates[1]);
return result;
}

/**
* Calculates the scroll start point, with respect to the system gesture insets.
* @param view
* @param direction The scroll direction.
* @param x The scroll start point, with respect to the view's location.
* @param y The scroll start point, with respect to the view's location.
* @return an array of two integers, denoting the scroll start point, with respect to the system gesture insets.
*/
private static int[] applyScreenInsets(View view, int direction, int x, int y) {
// System gesture insets are only available on Android Q (29) and above.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
return new int[]{x, y};
}

final float[] displaySize = DeviceDisplay.getScreenSizeInPX();

// Calculate the min/max scrollable area, taking into account the system gesture insets.
// By default we assume the scrollable area is the entire screen.
// 2dp is a safety offset to make sure we don't hit the system gesture area.
int gestureSafeOffset = DeviceDisplay.convertDpiToPx(2);
int minX = gestureSafeOffset;
int minY = gestureSafeOffset;
float maxX = displaySize[0] - gestureSafeOffset;
float maxY = displaySize[1] - gestureSafeOffset;

// Try to get the root window insets, and if available, use them to calculate the scrollable area.
WindowInsets rootWindowInsets = view.getRootWindowInsets();
if (rootWindowInsets == null) {
Log.w(LOG_TAG, "Could not get root window insets");
} else {
Insets gestureInsets = rootWindowInsets.getSystemGestureInsets();
minX = gestureInsets.left;
minY = gestureInsets.top;
maxX -= gestureInsets.right;
maxY -= gestureInsets.bottom;

Log.d(LOG_TAG,
"System gesture insets: " +
gestureInsets + " minX=" + minX + " minY=" + minY + " maxX=" + maxX + " maxY=" + maxY + " currentX=" + x + " currentY=" + y);
}

switch (direction) {
case MOTION_DIR_UP:
y = (int) Math.max(y, minY);
break;
case MOTION_DIR_DOWN:
y = (int) Math.min(y, maxY);
break;
case MOTION_DIR_LEFT:
x = (int) Math.max(x, minX);
break;
case MOTION_DIR_RIGHT:
x = (int) Math.min(x, maxX);
break;
}

return new int[]{x, y};
}

private static Point getScrollEndPoint(Point startPoint, @MotionDir int direction, int userAmountPx, Float startOffsetPercentX, Float startOffsetPercentY) {
Expand Down Expand Up @@ -211,13 +301,18 @@ private static Point getScrollEndPoint(Point startPoint, @MotionDir int directio
return point;
}

/**
* Calculates the global location of the view on the screen.
* @param view The view to calculate.
* @return a Point object, denoting the global location of the view.
*/
private static Point getGlobalViewLocation(View view) {
int[] pos = new int[2];
view.getLocationInWindow(pos);
return new Point(pos[0], pos[1]);
}

private static ViewConfiguration getViewConfiguration() {
public static ViewConfiguration getViewConfiguration() {
if (viewConfiguration == null) {
final Context applicationContext = InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext();
viewConfiguration = ViewConfiguration.get(applicationContext);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package com.wix.detox.espresso.scroll

import android.graphics.Insets
import android.view.MotionEvent
import android.view.View
import android.view.WindowInsets
import androidx.test.espresso.UiController
import androidx.test.platform.app.InstrumentationRegistry
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.DeviceDisplay
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.kotlin.whenever
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals

private const val INSETS_SIZE = 100
private const val SCROLL_RANGE_SAFE_PERCENT = 0.9f // ScrollHelper.SCROLL_RANGE_SAFE_PERCENT

@Config(
qualifiers = "xxxhdpi", // 1280x1880
sdk = [33]
)
@RunWith(RobolectricTestRunner::class)
class ScrollHelperTest {

private val display = DeviceDisplay.getScreenSizeInPX()
private val displayWidth = display[0].toInt()
private val displayHeight = display[1].toInt()
private val touchSlopPx = ScrollHelper.getViewConfiguration().scaledTouchSlop
private val safetyMarginPx = DeviceDisplay.convertDpiToPx(2.0)

private val uiControllerMock = mock<UiController>()
private val viewMock = mockViewWithGestureNavigation(displayWidth, displayHeight)

@Test
fun `should scrolling down by 200 when gesture navigation enabled`() {
val amountInDp = 200.0
val amountInPx = amountInDp * DeviceDisplay.getDensity()

ScrollHelper.perform(uiControllerMock, viewMock, MOTION_DIR_DOWN, amountInDp, null, null)

val upEvent = getUpEvent()
// Verify that the scroll started at the center of the view
assertEquals(displayWidth / 2.0, upEvent.x.toDouble(), 0.0)
// Verify that the scroll ended at the center of the view minus the requested amount
assertEquals(displayHeight - amountInPx - touchSlopPx - safetyMarginPx - INSETS_SIZE, upEvent.y.toDouble(), 0.0)
}

@Test
fun `should scrolling down by 200 when gesture navigation disabled`() {
val amountInDp = 200.0
val amountInPx = amountInDp * DeviceDisplay.getDensity()

val viewMock = mockViewWithoutGestureNavigation(displayWidth, displayHeight)
ScrollHelper.perform(uiControllerMock, viewMock, MOTION_DIR_DOWN, amountInDp, null, null)

val upEvent = getUpEvent()
// Verify that the scroll started at the center of the view
assertEquals(displayWidth / 2.0, upEvent.x.toDouble(), 0.0)
// Verify that the scroll ended at the center of the view minus the requested amount
assertEquals(displayHeight - amountInPx - touchSlopPx - safetyMarginPx, upEvent.y.toDouble(), 0.0)
}

@Test
fun `should scroll down to edge on full screen view when gesture navigation enabled`() {
ScrollHelper.performOnce(uiControllerMock, viewMock, MOTION_DIR_DOWN, null, null)
val upEvent = getUpEvent()
val amountInPx = displayHeight * SCROLL_RANGE_SAFE_PERCENT

// Calculate where the scroll should end
val targetY = displayHeight - amountInPx -
touchSlopPx -
safetyMarginPx -
INSETS_SIZE

assertEquals(displayWidth / 2.0, upEvent.x.toDouble(), 0.0)
assertEquals(targetY, upEvent.y, 0.0f)
}

@Test
fun `should scroll left to edge on full screen view when gesture navigation enabled`() {
ScrollHelper.performOnce(uiControllerMock, viewMock, MOTION_DIR_LEFT, null, null)
val upEvent = getUpEvent()
val amountInPx = displayWidth * SCROLL_RANGE_SAFE_PERCENT

// Calculate where the scroll should end
val targetX = amountInPx +
touchSlopPx +
INSETS_SIZE

assertEquals(targetX, upEvent.x, 0.0f)
assertEquals(displayHeight / 2.0, upEvent.y.toDouble(), 0.0)
}

@Test
fun `should scroll up to edge on full screen view when gesture navigation enabled`() {
ScrollHelper.performOnce(uiControllerMock, viewMock, MOTION_DIR_UP,null, null)
val upEvent = getUpEvent()
val amountInPx = displayHeight * SCROLL_RANGE_SAFE_PERCENT

// Calculate where the scroll should end
val targetY = amountInPx +
touchSlopPx +
INSETS_SIZE

assertEquals(displayWidth / 2.0, upEvent.x.toDouble(), 0.0)
assertEquals(targetY, upEvent.y, 0.0f)
}

@Test
fun `should scroll right to edge on full screen view when gesture navigation enabled`() {
ScrollHelper.performOnce(uiControllerMock, viewMock, MOTION_DIR_RIGHT, null, null)
val upEvent = getUpEvent()
val amountInPx = displayWidth * SCROLL_RANGE_SAFE_PERCENT

// Calculate where the scroll should end
val targetX = displayWidth - amountInPx -
touchSlopPx -
safetyMarginPx -
INSETS_SIZE

assertEquals(targetX, upEvent.x, 0.0f)
assertEquals(displayHeight / 2.0, upEvent.y.toDouble(), 0.0)
}

/**
* Get the performed UP event from the ui controller
*/
private fun getUpEvent(): MotionEvent {
val capture = argumentCaptor<Iterable<MotionEvent>>()
// Capture the events from the ui controller
verify(uiControllerMock).injectMotionEventSequence(capture.capture())

val listOfCapturedEvents = capture.firstValue.toList()
// The last event is the UP event with the target coordinates. All of the rest are not interesting
return listOfCapturedEvents.last()
}

private fun mockViewWithoutGestureNavigation(displayWidth: Int, displayHeight: Int): View {
// This is how we disable gesture navigation
val windowInsets = mock<WindowInsets>() {
whenever(it.systemGestureInsets).thenReturn(
Insets.of(0, 0, 0, 0)
)
}

return mockView(displayWidth, displayHeight, windowInsets)
}

/**
* Mock a view with gesture navigation enabled
*/
private fun mockViewWithGestureNavigation(displayWidth: Int, displayHeight: Int): View {
// This is how we enable gesture navigation
val windowInsets = mock<WindowInsets>() {
whenever(it.systemGestureInsets).thenReturn(
Insets.of(INSETS_SIZE, INSETS_SIZE, INSETS_SIZE, INSETS_SIZE)
)
}

return mockView(displayWidth, displayHeight, windowInsets)
}

private fun mockView(
displayWidth: Int,
displayHeight: Int,
windowInsets: WindowInsets
): View {
val view = mock<View>() {
whenever(it.width).thenReturn(displayWidth)
whenever(it.height).thenReturn(displayHeight)
whenever(it.canScrollVertically(any())).thenReturn(true) // We allow endless scroll
whenever(it.canScrollHorizontally(any())).thenReturn(true) // We allow endless scroll
whenever(it.context).thenReturn(InstrumentationRegistry.getInstrumentation().targetContext)
whenever(it.rootWindowInsets).thenReturn(windowInsets)
}
return view
}
}

0 comments on commit 275fee9

Please sign in to comment.