Repository: wix/Detox
Pull request #2432 (2020-10-23 10:16:11)

Add x,y coordinate support for swipe action
- [x] This change has been discussed in issue #2428 and the solution has been agreed upon with maintainers.
- [x] JS native implementation
- [x] iOS native implementation
- [x] Android native implementation
- [x] Fix code review comments
- [x] Ensure consistent parameter naming
- [x] Add a few more tests for various `fast=true/false, startPointX/Y=NaN/something, offset=NaN/0/0.5/1` tests
- [x] Re-cover Android implementation with unit-tests

---

**Description:**

Signature:

```js
/*
 * @param { 'up' | 'down' | 'left' | 'right' } direction - Direction to swipe
 * @param { 'fast' | 'slow' } speed
 * @param {number} [normalizedSwipeOffset] - swipe amount relative to the screen width/height
 * @param {number} [normalizedStartingPointX] - X coordinate of swipe starting point, relative to the view width
 * @param {number} [normalizedStartingPointY] - Y coordinate of swipe starting point, relative to the view height
 */
function swipe(direction, speed, normalizedSwipeOffset = NaN, normalizedStartingPointX = NaN, normalizedStartingPointY = NaN) { ... }
```

Usage:

```js
await element(by.id('ScrollView161')).swipe('up');
await element(by.id('ScrollView161')).swipe('down', 'slow');
await element(by.id('ScrollView161')).swipe('left', 'fast', 0.95);
await element(by.id('ScrollView161')).swipe('right', 'slow', NaN, 0.3);
await element(by.id('ScrollView161')).swipe('up', 'fast', undefined, 0.5, 0.95);
```


LeoNatan: Implemented on iOS. Is this iOS only for now or will @d4vidi add impl for Android as well?
d4vidi: I can get to it soon, should be fairly ez to set up
LeoNatan: Great, thanks! @d4vidi 
noomorph: @d4vidi, @LeoNatan, need your advice regarding the swiped amount.

Look at the difference between the current iOS and Android implementations.

This happens because I calculate on Android the end coordinate not relative to the screen, but relative to the element itself. I have done that not really on purpose, just I was trying to follow the logic in the current Android implementation, and ended up in the place where I am.

So, iOS and Android behavior here differs. What would be more correct behavior, what do you think?

<table>
<tr>
<td width="50%">
<img src="https://user-images.githubusercontent.com/1962469/98451228-7a6b1000-214c-11eb-8b30-a3d66c9da572.gif">
</td>
<td width="50%">
<img src="https://user-images.githubusercontent.com/1962469/98451236-8a82ef80-214c-11eb-8fab-b1ca4359ebe9.gif">
</td>
</tr>
</table>

LeoNatan: That's how it was on iOS too, but according to feedback, it had to be in screen coords. CC @viktorijasujetaite 
noomorph: @LeoNatan, thanks. I've been suspecting something like that. 😳 
Ok, will rewrite the logic on Monday, apparently. 
LeoNatan: How was it before your changes? The Espresso swipe…?
LeoNatan: If you need to write your own logic for the start and end points, you can copy the logic from the iOS side. The logic should be the same. 
d4vidi: @noomorph I agree with @LeoNatan - swiping has to start somewhere inside the view bounds, but end as farther away as the _screen_ allows it.
d4vidi: Well @noomorph I'm sort of done reviewing this, for now - seems there's a lot to go over for now, so let's reiterate this when the time is right
noomorph: @d4vidi , @LeoNatan

Now the implementations feel aligned, right?

<table>
<tr>
<td width="50%">
<img src="https://user-images.githubusercontent.com/1962469/98992112-70bf1f00-2535-11eb-94ae-e68b31e01d3d.gif">
</td>
<td width="50%">
<img src="https://user-images.githubusercontent.com/1962469/98992358-c693c700-2535-11eb-85c8-2804162ef2b1.gif">
</td>
</tr>
</table>

d4vidi: @noomorph amazing work overall! I hope you don't mind me nitpicking here and there 😂  Anyways it's mostly naming that bothered me in my way of understanding the code, so most comments are in that area. Other than that seem pretty good!

Patch:
diff --git a/detox/android/detox/src/main/java/com/wix/detox/common/DetoxErrors.java b/detox/android/detox/src/main/java/com/wix/detox/common/DetoxErrors.java
index 15f70b9cb..29e94284f 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/common/DetoxErrors.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/common/DetoxErrors.java
@@ -26,4 +26,10 @@ public interface DetoxErrors {
             super(message);
         }
     }
+
+    class DetoxIllegalArgumentException extends DetoxRuntimeException {
+        public DetoxIllegalArgumentException(String message) {
+            super(message);
+        }
+    }
 }
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAction.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAction.java
index 108efcb7d..7179cf33f 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAction.java
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxAction.java
@@ -2,6 +2,7 @@ package com.wix.detox.espresso;
 
 import android.view.View;
 
+import com.wix.detox.common.DetoxErrors;
 import com.wix.detox.common.DetoxErrors.DetoxRuntimeException;
 import com.wix.detox.common.DetoxErrors.StaleActionException;
 import com.wix.detox.espresso.action.DetoxMultiTap;
@@ -10,6 +11,7 @@ import com.wix.detox.espresso.action.TakeViewScreenshotAction;
 import com.wix.detox.espresso.common.annot.MotionDir;
 import com.wix.detox.espresso.scroll.ScrollEdgeException;
 import com.wix.detox.espresso.scroll.ScrollHelper;
+import com.wix.detox.espresso.scroll.SwipeHelper;
 
 import org.hamcrest.Matcher;
 
@@ -20,13 +22,9 @@ import androidx.test.espresso.action.GeneralClickAction;
 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;
 
 import static androidx.test.espresso.action.ViewActions.actionWithAssertions;
-import static androidx.test.espresso.action.ViewActions.swipeDown;
-import static androidx.test.espresso.action.ViewActions.swipeLeft;
-import static androidx.test.espresso.action.ViewActions.swipeRight;
-import static androidx.test.espresso.action.ViewActions.swipeUp;
 import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom;
 import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
 import static com.wix.detox.espresso.common.annot.MotionDefs.MOTION_DIR_DOWN;
@@ -168,67 +166,21 @@ public class DetoxAction {
         });
     }
 
-    private final static float EDGE_FUZZ_FACTOR = 0.083f;
-
     /**
      * 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 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
      */
-    public static ViewAction swipeInDirection(final int direction, boolean fast) {
-        if (fast) {
-            switch (direction) {
-                case MOTION_DIR_LEFT:
-                    return swipeLeft();
-                case MOTION_DIR_RIGHT:
-                    return swipeRight();
-                case MOTION_DIR_UP:
-                    return swipeUp();
-                case MOTION_DIR_DOWN:
-                    return swipeDown();
-                default:
-                    throw new RuntimeException("Unsupported swipe direction: " + direction);
-            }
-        }
-
-        switch (direction) {
-            case MOTION_DIR_LEFT:
-                return actionWithAssertions(new GeneralSwipeAction(Swipe.SLOW,
-                        translate(GeneralLocation.CENTER_RIGHT, -EDGE_FUZZ_FACTOR, 0),
-                        GeneralLocation.CENTER_LEFT, Press.FINGER));
-            case MOTION_DIR_RIGHT:
-                return actionWithAssertions(new GeneralSwipeAction(Swipe.SLOW,
-                        translate(GeneralLocation.CENTER_LEFT, EDGE_FUZZ_FACTOR, 0),
-                        GeneralLocation.CENTER_RIGHT, Press.FINGER));
-            case MOTION_DIR_UP:
-                return actionWithAssertions(new GeneralSwipeAction(Swipe.SLOW,
-                        translate(GeneralLocation.BOTTOM_CENTER, 0, -EDGE_FUZZ_FACTOR),
-                        GeneralLocation.TOP_CENTER, Press.FINGER));
-            case MOTION_DIR_DOWN:
-                return actionWithAssertions(new GeneralSwipeAction(Swipe.SLOW,
-                        translate(GeneralLocation.TOP_CENTER, 0, EDGE_FUZZ_FACTOR),
-                        GeneralLocation.BOTTOM_CENTER, Press.FINGER));
-            default:
-                throw new RuntimeException("Unsupported swipe direction: " + direction);
-        }
+    public static ViewAction swipeInDirection(final int direction, boolean fast, double normalizedOffset, double normalizedStartingPointX, double normalizedStartingPointY) {
+        SwipeHelper swipeHelper = SwipeHelper.getDefault();
+        return swipeHelper.swipeInDirection(direction, fast, normalizedOffset, normalizedStartingPointX, normalizedStartingPointY);
     }
 
     public static ViewAction takeViewScreenshot() {
         return new TakeViewScreenshotAction();
     }
-
-    private static CoordinatesProvider translate(final CoordinatesProvider coords,
-                                                 final float dx, final float dy) {
-        return new CoordinatesProvider() {
-            @Override
-            public float[] calculateCoordinates(View view) {
-                float xy[] = coords.calculateCoordinates(view);
-                xy[0] += dx * view.getWidth();
-                xy[1] += dy * view.getHeight();
-                return xy;
-            }
-        };
-    }
 }
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/common/annot/MotionDefs.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/common/annot/MotionDefs.kt
index d2ed8a006..2ebae453f 100644
--- a/detox/android/detox/src/main/java/com/wix/detox/espresso/common/annot/MotionDefs.kt
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/common/annot/MotionDefs.kt
@@ -13,6 +13,14 @@ const val MOTION_DIR_RIGHT = 2
 const val MOTION_DIR_UP = 3
 const val MOTION_DIR_DOWN = 4
 
+fun isAscending(@MotionDir direction: Int): Boolean {
+    return direction == MOTION_DIR_RIGHT || direction == MOTION_DIR_DOWN
+}
+
+fun isDescending(@MotionDir direction: Int): Boolean {
+    return direction == MOTION_DIR_LEFT || direction == MOTION_DIR_UP
+}
+
 fun isHorizontal(@MotionDir direction: Int): Boolean {
     return direction == MOTION_DIR_LEFT || direction == MOTION_DIR_RIGHT
 }
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/SwipeHelper.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/SwipeHelper.kt
new file mode 100644
index 000000000..ac5b1e39e
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/SwipeHelper.kt
@@ -0,0 +1,96 @@
+package com.wix.detox.espresso.scroll
+
+import androidx.test.espresso.ViewAction
+import androidx.test.espresso.action.*
+import com.wix.detox.espresso.common.annot.*
+import com.wix.detox.espresso.utils.*
+import kotlin.math.abs
+import kotlin.math.min
+import kotlin.math.max
+
+private const val EDGE_FUZZ_FACTOR = 0.083
+
+private fun minMax(minValue: Double, value: Double, maxValue: Double) = max(minValue, min(value, maxValue))
+
+private fun ifNaN(value: Double, fallback: Double) = when {
+    value.isNaN() -> fallback
+    else -> value
+}
+
+typealias CreateSwipeAction = (
+        swiper: Swiper,
+        startCoordinatesProvider: CoordinatesProvider,
+        endCoordinatesProvider: CoordinatesProvider,
+        precisionDescriber: PrecisionDescriber
+) -> ViewAction
+
+class SwipeHelper(private val createAction: CreateSwipeAction) {
+
+    fun swipeInDirection(
+            direction: Int,
+            fast: Boolean,
+            normalizedSwipeAmount: Double,
+            normalizedStartingPointX: Double,
+            normalizedStartingPointY: Double
+    ): ViewAction {
+        val (edgeMin, edgeMax) = Pair(EDGE_FUZZ_FACTOR, 1.0 - EDGE_FUZZ_FACTOR)
+        val defaultNormalizedStartingPoint = Vector2D(0.5, edgeMin).rotate(direction, MOTION_DIR_DOWN).normalize()
+        val safeNormalizedStartPoint = Vector2D(
+                minMax(edgeMin, ifNaN(normalizedStartingPointX, defaultNormalizedStartingPoint.x), edgeMax),
+                minMax(edgeMin, ifNaN(normalizedStartingPointY, defaultNormalizedStartingPoint.y), edgeMax)
+        )
+
+        val safeSwipeAmount = minMax(0.0, ifNaN(normalizedSwipeAmount, 0.75), 1.0)
+        val startCoordinatesProvider = buildStartCoordinatesProvider(safeNormalizedStartPoint)
+        val endCoordinatesProvider = buildEndCoordinatesProvider(startCoordinatesProvider, direction, safeSwipeAmount)
+        val swiper = if (fast) Swipe.FAST else Swipe.SLOW
+
+        return this.createAction(swiper, startCoordinatesProvider, endCoordinatesProvider, Press.FINGER)
+    }
+
+    private fun buildStartCoordinatesProvider(normalizedStartPoint: Vector2D) = CoordinatesProvider { view ->
+        val xy = GeneralLocation.TOP_LEFT.calculateCoordinates(view)
+        xy[0] += (normalizedStartPoint.x * view.width).toFloat()
+        xy[1] += (normalizedStartPoint.y * view.height).toFloat()
+        xy
+    }
+
+    private fun buildEndCoordinatesProvider(startCoordinatesProvider: CoordinatesProvider, direction: Int, normalizedSwipeAmount: Double) =
+            CoordinatesProvider { view ->
+                val xy = startCoordinatesProvider.calculateCoordinates(view)
+
+                val screenEdge = Vector2D.from(
+                        view.context.resources.displayMetrics.widthPixels,
+                        view.context.resources.displayMetrics.heightPixels
+                )
+
+                val additionVector = Vector2D(0.0, normalizedSwipeAmount).rotate(direction, MOTION_DIR_DOWN)
+                val swipeEnd = Vector2D.from(xy)
+                        .add(screenEdge.scale(additionVector))
+                        .trimMax(0.0, 0.0)
+                        .trimMin(screenEdge.x, screenEdge.y)
+
+                xy[0] = swipeEnd.x.toFloat()
+                xy[1] = swipeEnd.y.toFloat()
+                xy
+            }
+
+    companion object {
+        @JvmStatic
+        val default = SwipeHelper { swiper: Swiper,
+                                    startCoordinatesProvider: CoordinatesProvider,
+                                    endCoordinatesProvider: CoordinatesProvider,
+                                    precisionDescriber: PrecisionDescriber ->
+            ViewActions.actionWithAssertions(
+                    GeneralSwipeAction(
+                            swiper,
+                            startCoordinatesProvider,
+                            endCoordinatesProvider,
+                            precisionDescriber
+                    )
+            );
+        }
+
+        const val edgeFuzzFactor = EDGE_FUZZ_FACTOR.toFloat()
+    }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/utils/Vector2D.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/utils/Vector2D.kt
new file mode 100644
index 000000000..266c05e47
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/utils/Vector2D.kt
@@ -0,0 +1,62 @@
+package com.wix.detox.espresso.utils
+
+import com.wix.detox.common.DetoxErrors
+import com.wix.detox.espresso.common.annot.MOTION_DIR_DOWN
+import com.wix.detox.espresso.common.annot.MOTION_DIR_LEFT
+import com.wix.detox.espresso.common.annot.MOTION_DIR_RIGHT
+import com.wix.detox.espresso.common.annot.MOTION_DIR_UP
+import kotlin.math.floor
+import kotlin.math.max
+import kotlin.math.min
+
+private fun frac(value: Double): Double = if (value < 0)
+    -frac(-value)
+else
+    value - floor(value)
+
+private fun normalize(value: Double): Double = if (value < 0)
+    1 + frac(value)
+else
+    frac(value)
+
+private fun clockwise90DegRotationsToDown(direction: Int) = when (direction) {
+    MOTION_DIR_LEFT -> 3
+    MOTION_DIR_UP -> 2
+    MOTION_DIR_RIGHT -> 1
+    MOTION_DIR_DOWN -> 0
+    else -> throw DetoxErrors.DetoxIllegalArgumentException("Unsupported swipe direction: $direction")
+}
+
+private fun angleBetween(fromDirection: Int, toDirection: Int): Int =
+        90 * ((4 + clockwise90DegRotationsToDown(fromDirection) - clockwise90DegRotationsToDown(toDirection)) % 4)
+
+data class Vector2D(val x: Double, val y: Double) {
+    fun add(other: Vector2D) = Vector2D(x + other.x, y + other.y)
+
+    fun normalize() = Vector2D(normalize(x), normalize(y))
+
+    fun rotate(fromDirection: Int, toDirection: Int) =
+            when (angleBetween(fromDirection, toDirection)) {
+                90 -> Vector2D(y, -x)
+                180 -> Vector2D(-x, -y)
+                270 -> Vector2D(-y, x)
+                else -> Vector2D(x, y)
+            }
+
+    fun scale(amountX: Double, amountY: Double = amountX) = Vector2D(x * amountX, y * amountY)
+
+    fun scale(vector: Vector2D) = scale(vector.x, vector.y)
+
+    fun trimMax(xMax: Double, yMax: Double = xMax) = Vector2D(max(x, xMax), max(y, yMax))
+
+    fun trimMin(xMin: Double, yMin: Double = xMin) = Vector2D(min(x, xMin), min(y, yMin))
+
+    fun withX(value: Double) = Vector2D(value, y)
+
+    fun withY(value: Double) = Vector2D(x, value)
+
+    companion object {
+        fun from(arr: FloatArray) = Vector2D(arr[0].toDouble(), arr[1].toDouble())
+        fun from(x: Int, y: Int) = Vector2D(x.toDouble(), y.toDouble())
+    }
+}
diff --git a/detox/android/gradle/wrapper/gradle-wrapper.properties b/detox/android/gradle/wrapper/gradle-wrapper.properties
index 372e68a2f..62ae71106 100644
--- a/detox/android/gradle/wrapper/gradle-wrapper.properties
+++ b/detox/android/gradle/wrapper/gradle-wrapper.properties
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-6.2.2-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
diff --git a/detox/ios/Detox/Actions/NSObject+DetoxActions.h b/detox/ios/Detox/Actions/NSObject+DetoxActions.h
index 09e97e904..c2c06972b 100644
--- a/detox/ios/Detox/Actions/NSObject+DetoxActions.h
+++ b/detox/ios/Detox/Actions/NSObject+DetoxActions.h
@@ -14,11 +14,12 @@ NS_ASSUME_NONNULL_BEGIN
 
 - (void)dtx_tapAtAccessibilityActivationPoint;
 - (void)dtx_tapAtAccessibilityActivationPointWithNumberOfTaps:(NSUInteger)numberOfTaps;
-- (void)dtx_tapAtPoint:(CGPoint)point numberOfTaps:(NSUInteger)numberOfTaps NS_SWIFT_NAME(dtx_tap(atPoint:numberOfTaps:));
+- (void)dtx_tapAtPoint:(CGPoint)point numberOfTaps:(NSUInteger)numberOfTaps NS_SWIFT_NAME(dtx_tap(at:numberOfTaps:));
 - (void)dtx_longPressAtAccessibilityActivationPoint;
 - (void)dtx_longPressAtAccessibilityActivationPointForDuration:(NSTimeInterval)duration;
-- (void)dtx_longPressAtPoint:(CGPoint)point duration:(NSTimeInterval)duration NS_SWIFT_NAME(dtx_longPress(atPoint:duration:));
-- (void)dtx_swipeWithNormalizedOffset:(CGPoint)normalizedOffset velocity:(CGFloat)velocity;
+- (void)dtx_longPressAtPoint:(CGPoint)point duration:(NSTimeInterval)duration NS_SWIFT_NAME(dtx_longPress(at:duration:));
+- (void)dtx_swipeWithNormalizedOffset:(CGPoint)normalizedOffset velocity:(CGFloat)velocity NS_SWIFT_NAME(dtx_swipe(withNormalizedOffset:velocity:));
+- (void)dtx_swipeWithNormalizedOffset:(CGPoint)normalizedOffset velocity:(CGFloat)velocity normalizedStartingPoint:(CGPoint)normalizedStartingPoint NS_SWIFT_NAME(dtx_swipe(withNormalizedOffset:velocity:normalizedStartingPoint:));
 - (void)dtx_pinchWithScale:(CGFloat)scale velocity:(CGFloat)velocity angle:(CGFloat)angle;
 
 - (void)dtx_clearText;
diff --git a/detox/ios/Detox/Actions/NSObject+DetoxActions.m b/detox/ios/Detox/Actions/NSObject+DetoxActions.m
index d8856175f..44827caa7 100644
--- a/detox/ios/Detox/Actions/NSObject+DetoxActions.m
+++ b/detox/ios/Detox/Actions/NSObject+DetoxActions.m
@@ -91,16 +91,28 @@ static void _DTXApplySwipe(UIWindow* window, CGPoint startPoint, CGPoint endPoin
 	[DTXSyntheticEvents touchAlongPath:points relativeToWindow:window holdDurationOnLastTouch:0.0];
 }
 
-#define DTX_CALC_SWIPE_START_END_POINTS(safeBoundsInScreenSpace, screenBounds, normalizedOffset, main, other, CGRectGetMinMain, CGRectGetMidMain, CGRectGetMidOther, CGRectGetMaxMain, CGRectGetMainSize) \
-CGFloat mainStart = MAX(MIN(CGRectGetMidMain(screenBounds) - 0.5 * normalizedOffset.main * CGRectGetMainSize(screenBounds), CGRectGetMaxMain(safeBoundsInScreenSpace) - 1), CGRectGetMinMain(safeBoundsInScreenSpace) + 1); \
+- (void)dtx_swipeWithNormalizedOffset:(CGPoint)normalizedOffset velocity:(CGFloat)velocity
+{
+	[self dtx_swipeWithNormalizedOffset:normalizedOffset velocity:velocity normalizedStartingPoint:CGPointMake(NAN, NAN)];
+}
+
+#define DTX_ENFORCE_NORMALIZED_STARTING_POINT(normalizedStartingPoint) \
+if((isnan(normalizedStartingPoint.x) == NO && (normalizedStartingPoint.x < 0 || normalizedStartingPoint.x > 1)) || isnan(normalizedStartingPoint.y) == NO && (normalizedStartingPoint.y < 0 || normalizedStartingPoint.y > 1)) \
+{ \
+DTXAssert(NO, @"Bad normalized starting point provided."); \
+} \
+
+#define DTX_CALC_SWIPE_START_END_POINTS(safeBoundsInScreenSpace, screenBounds, normalizedStartingPoint, normalizedOffset, main, other, CGRectGetMinMain, CGRectGetMinOther, CGRectGetMidMain, CGRectGetMidOther, CGRectGetMaxMain, CGRectGetMainSize, CGRectGetOtherSize) \
+CGFloat mainStart = !isnan(normalizedStartingPoint.main) ? CGRectGetMinMain(safeBoundsInScreenSpace) + CGRectGetMainSize(safeBoundsInScreenSpace) * normalizedStartingPoint.main : MAX(MIN(CGRectGetMidMain(screenBounds) - 0.5 * normalizedOffset.main * CGRectGetMainSize(screenBounds), CGRectGetMaxMain(safeBoundsInScreenSpace) - 1), CGRectGetMinMain(safeBoundsInScreenSpace) + 1); \
 startPoint.main = mainStart; \
-startPoint.other = CGRectGetMidOther(safeBoundsInScreenSpace); \
+startPoint.other = !isnan(normalizedStartingPoint.other) ? CGRectGetMinOther(safeBoundsInScreenSpace) + CGRectGetOtherSize(safeBoundsInScreenSpace) * normalizedStartingPoint.other : CGRectGetMidOther(safeBoundsInScreenSpace); \
 endPoint.main = MIN(MAX(mainStart + normalizedOffset.main * CGRectGetMainSize(screenBounds), CGRectGetMinMain(screenBounds) + 1), CGRectGetMaxMain(screenBounds) - 1); \
-endPoint.other = CGRectGetMidOther(safeBoundsInScreenSpace);
+endPoint.other = !isnan(normalizedStartingPoint.other) ? CGRectGetMinOther(safeBoundsInScreenSpace) + CGRectGetOtherSize(safeBoundsInScreenSpace) * normalizedStartingPoint.other : CGRectGetMidOther(safeBoundsInScreenSpace);
 
-- (void)dtx_swipeWithNormalizedOffset:(CGPoint)normalizedOffset velocity:(CGFloat)velocity
+- (void)dtx_swipeWithNormalizedOffset:(CGPoint)normalizedOffset velocity:(CGFloat)velocity normalizedStartingPoint:(CGPoint)normalizedStartingPoint
 {
 	NSParameterAssert(velocity > 0.0);
+	DTX_ENFORCE_NORMALIZED_STARTING_POINT(normalizedStartingPoint);
 	
 	if(normalizedOffset.x == 0 && normalizedOffset.y == 0)
 	{
@@ -119,11 +131,11 @@ endPoint.other = CGRectGetMidOther(safeBoundsInScreenSpace);
 	
 	if(normalizedOffset.x != 0)
 	{
-		DTX_CALC_SWIPE_START_END_POINTS(safeBoundsInScreenSpace, screenBounds, normalizedOffset, x, y, CGRectGetMinX, CGRectGetMidX, CGRectGetMidY, CGRectGetMaxX, CGRectGetWidth);
+		DTX_CALC_SWIPE_START_END_POINTS(safeBoundsInScreenSpace, screenBounds, normalizedStartingPoint, normalizedOffset, x, y, CGRectGetMinX, CGRectGetMinY, CGRectGetMidX, CGRectGetMidY, CGRectGetMaxX, CGRectGetWidth, CGRectGetHeight);
 	}
 	else
 	{
-		DTX_CALC_SWIPE_START_END_POINTS(safeBoundsInScreenSpace, screenBounds, normalizedOffset, y, x, CGRectGetMinY, CGRectGetMidY, CGRectGetMidX, CGRectGetMaxY, CGRectGetHeight);
+		DTX_CALC_SWIPE_START_END_POINTS(safeBoundsInScreenSpace, screenBounds, normalizedStartingPoint, normalizedOffset, y, x, CGRectGetMinY, CGRectGetMinX, CGRectGetMidY, CGRectGetMidX, CGRectGetMaxY, CGRectGetHeight, CGRectGetWidth);
 	}
 	
 	
diff --git a/detox/ios/Detox/Actions/UIScrollView+DetoxActions.h b/detox/ios/Detox/Actions/UIScrollView+DetoxActions.h
index ba9a87774..c2c0638fd 100644
--- a/detox/ios/Detox/Actions/UIScrollView+DetoxActions.h
+++ b/detox/ios/Detox/Actions/UIScrollView+DetoxActions.h
@@ -12,7 +12,7 @@ NS_ASSUME_NONNULL_BEGIN
 
 @interface UIScrollView (DetoxActions)
 
-- (void)dtx_scrollToEdge:(UIRectEdge)edge;
+- (void)dtx_scrollToEdge:(UIRectEdge)edge NS_SWIFT_NAME(dtx_scroll(to:));
 - (void)dtx_scrollWithOffset:(CGPoint)offset;
 - (void)dtx_scrollWithOffset:(CGPoint)offset normalizedStartingPoint:(CGPoint)normalizedStartingPoint NS_SWIFT_NAME(dtx_scroll(withOffset:normalizedStartingPoint:));
 
diff --git a/detox/ios/Detox/Invocation/Action.swift b/detox/ios/Detox/Invocation/Action.swift
index 7e6d5cce3..f584fc5ea 100644
--- a/detox/ios/Detox/Invocation/Action.swift
+++ b/detox/ios/Detox/Invocation/Action.swift
@@ -288,8 +288,8 @@ class ScrollAction : Action {
 			fatalError("Unknown scroll direction")
 			break;
 		}
-		let startPositionX : Double
 		
+		let startPositionX : Double
 		if params?.count ?? 0 > 2, let param2 = params?[2] as? Double, param2.isNaN == false {
 			startPositionX = param2
 		} else {
@@ -388,7 +388,21 @@ class SwipeAction : Action {
 		targetNormalizedOffset.x *= CGFloat(appliedPercentage)
 		targetNormalizedOffset.y *= CGFloat(appliedPercentage)
 		
-		element.swipe(normalizedOffset: targetNormalizedOffset, velocity: velocity)
+		let startPositionX : Double
+		if params?.count ?? 0 > 3, let param2 = params?[3] as? Double, param2.isNaN == false {
+			startPositionX = param2
+		} else {
+			startPositionX = Double.nan
+		}
+		let startPositionY : Double
+		if params?.count ?? 0 > 4, let param3 = params?[4] as? Double, param3.isNaN == false {
+			startPositionY = param3
+		} else {
+			startPositionY = Double.nan
+		}
+		let normalizedStartingPoint = CGPoint(x: startPositionX, y: startPositionY)
+		
+		element.swipe(normalizedOffset: targetNormalizedOffset, velocity: velocity, normalizedStartingPoint: normalizedStartingPoint)
 		
 		return nil
 	}
diff --git a/detox/ios/Detox/Invocation/Element.swift b/detox/ios/Detox/Invocation/Element.swift
index 7ba613c9e..a59e20a5c 100644
--- a/detox/ios/Detox/Invocation/Element.swift
+++ b/detox/ios/Detox/Invocation/Element.swift
@@ -118,7 +118,7 @@ class Element : NSObject {
 			return
 		}
 		
-		view.dtx_tap(atPoint: point, numberOfTaps: UInt(numberOfTaps))
+		view.dtx_tap(at: point, numberOfTaps: UInt(numberOfTaps))
 	}
 	
 	func longPress(at point: CGPoint? = nil, duration: TimeInterval = 1.0) {
@@ -127,11 +127,15 @@ class Element : NSObject {
 			return
 		}
 		
-		view.dtx_longPress(atPoint: point, duration: duration)
+		view.dtx_longPress(at: point, duration: duration)
 	}
 	
-	func swipe(normalizedOffset: CGPoint, velocity: CGFloat = 1.0) {
-		view.dtx_swipe(withNormalizedOffset: normalizedOffset, velocity: velocity)
+	func swipe(normalizedOffset: CGPoint, velocity: CGFloat = 1.0, normalizedStartingPoint: CGPoint? = nil) {
+		if let normalizedStartingPoint = normalizedStartingPoint {
+			view.dtx_swipe(withNormalizedOffset: normalizedOffset, velocity: velocity, normalizedStartingPoint: normalizedStartingPoint)
+		} else {
+			view.dtx_swipe(withNormalizedOffset: normalizedOffset, velocity: velocity)
+		}
 	}
 	
 	func pinch(withScale scale: CGFloat, velocity: CGFloat = 2.0, angle: CGFloat = 0.0) {
diff --git a/detox/ios/Detox/Utilities/UIView+DetoxUtils.m b/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
index dccf33b4a..5c31b36ca 100644
--- a/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
+++ b/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
@@ -331,7 +331,7 @@ DTX_DIRECT_MEMBERS
 
 - (CGRect)_dtx_hitBoundsAroundPoint:(CGPoint)point
 {
-	return CGRectIntersection(self.bounds, CGRectMake(point.x - 22, point.y - 22, 44, 44));
+	return CGRectIntersection(self.bounds, CGRectMake(point.x - 0.5, point.y - 0.5, 1, 1));
 }
 
 - (BOOL)dtx_isHittableAtPoint:(CGPoint)point error:(NSError* __strong *)error
diff --git a/detox/src/android/espressoapi/DetoxAction.js b/detox/src/android/espressoapi/DetoxAction.js
index a90aae936..9c29620cc 100644
--- a/detox/src/android/espressoapi/DetoxAction.js
+++ b/detox/src/android/espressoapi/DetoxAction.js
@@ -137,9 +137,12 @@ class DetoxAction {
     };
   }
 
-  static swipeInDirection(direction, fast) {
+  static swipeInDirection(direction, fast, normalizedOffset, normalizedStartingPointX, normalizedStartingPointY) {
     if (typeof direction !== "string") throw new Error("direction should be a string, but got " + (direction + (" (" + (typeof direction + ")"))));
     if (typeof fast !== "boolean") throw new Error("fast should be a boolean, but got " + (fast + (" (" + (typeof fast + ")"))));
+    if (typeof normalizedOffset !== "number") throw new Error("normalizedOffset should be a number, but got " + (normalizedOffset + (" (" + (typeof normalizedOffset + ")"))));
+    if (typeof normalizedStartingPointX !== "number") throw new Error("normalizedStartingPointX should be a number, but got " + (normalizedStartingPointX + (" (" + (typeof normalizedStartingPointX + ")"))));
+    if (typeof normalizedStartingPointY !== "number") throw new Error("normalizedStartingPointY should be a number, but got " + (normalizedStartingPointY + (" (" + (typeof normalizedStartingPointY + ")"))));
     return {
       target: {
         type: "Class",
@@ -152,6 +155,15 @@ class DetoxAction {
       }, {
         type: "boolean",
         value: fast
+      }, {
+        type: "Double",
+        value: normalizedOffset
+      }, {
+        type: "Double",
+        value: normalizedStartingPointX
+      }, {
+        type: "Double",
+        value: normalizedStartingPointY
       }]
     };
   }
diff --git a/detox/src/android/expect.js b/detox/src/android/expect.js
index 82f02eb9a..b0bb1cea6 100644
--- a/detox/src/android/expect.js
+++ b/detox/src/android/expect.js
@@ -20,6 +20,9 @@ const {
   ValueMatcher,
   ToggleMatcher,
 } = require('./matcher');
+const { assertEnum, assertNormalized, assertNumber } = require('../utils/assertArgument');
+const assertDirection = assertEnum(['left', 'right', 'up', 'down']);
+const assertSpeed = assertEnum(['fast', 'slow']);
 
 function call(maybeAFunction) {
   return maybeAFunction instanceof Function ? maybeAFunction() : maybeAFunction;
@@ -107,16 +110,24 @@ class ScrollEdgeAction extends Action {
 }
 
 class SwipeAction extends Action {
-  // This implementation ignores the percentage parameter
-  constructor(direction, speed, percentage) {
+  constructor(direction, speed, normalizedSwipeOffset, normalizedStartingPointX, normalizedStartingPointY) {
     super();
-    if (speed === 'fast') {
-      this._call = invoke.callDirectly(DetoxActionApi.swipeInDirection(direction, true));
-    } else if (speed === 'slow') {
-      this._call = invoke.callDirectly(DetoxActionApi.swipeInDirection(direction, false));
-    } else {
-      throw new Error(`SwipeAction speed must be a 'fast'/'slow', got ${speed}`);
-    }
+
+    assertDirection({ direction });
+    assertSpeed({ speed });
+    assertNormalized({ normalizedSwipeOffset });
+    assertNormalized({ normalizedStartingPointX });
+    assertNormalized({ normalizedStartingPointY });
+
+    this._call = invoke.callDirectly(
+      DetoxActionApi.swipeInDirection(
+        direction,
+        speed === 'fast',
+        normalizedSwipeOffset,
+        normalizedStartingPointX,
+        normalizedStartingPointY
+      )
+    );
   }
 }
 
@@ -277,10 +288,20 @@ class Element {
     return await new ActionInteraction(this._invocationManager, this, new ScrollEdgeAction(edge)).execute();
   }
 
-  async swipe(direction, speed = 'fast', percentage = 0) {
+  /**
+   * @param {'up' | 'right' | 'down' | 'left'} direction
+   * @param {'slow' | 'fast'} [speed]
+   * @param {number} [normalizedSwipeOffset] - swipe amount relative to the screen width/height
+   * @param {number} [normalizedStartingPointX] - X coordinate of swipe starting point, relative to the view width
+   * @param {number} [normalizedStartingPointY] - Y coordinate of swipe starting point, relative to the view height
+   */
+  async swipe(direction, speed = 'fast', normalizedSwipeOffset = NaN, normalizedStartingPointX = NaN, normalizedStartingPointY = NaN) {
+    normalizedSwipeOffset = Number.isNaN(normalizedSwipeOffset) ? 0.75 : normalizedSwipeOffset;
+
     // override the user's element selection with an extended matcher that avoids RN issues with RCTScrollView
     this._selectElementWithMatcher(this._originalMatcher._avoidProblematicReactNativeElements());
-    return await new ActionInteraction(this._invocationManager, this, new SwipeAction(direction, speed, percentage)).execute();
+    const action = new SwipeAction(direction, speed, normalizedSwipeOffset, normalizedStartingPointX, normalizedStartingPointY);
+    return await new ActionInteraction(this._invocationManager, this, action).execute();
   }
 
   async takeScreenshot(screenshotName) {
diff --git a/detox/src/ios/expectTwo.js b/detox/src/ios/expectTwo.js
index d253915c3..3a1c6e32a 100644
--- a/detox/src/ios/expectTwo.js
+++ b/detox/src/ios/expectTwo.js
@@ -1,5 +1,8 @@
 const _ = require('lodash');
 const DetoxRuntimeError = require('../errors/DetoxRuntimeError');
+const { assertEnum, assertNormalized, assertNumber } = require('../utils/assertArgument');
+const assertDirection = assertEnum(['left', 'right', 'up', 'down']);
+const assertSpeed = assertEnum(['fast', 'slow']);
 
 class Expect {
   constructor(invocationManager, element) {
@@ -167,11 +170,15 @@ class Element {
     return this.withAction('scrollTo', edge);
   }
 
-  swipe(direction, speed = 'fast', percentage = 0.75) {
-    if (!['left', 'right', 'up', 'down'].some(option => option === direction)) throw new Error('direction should be one of [left, right, up, down], but got ' + direction);
-    if (!['slow', 'fast'].some(option => option === speed)) throw new Error('speed should be one of [slow, fast], but got ' + speed);
-    if (!(typeof percentage === 'number' && percentage >= 0 && percentage <= 1)) throw new Error('yOriginStartPercentage should be a number [0.0, 1.0], but got ' + (percentage + (' (' + (typeof percentage + ')'))));
-    return this.withAction('swipe', direction, speed, percentage);
+  swipe(direction, speed = 'fast', normalizedSwipeOffset = NaN, normalizedStartingPointX = NaN, normalizedStartingPointY = NaN) {
+    assertDirection({ direction });
+    assertSpeed({ speed });
+    assertNormalized({ normalizedSwipeOffset });
+    assertNormalized({ normalizedStartingPointX });
+    assertNormalized({ normalizedStartingPointY });
+
+    normalizedSwipeOffset = Number.isNaN(normalizedSwipeOffset) ? 0.75 : normalizedSwipeOffset;
+    return this.withAction('swipe', direction, speed, normalizedSwipeOffset, normalizedStartingPointX, normalizedStartingPointY);
   }
 
   setColumnToValue(column, value) {
diff --git a/detox/src/utils/assertArgument.js b/detox/src/utils/assertArgument.js
new file mode 100644
index 000000000..6a0a08b53
--- /dev/null
+++ b/detox/src/utils/assertArgument.js
@@ -0,0 +1,42 @@
+function firstEntry(obj) {
+  return Object.entries(obj)[0];
+}
+
+function assertType(expectedType) {
+  return function assertSpecificType(arg) {
+    const [key, value] = firstEntry(arg);
+
+    if (typeof value !== expectedType) {
+      throw new Error(`${key} should be a ${expectedType}, but got ${value} (${typeof value})`);
+    }
+  };
+}
+
+const assertNumber = assertType('number');
+const assertString = assertType('string');
+
+function assertNormalized(arg) {
+  assertNumber(arg);
+
+  const [key, value] = firstEntry(arg);
+  if (value < 0 || value > 1) {
+    throw new Error(`${key} should be a number [0.0, 1.0], but got ${value} (${typeof value})`);
+  }
+}
+
+function assertEnum(allowedValues) {
+  return function assertSpecificEnum(arg) {
+    const [key, value] = firstEntry(arg);
+
+    if (allowedValues.indexOf(value) === -1) {
+      throw new Error(`${key} should be one of [${allowedValues.join(', ')}], but got ${value} (${typeof value})`);
+    }
+  };
+}
+
+module.exports = {
+  assertEnum,
+  assertNormalized,
+  assertNumber,
+  assertString,
+};
diff --git a/docs/APIRef.ActionsOnElement.md b/docs/APIRef.ActionsOnElement.md
index d48dc56c1..12e7c1ef9 100644
--- a/docs/APIRef.ActionsOnElement.md
+++ b/docs/APIRef.ActionsOnElement.md
@@ -9,9 +9,9 @@ Use [expectations](APIRef.Expect.md) to verify element states.
 - [`.tap()`](#tappoint)
 - [`.multiTap()`](#multitaptimes)
 - [`.longPress()`](#longpressduration)
-- [`.swipe()`](#swipedirection-speed-percentage)
+- [`.swipe()`](#swipedirection-speed-normalizedoffset-normalizedstartingpointx-normalizedstartingpointy)
 - [`.pinch()`](#pinchscale-speed-angle--ios-only) **iOS only**
-- [`.scroll()`](#scrolloffset-direction-startpositionxnan-startpositionynan)
+- [`.scroll()`](#scrolloffset-direction-startpositionx-startpositiony)
   - [`whileElement()`](#whileelementelement)
 - [`.scrollTo()`](#scrolltoedge)
 - [`.typeText()`](#typetexttext)
@@ -29,7 +29,7 @@ Use [expectations](APIRef.Expect.md) to verify element states.
 
 Simulates a tap on the element at the specified point, or at element's activation point if no point is specified.
 
-`point`—a point in the element's coordinate space (optional, valid input: object with x and y numerical values)
+`point`—a point in the element's coordinate space (optional, valid input: object with x and y numerical values, default is `null`)
 
 **Note:** Special care should be applied when specifying a point with this method. Elements may have different dimensions when displayed on different device screen sizes, different text sizes, etc.
 
@@ -46,27 +46,34 @@ Simulates multiple taps on the element at its activation point. All taps are app
 await element(by.id('tappable')).multiTap(3);
 ```
 ### `longPress(duration)`
+
 Simulates a long press on the element at its activation point.
 
-`duration`—the duration to press for, in ms (optional)
+`duration`—the duration to press for, in ms (optional, default is 1000)
 
 ```js
 await element(by.id('tappable')).longPress();
 await element(by.id('tappable')).longPress(1500);
 ```
-### `swipe(direction, speed, percentage)`
+
+### `swipe(direction, speed, normalizedOffset, normalizedStartingPointX, normalizedStartingPointY)`
 
 Simulates a swipe on the element with the provided options.
 
-`direction`—the swipe's direction (valid input: `"left"`/`"right"`/`"up"`/`"down"`) <br/>
-`speed`—the speed of the swipe (optional, valid input: `"fast"`/`"slow"` , default is `"fast"`) <br/>
-`percentage`—the percentage of the screen to swipe (optional, valid input: [0.0, 1.0], default is 0.75)
+`direction` — the direction of the swipe (reuqired, valid input: `"left"`/`"right"`/`"up"`/`"down"`) <br/>
+`speed` — the speed of the swipe (optional, valid input: `"fast"`/`"slow"` , default is `"fast"`) <br/>
+`normalizedOffset` — swipe amount relative to the screen width/height (optional, a number between 0.0 and 1.0, default is `NaN` — choose an optimal value automatically) <br/>
+`normalizedStartingPointX` — X coordinate of swipe starting point, relative to the view width (optional, a number between 0.0 and 1.0, default is `NaN` — choose an optimal value automatically) <br/>
+`normalizedStartingPointY` — Y coordinate of swipe starting point, relative to the view height (optional, a number between 0.0 and 1.0, default is `NaN` — choose an optimal value automatically)
 
 ```js
 await element(by.id('scrollView')).swipe('down');
-await element(by.id('scrollView')).swipe('down', 'fast');
-await element(by.id('scrollView')).swipe('down', 'fast', 0.5);
+await element(by.id('scrollView')).swipe('down', 'slow'); // set swipe speed
+await element(by.id('scrollView')).swipe('down', 'fast', 0.75); // set swipe amount
+await element(by.id('scrollView')).swipe('down', 'fast', NaN, 0.8); // set starting point X
+await element(by.id('scrollView')).swipe('down', 'fast', NaN, NaN, 0.25); // set starting point Y
 ```
+
 ### `pinch(scale, speed, angle)`  iOS only
 
 Simulates a pinch on the element with the provided options.
@@ -80,14 +87,14 @@ 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
 ```
-### `scroll(offset, direction, startPositionX=NaN, startPositionY=NaN)`
+### `scroll(offset, direction, startPositionX, startPositionY)`
 
 Simulates a scroll on the element with the provided options.
 
 `offset`—the offset to scroll, in points <br/>
 `direction`—the scroll's direction (valid input: `"left"`/`"right"`/`"up"`/`"down"`) <br/>
-`startPositionX`—the normalized x percentage of the element to use as scroll start point (optional, valid input: [0.0, 1.0], `NaN`—choose an optimal value automatically) <br/>
-`startPositionY`—the normalized y percentage of the element to use as scroll start point (optional, valid input: [0.0, 1.0], `NaN`—choose an optimal value automatically)
+`startPositionX`—the normalized x percentage of the element to use as scroll start point (optional, valid input: [0.0, 1.0], `NaN`—choose an optimal value automatically, default is `NaN`) <br/>
+`startPositionY`—the normalized y percentage of the element to use as scroll start point (optional, valid input: [0.0, 1.0], `NaN`—choose an optimal value automatically, default is `NaN`)
 
 ```js
 await element(by.id('scrollView')).scroll(100, 'up');
@@ -116,7 +123,7 @@ await element(by.id('scrollView')).scrollTo('top');
 
 Simulates typing of the specified text into the element, using the system's builtin keyboard and typing behavior.
 
-On iOS, any element can be typed into, as long as it can become first responder and conforms to the [`UITextInput`](https://developer.apple.com/documentation/uikit/uitextinput) protocol.
+On iOS, any element can be typed into, as long as it can become first responder and conforms to the [`UITextInput`](https://developer.apple.com/documentation/uikit/uitextinput) protocol. Before typing the text, Detox attempts making the element the first responder, if it isn't already. If the element’s window is not key window, Detox attempts making it the key window.
 
 `text`—the text to type (valid input: string)
 
@@ -128,7 +135,7 @@ await element(by.id('textField')).typeText('passcode');
 
 Replaces the element's text with the specified text, without using the system's builtin keyboard or typing behavior. **Note**, that using this method is faster than using [`.typeText()`](#typetexttext), but may not trigger all text input callbacks, causing an undefined state in your app.
 
-On iOS, any element's text can be replaced, as long as it can become first responder and conforms to the [`UITextInput`](https://developer.apple.com/documentation/uikit/uitextinput) protocol.
+On iOS, any element's text can be replaced, as long as it can become first responder and conforms to the [`UITextInput`](https://developer.apple.com/documentation/uikit/uitextinput) protocol. Before replacing the text, Detox attempts making the element the first responder, if it isn't already. If the element’s window is not key window, Detox attempts making it the key window.
 
 `text`—the text to replace with (valid input: string)
 
@@ -139,16 +146,16 @@ await element(by.id('textField')).replaceText('passcode again');
 ### `clearText()`
 Simulates clearing the text of the element, using the system's builtin keyboard and typing behavior.
 
-On iOS, any element's text can be cleared, as long as it can become first responder and conforms to the [`UITextInput`](https://developer.apple.com/documentation/uikit/uitextinput) protocol.
+On iOS, any element's text can be cleared, as long as it can become first responder and conforms to the [`UITextInput`](https://developer.apple.com/documentation/uikit/uitextinput) protocol. Before clearing the text, Detox attempts making the element the first responder, if it isn't already. If the element’s window is not key window, Detox attempts making it the key window.
 
 ```js
 await element(by.id('textField')).clearText();
 ```
 
 ### `tapReturnKey()`
-Simulates tapping of the return key into the element, using the system's builtin keyboard and typing behavior.
+Simulates tapping on the return key into the element, using the system's builtin keyboard and typing behavior.
 
-On iOS, any element can be sent return key input, as long as it can become first responder and conforms to the [`UITextInput`](https://developer.apple.com/documentation/uikit/uitextinput) protocol.
+On iOS, any element can be sent return key input, as long as it can become first responder and conforms to the [`UITextInput`](https://developer.apple.com/documentation/uikit/uitextinput) protocol. Before tapping on the return key, Detox attempts making the element the first responder, if it isn't already. If the element’s window is not key window, Detox attempts making it the key window.
 
 ```js
 await element(by.id('textField')).tapReturnKey();
@@ -158,7 +165,7 @@ await element(by.id('textField')).tapReturnKey();
 
 Simulates tapping of the backspace key into the element, using the system's builtin keyboard and typing behavior.
 
-On iOS, any element can be sent backspace key input, as long as it can become first responder and conforms to the [`UITextInput`](https://developer.apple.com/documentation/uikit/uitextinput) protocol.
+On iOS, any element can be sent backspace key input, as long as it can become first responder and conforms to the [`UITextInput`](https://developer.apple.com/documentation/uikit/uitextinput) protocol. Before tapping on the backspace key, Detox attempts making the element the first responder, if it isn't already. If the element’s window is not key window, Detox attempts making it the key window.
 
 ```js
 await element(by.id('textField')).tapBackspaceKey();


Test patch:
diff --git a/detox/android/detox/src/test/java/com/wix/detox/espresso/scroll/SwipeHelperSpec.kt b/detox/android/detox/src/test/java/com/wix/detox/espresso/scroll/SwipeHelperSpec.kt
new file mode 100644
index 000000000..31e175a29
--- /dev/null
+++ b/detox/android/detox/src/test/java/com/wix/detox/espresso/scroll/SwipeHelperSpec.kt
@@ -0,0 +1,213 @@
+package com.wix.detox.espresso.scroll
+
+import android.content.Context
+import android.content.res.Resources
+import android.util.DisplayMetrics
+import android.view.View
+import androidx.test.espresso.ViewAction
+import androidx.test.espresso.action.*
+import com.nhaarman.mockitokotlin2.mock
+import com.nhaarman.mockitokotlin2.whenever
+import com.wix.detox.espresso.common.annot.*
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.Parameterized
+import kotlin.test.assertEquals
+
+data class SwipeArguments(
+        val direction: Int,
+        val fast: Boolean,
+        val normalizedSwipeAmount: Double,
+        val normalizedStartingPointX: Double,
+        val normalizedStartingPointY: Double
+)
+
+data class SwipeCoordinates(
+        val startX: Float,
+        val startY: Float,
+        val endX: Float,
+        val endY: Float
+)
+
+data class RectangleCalculator(val x: Int, val y: Int, val width: Int, val height: Int, val fuzz: Float) {
+    val left: Float get() = x + width * 0.0f
+    val center: Float get() = x + width * 0.5f
+    val right: Float get() = x + width * 1.0f
+    val top: Float get() = y + height * 0.0f
+    val middle: Float get() = y + height * 0.5f
+    val bottom: Float get() = y + height * 1.0f
+
+    val safeLeft: Float get() = x + width * (0f + fuzz)
+    val safeRight: Float get() = x + width * (1f - fuzz)
+    val safeTop: Float get() = y + height * (0f + fuzz)
+    val safeBottom: Float get() = y + height * (1f - fuzz)
+}
+
+@RunWith(Parameterized::class)
+class SwipeHelperTest(
+        val args: SwipeArguments,
+        val expected: SwipeCoordinates
+) {
+    lateinit var action: ViewAction
+    lateinit var swiper: Swiper
+    lateinit var startCoordinatesProvider: CoordinatesProvider
+    lateinit var endCoordinatesProvider: CoordinatesProvider
+    lateinit var precisionDescriber: PrecisionDescriber
+    lateinit var swipeResult: Any
+
+    private val swipeHelper = SwipeHelper { _swiper: Swiper,
+                                            _startCoordinatesProvider: CoordinatesProvider,
+                                            _endCoordinatesProvider: CoordinatesProvider,
+                                            _precisionDescriber: PrecisionDescriber ->
+        swiper = _swiper
+        startCoordinatesProvider = _startCoordinatesProvider
+        endCoordinatesProvider = _endCoordinatesProvider
+        precisionDescriber = _precisionDescriber
+        action = mock()
+        action
+    }
+
+    @Before
+    fun setup() {
+        swipeResult = swipeHelper.swipeInDirection(
+                args.direction,
+                args.fast,
+                args.normalizedSwipeAmount,
+                args.normalizedStartingPointX,
+                args.normalizedStartingPointY
+        )
+    }
+
+    @Test
+    fun shouldReturnAction() {
+        assertEquals(action, swipeResult)
+    }
+
+    @Test
+    fun shouldUseFingerPrecision() {
+        assertEquals(Press.FINGER, precisionDescriber)
+    }
+
+    @Test
+    fun shouldCalculateCorrectStartPoint() {
+        val mockView = setupMockView()
+        val startXY = startCoordinatesProvider.calculateCoordinates(mockView)
+        val expectedStart = Pair(expected.startX, expected.startY)
+        val actualStart = Pair(startXY[0], startXY[1])
+        assertEquals(expectedStart, actualStart)
+    }
+
+    @Test
+    fun shouldCalculateCorrectEndPoint() {
+        val mockView = setupMockView()
+        val endXY = endCoordinatesProvider.calculateCoordinates(mockView)
+        val expectedEnd = Pair(expected.endX, expected.endY)
+        val actualEnd = Pair(endXY[0], endXY[1])
+        assertEquals(expectedEnd, actualEnd)
+    }
+
+    companion object {
+        @JvmStatic
+        @Parameterized.Parameters(name = "{0}")
+        fun data(): Collection<Array<Any>> {
+            return listOf(
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_LEFT, true, Double.NaN, Double.NaN, Double.NaN),
+                            SwipeCoordinates(view.safeRight, view.middle, view.safeRight - 0.75f * screen.width, view.middle)
+                    ),
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_LEFT, true, 1.0, Double.NaN, Double.NaN),
+                            SwipeCoordinates(view.safeRight, view.middle, screen.left, view.middle)
+                    ),
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_LEFT, false, Double.NaN, 1.0, 1.0),
+                            SwipeCoordinates(view.safeRight, view.safeBottom, view.safeRight - 0.75f * screen.width, view.safeBottom)
+                    ),
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_LEFT, false, 0.0, 0.5, 0.5),
+                            SwipeCoordinates(view.center, view.middle, view.center, view.middle)
+                    ),
+
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_RIGHT, true, Double.NaN, Double.NaN, Double.NaN),
+                            SwipeCoordinates(view.safeLeft, view.middle, view.safeLeft + 0.75f * screen.width, view.middle)
+                    ),
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_RIGHT, true, 1.0, Double.NaN, Double.NaN),
+                            SwipeCoordinates(view.safeLeft, view.middle, screen.right, view.middle)
+                    ),
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_RIGHT, false, Double.NaN, 0.0, 0.0),
+                            SwipeCoordinates(view.safeLeft, view.safeTop, view.safeLeft + 0.75f * screen.width, view.safeTop)
+                    ),
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_RIGHT, false, 0.0, 0.5, 0.5),
+                            SwipeCoordinates(view.center, view.middle, view.center, view.middle)
+                    ),
+
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_UP, true, Double.NaN, Double.NaN, Double.NaN),
+                            SwipeCoordinates(view.center, view.safeBottom, view.center, view.safeBottom - 0.75f * screen.height)
+                    ),
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_UP, true, 1.0, Double.NaN, Double.NaN),
+                            SwipeCoordinates(view.center, view.safeBottom, view.center, screen.top)
+                    ),
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_UP, false, Double.NaN, 1.0, 1.0),
+                            SwipeCoordinates(view.safeRight, view.safeBottom, view.safeRight, view.safeBottom - 0.75f * screen.height)
+                    ),
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_UP, false, 0.0, 0.5, 0.5),
+                            SwipeCoordinates(view.center, view.middle, view.center, view.middle)
+                    ),
+
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_DOWN, true, Double.NaN, Double.NaN, Double.NaN),
+                            SwipeCoordinates(view.center, view.safeTop, view.center, view.safeTop + 0.75f * screen.height)
+                    ),
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_DOWN, true, 1.0, Double.NaN, Double.NaN),
+                            SwipeCoordinates(view.center, view.safeTop, view.center, screen.bottom)
+                    ),
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_DOWN, false, Double.NaN, 0.0, 0.0),
+                            SwipeCoordinates(view.safeLeft, view.safeTop, view.safeLeft, view.safeTop + 0.75f * screen.height)
+                    ),
+                    arrayOf(
+                            SwipeArguments(MOTION_DIR_DOWN, false, 0.0, 0.5, 0.5),
+                            SwipeCoordinates(view.center, view.middle, view.center, view.middle)
+                    )
+            )
+        }
+
+        val view = RectangleCalculator(200, 250, 3600, 4500, SwipeHelper.edgeFuzzFactor)
+        val screen = RectangleCalculator(0, 0, 4000, 5000, 0f)
+    }
+
+    private fun setupMockView(): View {
+        val mockView = mock<View>()
+        val mockDisplayMetrics = mock<DisplayMetrics>()
+        mockDisplayMetrics.widthPixels = screen.width
+        mockDisplayMetrics.heightPixels = screen.height
+
+        val mockResources = mock<Resources>()
+        whenever(mockResources.displayMetrics).then { mockDisplayMetrics }
+
+        val mockContext = mock<Context>()
+        whenever(mockContext.resources).then { mockResources }
+        whenever(mockView.context).then { mockContext }
+        whenever(mockView.width).then { view.width }
+        whenever(mockView.height).then { view.height }
+        whenever(mockView.getLocationOnScreen(IntArray(2))).then {
+            val arg0 = it.arguments[0]
+            val xy = arg0 as IntArray
+            xy[0] = view.x
+            xy[1] = view.y
+            xy
+        }
+
+        return mockView
+    }
+}
diff --git a/detox/android/detox/src/test/java/com/wix/detox/espresso/utils/Vector2DSpec.kt b/detox/android/detox/src/test/java/com/wix/detox/espresso/utils/Vector2DSpec.kt
new file mode 100644
index 000000000..4dbda2135
--- /dev/null
+++ b/detox/android/detox/src/test/java/com/wix/detox/espresso/utils/Vector2DSpec.kt
@@ -0,0 +1,93 @@
+package com.wix.detox.espresso.utils
+
+import com.wix.detox.espresso.common.annot.*
+import org.spekframework.spek2.Spek
+import org.spekframework.spek2.style.specification.describe
+import kotlin.test.assertEquals
+
+object Vector2DSpec: Spek({
+    describe("Vector2DSpec") {
+        it("should have x, y coordinates") {
+            val vector = Vector2D(3.0, 4.0)
+            assertEquals(Pair(3.0, 4.0), Pair(vector.x, vector.y))
+        }
+
+        it("should be comparable to each other") {
+            val vector1 = Vector2D(3.0, 4.0)
+            val vector2 = Vector2D(3.0, 4.0)
+            assertEquals(vector1, vector2)
+        }
+
+        it("should be rotatable clockwise") {
+            val vector = Vector2D(3.0, 4.0)
+
+            assertEquals(Vector2D(4.0, -3.0), vector.rotate(MOTION_DIR_UP, MOTION_DIR_RIGHT))
+            assertEquals(Vector2D(-3.0, -4.0), vector.rotate(MOTION_DIR_UP, MOTION_DIR_DOWN))
+            assertEquals(Vector2D(-4.0, 3.0), vector.rotate(MOTION_DIR_UP, MOTION_DIR_LEFT))
+            assertEquals(vector, vector.rotate(MOTION_DIR_UP, MOTION_DIR_UP))
+        }
+
+        it("should be have equivalent rotations clockwise") {
+            val vector = Vector2D(3.0, 4.0)
+
+            assertEquals(vector.rotate(MOTION_DIR_UP, MOTION_DIR_RIGHT), vector.rotate(MOTION_DIR_RIGHT, MOTION_DIR_DOWN))
+            assertEquals(vector.rotate(MOTION_DIR_RIGHT, MOTION_DIR_DOWN), vector.rotate(MOTION_DIR_DOWN, MOTION_DIR_LEFT))
+            assertEquals(vector.rotate(MOTION_DIR_DOWN, MOTION_DIR_LEFT), vector.rotate(MOTION_DIR_LEFT, MOTION_DIR_UP))
+            assertEquals(vector.rotate(MOTION_DIR_LEFT, MOTION_DIR_UP), vector.rotate(MOTION_DIR_UP, MOTION_DIR_RIGHT));
+        }
+
+        it("should be have equivalent rotations counter-clockwise") {
+            val vector = Vector2D(3.0, 4.0)
+
+            assertEquals(vector.rotate(MOTION_DIR_UP, MOTION_DIR_LEFT), vector.rotate(MOTION_DIR_LEFT, MOTION_DIR_DOWN))
+            assertEquals(vector.rotate(MOTION_DIR_LEFT, MOTION_DIR_DOWN), vector.rotate(MOTION_DIR_DOWN, MOTION_DIR_RIGHT))
+            assertEquals(vector.rotate(MOTION_DIR_DOWN, MOTION_DIR_RIGHT), vector.rotate(MOTION_DIR_RIGHT, MOTION_DIR_UP))
+            assertEquals(vector.rotate(MOTION_DIR_RIGHT, MOTION_DIR_UP), vector.rotate(MOTION_DIR_UP, MOTION_DIR_LEFT));
+        }
+
+        it("should be normalizable") {
+            assertEquals(Vector2D(0.5, 0.25), Vector2D(0.5, 0.25).normalize())
+            assertEquals(Vector2D(0.25, 0.5), Vector2D(1.25, 0.5).normalize())
+            assertEquals(Vector2D(0.0, 0.0), Vector2D(3.0, 4.0).normalize())
+            assertEquals(Vector2D(0.75, 0.25), Vector2D(-3.25, -4.75).normalize())
+        }
+
+        it("should be trimmable by max value") {
+            assertEquals(Vector2D(1.0, 1.0), Vector2D(1.0, 1.0).trimMax(0.0, 0.0))
+            assertEquals(Vector2D(100.0, 1.0), Vector2D(1.0, 1.0).trimMax(100.0, 0.0))
+            assertEquals(Vector2D(1.0, 100.0), Vector2D(1.0, 1.0).trimMax(0.0, 100.0))
+            assertEquals(Vector2D(0.0, 0.0), Vector2D(-1000.0, -3000.0).trimMax(0.0, 0.0))
+            assertEquals(Vector2D(-1000.0, -3000.0).trimMax(0.0, 0.0), Vector2D(-1000.0, -3000.0).trimMax(0.0))
+        }
+
+        it("should be scalable by amount") {
+            assertEquals(Vector2D(4.0, 6.0), Vector2D(2.0, 2.0).scale(2.0, 3.0))
+            assertEquals(Vector2D(1.0, 1.0), Vector2D(2.0, 2.0).scale(0.5))
+        }
+
+        it("should be scalable by amount vector") {
+            val factor = Vector2D(0.0, -1.0)
+            assertEquals(Vector2D(0.0, -2.0), Vector2D(2.0, 2.0).scale(factor))
+        }
+
+        it("should be X/Y settable") {
+            assertEquals(Vector2D(4.0, 2.0), Vector2D(2.0, 2.0).withX(4.0))
+            assertEquals(Vector2D(2.0, 4.0), Vector2D(2.0, 2.0).withY(4.0))
+        }
+
+        it("should be addable") {
+            val vector1 = Vector2D(1.0, 1.0)
+            val vector2 = Vector2D(2.0, -4.0)
+
+            assertEquals(Vector2D(3.0, -3.0), vector1.add(vector2))
+        }
+
+        it("should be creatable from FloatArray") {
+            assertEquals(Vector2D(0.0, 1.0), Vector2D.from(FloatArray(2) { i -> i.toFloat() }))
+        }
+
+        it("should be creatable from x: Int, y: Int") {
+            assertEquals(Vector2D(1.0, 1.0), Vector2D.from(1, 1))
+        }
+    }
+})
diff --git a/detox/src/android/expect.test.js b/detox/src/android/expect.test.js
index a3f5369a6..9bbd9d6c1 100644
--- a/detox/src/android/expect.test.js
+++ b/detox/src/android/expect.test.js
@@ -198,6 +198,8 @@ describe('expect', () => {
       await e.element(e.by.id('ScrollView799')).swipe('up', 'slow', 0.9);
       await e.element(e.by.id('ScrollView799')).swipe('left', 'fast', 0.9);
       await e.element(e.by.id('ScrollView799')).swipe('right', 'slow', 0.9);
+      await e.element(e.by.id('ScrollView799')).swipe('down', 'fast', undefined, undefined, 0.25);
+      await e.element(e.by.id('ScrollView799')).swipe('up', 'slow', 0.9, 0.5, 0.5);
     });
 
     it('should not swipe given bad args', async () => {
diff --git a/detox/src/ios/expectTwo.test.js b/detox/src/ios/expectTwo.test.js
index c1e882f5b..e837ce8b6 100644
--- a/detox/src/ios/expectTwo.test.js
+++ b/detox/src/ios/expectTwo.test.js
@@ -317,6 +317,23 @@ describe('expectTwo', () => {
     expect(testCall).deepEquals(jsonOutput);
   });
 
+  it(`element(by.id('ScrollView100')).swipe('up', 'fast', undefined, undefined, 0.5)`, () => {
+    const testCall = e.element(e.by.id('ScrollView100')).swipe('up', 'fast', undefined, undefined, 0.5);
+    const jsonOutput = {
+      invocation: {
+        type: 'action',
+        action: 'swipe',
+        params: ['up', 'fast', 0.75, null, 0.5],
+        predicate: {
+          type: 'id',
+          value: 'ScrollView100'
+        }
+      }
+    };
+
+    expect(testCall).deepEquals(jsonOutput);
+  });
+
   it(`waitFor(element(by.text('Text5'))).toBeNotVisible().whileElement(by.id('ScrollView630')).scroll(50, 'down')`, () => {
     const testCall = e.waitFor(e.element(e.by.text('Text5'))).toBeNotVisible().whileElement(e.by.id('ScrollView630')).scroll(50, 'down');
     const jsonOutput = {
diff --git a/detox/src/ios/expectTwoApiCoverage.test.js b/detox/src/ios/expectTwoApiCoverage.test.js
index 573d0e8d0..462a708c2 100644
--- a/detox/src/ios/expectTwoApiCoverage.test.js
+++ b/detox/src/ios/expectTwoApiCoverage.test.js
@@ -135,6 +135,10 @@ describe('expectTwo API Coverage', () => {
       await e.element(e.by.id('someId')).swipe('up', 'slow', 0.9);
       await e.element(e.by.id('someId')).swipe('left', 'fast', 0.9);
       await e.element(e.by.id('someId')).swipe('right', 'slow', 0.9);
+      await e.element(e.by.id('someId')).swipe('down', 'fast', undefined, 0.5);
+      await e.element(e.by.id('someId')).swipe('up', 'slow', 0.5, NaN, 1);
+      await e.element(e.by.id('someId')).swipe('left', 'fast', undefined, undefined, 0.5);
+      await e.element(e.by.id('someId')).swipe('right', 'slow', 0.9, 1, 0.5);
       await e.element(e.by.id('someId')).atIndex(1).tap();
       await e.element(e.by.id('someId')).setDatePickerDate('2019-2-8T05:10:00-08:00', 'yyyy-MM-dd\'T\'HH:mm:ssZZZZZ');
       await e.element(e.by.id('slider')).adjustSliderToPosition(0.5);
@@ -173,6 +177,8 @@ describe('expectTwo API Coverage', () => {
       await expectToThrow(() => e.element(e.by.id('someId')).swipe('noDirection', 'fast'));
       await expectToThrow(() => e.element(e.by.id('someId')).swipe('down', 'NotFastNorSlow'));
       await expectToThrow(() => e.element(e.by.id('someId')).swipe('down', 'NotFastNorSlow', 0.9));
+      await expectToThrow(() => e.element(e.by.id('someId')).swipe('down', 'fast', 0.9, 100, 0));
+      await expectToThrow(() => e.element(e.by.id('someId')).swipe('down', 'fast', 0.9, 0, 100));
 
       await expectToThrow(() => e.element(e.by.id('someId')).atIndex('NaN'));
 
diff --git a/detox/src/utils/__snapshots__/assertArgument.test.js.snap b/detox/src/utils/__snapshots__/assertArgument.test.js.snap
new file mode 100644
index 000000000..1b021c40d
--- /dev/null
+++ b/detox/src/utils/__snapshots__/assertArgument.test.js.snap
@@ -0,0 +1,23 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`assertEnum should create an assertion function for enums 1`] = `"speed should be one of [fast, slow], but got medium (string)"`;
+
+exports[`assertNormalized should throw for "0.5" 1`] = `"invalidNumber should be a number, but got 0.5 (string)"`;
+
+exports[`assertNormalized should throw for -0.5 1`] = `"invalidNumber should be a number [0.0, 1.0], but got -0.5 (number)"`;
+
+exports[`assertNormalized should throw for 1.01 1`] = `"invalidNumber should be a number [0.0, 1.0], but got 1.01 (number)"`;
+
+exports[`assertNormalized should throw for null 1`] = `"invalidNumber should be a number [0.0, 1.0], but got Infinity (number)"`;
+
+exports[`assertNormalized should throw for null 2`] = `"invalidNumber should be a number, but got null (object)"`;
+
+exports[`assertNormalized should throw for undefined 1`] = `"invalidNumber should be a number, but got undefined (undefined)"`;
+
+exports[`assertNumber should throw for "42" 1`] = `"invalidNumber should be a number, but got 42 (string)"`;
+
+exports[`assertNumber should throw for false 1`] = `"invalidNumber should be a number, but got false (boolean)"`;
+
+exports[`assertString should throw for 123 1`] = `"invalidString should be a string, but got 123 (number)"`;
+
+exports[`assertString should throw for undefined 1`] = `"invalidString should be a string, but got undefined (undefined)"`;
diff --git a/detox/src/utils/assertArgument.test.js b/detox/src/utils/assertArgument.test.js
new file mode 100644
index 000000000..d799f0435
--- /dev/null
+++ b/detox/src/utils/assertArgument.test.js
@@ -0,0 +1,75 @@
+const assertions = require('./assertArgument');
+
+describe('assertEnum', () => {
+  const { assertEnum } = assertions;
+
+  it('should create an assertion function for enums', () => {
+    const assertSpeed = assertEnum(['fast', 'slow']);
+
+    expect(() => assertSpeed({ speed: 'fast' })).not.toThrow();
+    expect(() => assertSpeed({ speed: 'slow' })).not.toThrow();
+    expect(() => assertSpeed({ speed: 'medium' })).toThrowErrorMatchingSnapshot();
+  });
+});
+
+describe('assertNormalized', () => {
+  const { assertNormalized } = assertions;
+
+  it.each([
+    0,
+    0.5,
+    1,
+    NaN
+  ])('should pass for %d', (validNumber) => {
+    expect(() => assertNormalized({ validNumber })).not.toThrow();
+  });
+
+  it.each([
+    -0.5,
+    1.01,
+    Infinity,
+    null,
+    undefined,
+    '0.5'
+  ])('should throw for %j', (invalidNumber) => {
+    expect(() => assertNormalized({ invalidNumber })).toThrowErrorMatchingSnapshot();
+  });
+});
+
+describe('assertNumber', () => {
+  const { assertNumber } = assertions;
+
+  it.each([
+    42,
+    NaN,
+    Infinity,
+    -Infinity,
+  ])('should pass for %d', (validNumber) => {
+    expect(() => assertNumber({ validNumber })).not.toThrow();
+  });
+
+  it.each([
+    '42',
+    false,
+  ])('should throw for %j', (invalidNumber) => {
+    expect(() => assertNumber({ invalidNumber })).toThrowErrorMatchingSnapshot();
+  });
+});
+
+describe('assertString', () => {
+  const { assertString } = assertions;
+
+  it.each([
+    '',
+    '123',
+  ])('should pass for %d', (validString) => {
+    expect(() => assertString({ validString })).not.toThrow();
+  });
+
+  it.each([
+    123,
+    undefined,
+  ])('should throw for %j', (invalidString) => {
+    expect(() => assertString({ invalidString })).toThrowErrorMatchingSnapshot();
+  });
+});
diff --git a/detox/test/android/gradle/wrapper/gradle-wrapper.properties b/detox/test/android/gradle/wrapper/gradle-wrapper.properties
index 4ac1a408c..2b7945f20 100644
--- a/detox/test/android/gradle/wrapper/gradle-wrapper.properties
+++ b/detox/test/android/gradle/wrapper/gradle-wrapper.properties
@@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-6.2.2-all.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-6.7-all.zip
diff --git a/detox/test/e2e/03.actions.test.js b/detox/test/e2e/03.actions.test.js
index e8c83b887..90c8a6de4 100644
--- a/detox/test/e2e/03.actions.test.js
+++ b/detox/test/e2e/03.actions.test.js
@@ -201,12 +201,41 @@ describe('Actions', () => {
     await expect(element(by.text('HText6'))).not.toBeVisible();
   });
 
-  // directions: 'up'/'down'/'left'/'right', speed: 'fast'/'slow'
-  it(':ios: should swipe down until pull to reload is triggered', async () => {
+  it('should swipe down until pull to reload is triggered', async () => {
     await element(by.id('ScrollView799')).swipe('down', 'fast');
     await expect(element(by.text('PullToReload Working!!!'))).toBeVisible();
   });
 
+  it('should swipe vertically', async () => {
+    await expect(element(by.text('Text1'))).toBeVisible();
+    await element(by.id('ScrollView161')).swipe('up');
+
+    if (device.getPlatform() === 'ios') {
+      // TODO: investigate why this assertion fails on Android
+      await expect(element(by.text('Text1'))).not.toBeVisible();
+    }
+
+    await element(by.id('ScrollView161')).swipe('down');
+    await expect(element(by.text('Text1'))).toBeVisible();
+  });
+
+  it('should swipe horizontally', async () => {
+    await expect(element(by.text('HText1'))).toBeVisible();
+    await element(by.id('ScrollViewH')).swipe('left');
+    await expect(element(by.text('HText1'))).not.toBeVisible();
+    await element(by.id('ScrollViewH')).swipe('right');
+    await expect(element(by.text('HText1'))).toBeVisible();
+  });
+
+  it('should swipe by offset from specified positions', async () => {
+    await element(by.id('toggleScrollOverlays')).tap();
+
+    await element(by.id('ScrollView161')).swipe('up', 'slow', NaN, 0.9, 0.95);
+    await element(by.id('ScrollView161')).swipe('down', 'fast', NaN, 0.1, 0.05);
+    await element(by.id('ScrollViewH')).swipe('left', 'slow', 0.25, 0.85, 0.75);
+    await element(by.id('ScrollViewH')).swipe('right', 'fast', 0.25, 0.15, 0.25);
+  });
+
   it('should not wait for long timeout (>1.5s)', async () => {
     await element(by.id('WhyDoAllTheTestIDsHaveTheseStrangeNames')).tap();
     await expect(element(by.id('WhyDoAllTheTestIDsHaveTheseStrangeNames'))).toBeVisible();