Skip to content

Commit

Permalink
#2787 $4312
Browse files Browse the repository at this point in the history
Fixing scroll interfere with system gesture navigation on android 33-34
  • Loading branch information
gosha212 committed Jan 4, 2024
1 parent 18cdc36 commit 60bb394
Show file tree
Hide file tree
Showing 2 changed files with 127 additions and 66 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
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 @@ -43,8 +44,8 @@ private ScrollHelper() {
/**
* 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. Null means select automatically.
* @param startOffsetPercentY Percentage denoting where Y-swipe should start, with respect to the scrollable view. Null means select automatically.
*/
Expand All @@ -54,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 @@ -116,25 +117,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[] getScrollStartCoordinatesInView(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 @@ -172,52 +180,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);

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 = getScrollStartCoordinatesInView(view, direction, startOffsetPercentX, startOffsetPercentY);

// 2. Make sure that the start point is within the scrollable area, taking into account the system gesture insets.
coordinates = calculateScrollStartWithNavigationGestureInsets(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[] calculateScrollStartWithNavigationGestureInsets(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 view.
int gestureSafeOffset = DeviceDisplay.convertDpiToPx(1) * 3;
// 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;

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// System gesture insets are only available on Android Q (29) and above.
WindowInsets rootWindowInsets = view.getRootWindowInsets();
if (rootWindowInsets == null) {
Log.w(LOG_TAG, "Could not get root window insets. Using default calculation.");
} 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 + " offsetX=" + offsetX + " offsetY=" + offsetY);
}
// 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:
offsetY = (int) Math.max(offsetY, minY);
y = (int) Math.max(y, minY);
break;
case MOTION_DIR_DOWN:
offsetY = (int) Math.min(offsetY, maxY);
y = (int) Math.min(y, maxY);
break;
case MOTION_DIR_LEFT:
offsetX = (int) Math.max(offsetX, minX);
x = (int) Math.max(x, minX);
break;
case MOTION_DIR_RIGHT:
offsetX = (int) Math.min(offsetX, maxX);
x = (int) Math.min(x, maxX);
break;
}

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

private static Point getScrollEndPoint(Point startPoint, @MotionDir int direction, int userAmountPx, Float startOffsetPercentX, Float startOffsetPercentY) {
Expand Down Expand Up @@ -256,6 +299,11 @@ 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.test.espresso.UiController
import androidx.test.platform.app.InstrumentationRegistry
import com.wix.detox.action.common.MOTION_DIR_DOWN
import com.wix.detox.espresso.DeviceDisplay
import com.wix.detox.espresso.scroll.ScrollHelper.getViewSafeScrollableRangePix
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.any
Expand All @@ -19,61 +20,73 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import kotlin.test.assertEquals

private const val INSETS_SIZE = 100

@Config(qualifiers = "xxxhdpi", 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 uiController = mock<UiController>()
private val view = mockView(displayWidth, displayHeight)
private val uiControllerMock = mock<UiController>()
private val viewMock = mockViewWithGestureNavigation(displayWidth, displayHeight)

@Test
fun `perform scroll down for 200 dp on full screen view`() {
fun `perform scroll down for 200 dp on full screen view with gesture navigation enabled`() {
val amountInDp = 200.0
val amountInPx = amountInDp * DeviceDisplay.getDensity()
val touchSlopPx = ScrollHelper.getViewConfiguration().scaledTouchSlop

ScrollHelper.perform(uiController, view, MOTION_DIR_DOWN, amountInDp, null, null)
val capture = argumentCaptor<Iterable<MotionEvent>>()
verify(uiController).injectMotionEventSequence(capture.capture())
// Perform the scroll
ScrollHelper.perform(uiControllerMock, viewMock, MOTION_DIR_DOWN, amountInDp, null, null)

val listOfCapturedEvents = capture.firstValue.toList()
val lastEvent = listOfCapturedEvents.last() // The last event is the UP event with the target coordinates
// the joinery of the swipe is not interesting
val lastEventX = lastEvent.x
val lastEventY = lastEvent.y
assertEquals(displayWidth / 2.0, lastEventX.toDouble(), 0.0)
assertEquals(displayHeight - amountInPx - touchSlopPx - DeviceDisplay.convertDpiToPx(1.0), lastEventY.toDouble(), 0.0)
// Verify start and end coordinates
val upEvent = getUpEvent()
val x = upEvent.x
val y = upEvent.y
// Verify that the scroll started at the center of the view
assertEquals(displayWidth / 2.0, 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, y.toDouble(), 0.0)
}

@Test
fun `perform scroll down for 200 dp on full screen view with offset y`() {
val amountInDp = 200.0
val amountInPx = amountInDp * DeviceDisplay.getDensity()
val touchSlopPx = ScrollHelper.getViewConfiguration().scaledTouchSlop
val offsetPercent = 0.9f
fun `perform scroll down to edge on full screen view with gesture navigation enabled`() {
ScrollHelper.performOnce(uiControllerMock, viewMock, MOTION_DIR_DOWN)
val upEvent = getUpEvent()
val x = upEvent.x
val y = upEvent.y
val amountInPx = getViewSafeScrollableRangePix(viewMock, MOTION_DIR_DOWN).toFloat()

ScrollHelper.perform(uiController, view, MOTION_DIR_DOWN, amountInDp, null, offsetPercent)
assertEquals(displayWidth / 2.0, x.toDouble(), 0.0)
assertEquals(displayHeight - amountInPx - touchSlopPx - safetyMarginPx - INSETS_SIZE, y, 0.0f)

}

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

val listOfCapturedEvents = capture.firstValue.toList()
val lastEvent = listOfCapturedEvents.last() // The last event is the UP event with the target coordinates
// the joinery of the swipe is not interesting
val lastEventX = lastEvent.x
val lastEventY = lastEvent.y
assertEquals(displayWidth / 2.0, lastEventX.toDouble(), 0.0)
assertEquals(displayHeight - amountInPx - touchSlopPx - DeviceDisplay.convertDpiToPx(1.0), lastEventY.toDouble(), 0.0)

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

private fun mockView(displayWidth: Int, displayHeight: Int): View {
/**
* 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(88, 88, 88, 100)
Insets.of(INSETS_SIZE, INSETS_SIZE, INSETS_SIZE, INSETS_SIZE)
)
}

Expand Down

0 comments on commit 60bb394

Please sign in to comment.