Repository: wix/Detox
Pull request #3620 (2022-10-02 15:30:50)

fix(iOS): revert^2 hittability-test mechanism fixes.
This PR reverts https://github.com/wix/Detox/pull/3150, which means it adds the following changes:
- https://github.com/wix/Detox/pull/3149
- https://github.com/wix/Detox/pull/3145
- https://github.com/wix/Detox/pull/3115

Those changes were meant to solve this issue https://github.com/wix/Detox/issues/2834, but caused other issue to arise that was resolved here: https://github.com/facebook/react-native/commit/a46a99e1203.


Patch:
diff --git a/detox/ios/Detox.xcodeproj/project.pbxproj b/detox/ios/Detox.xcodeproj/project.pbxproj
index b86107e8a..f7e025748 100644
--- a/detox/ios/Detox.xcodeproj/project.pbxproj
+++ b/detox/ios/Detox.xcodeproj/project.pbxproj
@@ -112,6 +112,8 @@
 		6062B5E62720323700CBDBF0 /* DTXAddressInfo.mm in Sources */ = {isa = PBXBuildFile; fileRef = 6062B5E02720323700CBDBF0 /* DTXAddressInfo.mm */; };
 		60C1961A271F11C4000172DD /* fishhook.h in Headers */ = {isa = PBXBuildFile; fileRef = 60C19618271F11C4000172DD /* fishhook.h */; };
 		60C1961B271F11C4000172DD /* fishhook.c in Sources */ = {isa = PBXBuildFile; fileRef = 60C19619271F11C4000172DD /* fishhook.c */; };
+		60E149C72759038F00519EE4 /* UIResponder+First.h in Headers */ = {isa = PBXBuildFile; fileRef = 60E149C52759038F00519EE4 /* UIResponder+First.h */; };
+		60E149C82759038F00519EE4 /* UIResponder+First.m in Sources */ = {isa = PBXBuildFile; fileRef = 60E149C62759038F00519EE4 /* UIResponder+First.m */; };
 		AD4781082636F7CF006774CD /* NSURL+DetoxUtils.h in Headers */ = {isa = PBXBuildFile; fileRef = AD4781062636F7CE006774CD /* NSURL+DetoxUtils.h */; settings = {ATTRIBUTES = (Private, ); }; };
 		AD4781092636F7CF006774CD /* NSURL+DetoxUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = AD4781072636F7CF006774CD /* NSURL+DetoxUtils.m */; };
 /* End PBXBuildFile section */
@@ -299,6 +301,8 @@
 		6062B5E02720323700CBDBF0 /* DTXAddressInfo.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = DTXAddressInfo.mm; path = DetoxSync/DetoxSync/DTXObjectiveCHelpers/DTXAddressInfo.mm; sourceTree = SOURCE_ROOT; };
 		60C19618271F11C4000172DD /* fishhook.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = fishhook.h; path = DetoxSync/DetoxSync/fishhook/fishhook.h; sourceTree = SOURCE_ROOT; };
 		60C19619271F11C4000172DD /* fishhook.c */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.c; name = fishhook.c; path = DetoxSync/DetoxSync/fishhook/fishhook.c; sourceTree = SOURCE_ROOT; };
+		60E149C52759038F00519EE4 /* UIResponder+First.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "UIResponder+First.h"; sourceTree = "<group>"; };
+		60E149C62759038F00519EE4 /* UIResponder+First.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "UIResponder+First.m"; sourceTree = "<group>"; };
 		AD4781062636F7CE006774CD /* NSURL+DetoxUtils.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSURL+DetoxUtils.h"; sourceTree = "<group>"; };
 		AD4781072636F7CF006774CD /* NSURL+DetoxUtils.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSURL+DetoxUtils.m"; sourceTree = "<group>"; };
 /* End PBXFileReference section */
@@ -525,6 +529,8 @@
 				395B06E3256D5A5600941716 /* UIView+DetoxSpeedup.m */,
 				AD4781062636F7CE006774CD /* NSURL+DetoxUtils.h */,
 				AD4781072636F7CF006774CD /* NSURL+DetoxUtils.m */,
+				60E149C52759038F00519EE4 /* UIResponder+First.h */,
+				60E149C62759038F00519EE4 /* UIResponder+First.m */,
 			);
 			path = Utilities;
 			sourceTree = "<group>";
@@ -601,6 +607,7 @@
 			isa = PBXHeadersBuildPhase;
 			buildActionMask = 2147483647;
 			files = (
+				60E149C72759038F00519EE4 /* UIResponder+First.h in Headers */,
 				3947679C1DBF985400D72256 /* Detox.h in Headers */,
 				39CA978C245B13CB00A7FC43 /* UIDevice+DetoxActions.h in Headers */,
 				3980D11C2448B52C004812DD /* DTXSyntheticEvents.h in Headers */,
@@ -828,6 +835,7 @@
 				396D455725238BCE0096E7FA /* UIImage+DetoxUtils.m in Sources */,
 				39A34C721E30F10D00BEBB59 /* DetoxAppDelegateProxy.m in Sources */,
 				39EECB7D24C0A5AF009C3364 /* NSThread+DetoxUtils.m in Sources */,
+				60E149C82759038F00519EE4 /* UIResponder+First.m in Sources */,
 				390DED84248906FC00E27BE8 /* UIWindow+DetoxUtils.m in Sources */,
 				3980D158244C45EA004812DD /* Modifier.swift in Sources */,
 				39EECB4324BF4FDA009C3364 /* ReactNativeSupport.m in Sources */,
diff --git a/detox/ios/Detox/Actions/UIDatePicker+DetoxActions.m b/detox/ios/Detox/Actions/UIDatePicker+DetoxActions.m
index 9cf4605fa..ea862d869 100644
--- a/detox/ios/Detox/Actions/UIDatePicker+DetoxActions.m
+++ b/detox/ios/Detox/Actions/UIDatePicker+DetoxActions.m
@@ -13,7 +13,7 @@
 
 - (void)dtx_adjustToDate:(NSDate*)date
 {
-	[self dtx_assertVisible];
+	[self dtx_assertHittable];
 	
 	NSDate* previousDate = self.date;
 	
diff --git a/detox/ios/Detox/Actions/UIPickerView+DetoxActions.m b/detox/ios/Detox/Actions/UIPickerView+DetoxActions.m
index c9c2147f5..5b610399e 100644
--- a/detox/ios/Detox/Actions/UIPickerView+DetoxActions.m
+++ b/detox/ios/Detox/Actions/UIPickerView+DetoxActions.m
@@ -14,7 +14,7 @@
 
 - (void)dtx_setComponent:(NSInteger)component toValue:(id)value
 {
-	[self dtx_assertVisible];
+	[self dtx_assertHittable];
 	
 	DTXViewAssert(self.dataSource != nil && self.delegate != nil, self.dtx_elementDebugAttributes, @"The picker view's data source and/or delegate are nil");
 	
diff --git a/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m b/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m
index 1858e3830..5bcd14747 100644
--- a/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m
+++ b/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m
@@ -289,7 +289,10 @@ if(isnan(normalizedStartingPoint.main) || normalizedStartingPoint.main < 0 || no
 	}
 	
 	CGPoint startPoint = CGPointMake(safeAreaToScroll.origin.x + safeAreaToScroll.size.width * normalizedStartingPoint.x, safeAreaToScroll.origin.y + safeAreaToScroll.size.height * normalizedStartingPoint.y);
-	
+
+	CGPoint viewPoint = [self convertPoint:startPoint fromView:self.window];
+	[self dtx_assertScrollableAtPoint:viewPoint];
+
 	NSUInteger successfullyAppliedScrolls = 0;
 	while (offset.x != 0.0 || offset.y != 0.0)
 	{
@@ -305,7 +308,16 @@ if(isnan(normalizedStartingPoint.main) || normalizedStartingPoint.main < 0 || no
 	DTXViewAssert(strict == NO || successfullyAppliedScrolls > 0, self.dtx_elementDebugAttributes, @"Unable to scroll %@ in “%@”", _DTXScrollDirectionDescriptionWithOffset(offset), self.dtx_shortDescription);
 	
 	self.dtx_disableDecelerationForScroll = NO;
-//	self.bounces = oldBounces;
+}
+
+- (void)dtx_assertScrollableAtPoint:(CGPoint)startPoint {
+  NSError *error;
+  DTXAssert([self dtx_isHittableAtPoint:startPoint error:&error],
+			@"View is not scrollable at the given start point. Start point (view coordinates): %@" \
+			"\n- Full error: %@\n----\nMore about scroll action API here: " \
+			"https://wix.github.io/Detox/docs/api/actions-on-element/" \
+			"#scrolloffset-direction-startpositionx-startpositiony",
+			NSStringFromCGPoint(startPoint), error.localizedDescription);
 }
 
 @end
diff --git a/detox/ios/Detox/Invocation/Element.swift b/detox/ios/Detox/Invocation/Element.swift
index 8f574de03..2150b3cfc 100644
--- a/detox/ios/Detox/Invocation/Element.swift
+++ b/detox/ios/Detox/Invocation/Element.swift
@@ -213,13 +213,7 @@ class Element : NSObject {
 	}
 	
 	func isHittable() throws -> Bool {
-		var error: NSError? = nil
-		let rv = view.dtx_isHittable(at: view.dtx_accessibilityActivationPointInViewCoordinateSpace, error: &error)
-		if let error = error {
-			throw error
-		}
-		
-		return rv
+		return view.dtx_isHittable
 	}
 	
 	func takeScreenshot(fileName: String?) -> [String : Any] {
diff --git a/detox/ios/Detox/Utilities/NSObject+DetoxUtils.h b/detox/ios/Detox/Utilities/NSObject+DetoxUtils.h
index 170961945..d2df54f36 100644
--- a/detox/ios/Detox/Utilities/NSObject+DetoxUtils.h
+++ b/detox/ios/Detox/Utilities/NSObject+DetoxUtils.h
@@ -33,16 +33,17 @@ static double LNLinearInterpolate(CGFloat from, CGFloat to, CGFloat p)
 @property (nonatomic, readonly) CGRect dtx_visibleBounds;
 
 - (BOOL)dtx_isVisible;
+- (void)dtx_assertVisibleWithPercent:(nullable NSNumber *)percent;
 - (BOOL)dtx_isVisibleAtRect:(CGRect)rect percent:(nullable NSNumber *)percent
-					  error:(NSError* __strong * __nullable)error;
+					  error:(NSError* __strong __nullable * __nullable)error;
 - (void)dtx_assertVisible;
 - (void)dtx_assertVisibleAtRect:(CGRect)rect percent:(nullable NSNumber *)percent;
 
 - (BOOL)dtx_isFocused;
 
 @property (nonatomic, readonly) BOOL dtx_isHittable;
-- (BOOL)dtx_isHittableAtPoint:(CGPoint)point;
-- (BOOL)dtx_isHittableAtPoint:(CGPoint)point error:(NSError* __strong * __nullable)error;
+- (BOOL)dtx_isHittableAtPoint:(CGPoint)viewPoint
+						error:(NSError* __strong __nullable * __nullable)error;
 - (void)dtx_assertHittable;
 - (void)dtx_assertHittableAtPoint:(CGPoint)point;
 
diff --git a/detox/ios/Detox/Utilities/NSObject+DetoxUtils.m b/detox/ios/Detox/Utilities/NSObject+DetoxUtils.m
index 5db6867a5..2574c9d40 100644
--- a/detox/ios/Detox/Utilities/NSObject+DetoxUtils.m
+++ b/detox/ios/Detox/Utilities/NSObject+DetoxUtils.m
@@ -41,7 +41,7 @@ static NSDictionary* DTXPointToDictionary(CGPoint point)
 DTX_ALWAYS_INLINE
 static NSString* DTXPointToString(CGPoint point)
 {
-	return [[NSString alloc] initWithData:[NSJSONSerialization dataWithJSONObject:DTXPointToDictionary(point) options:0 error:NULL] encoding:NSUTF8StringEncoding];
+	return [[NSString alloc] initWithData:[NSJSONSerialization dataWithJSONObject:DTXPointToDictionary(point) options:0 error:nil] encoding:NSUTF8StringEncoding];
 }
 
 @interface NSObject ()
@@ -137,19 +137,23 @@ BOOL __DTXPointEqualToPoint(CGPoint a, CGPoint b)
 }
 
 - (BOOL)dtx_isVisible {
-	return [self dtx_isVisibleAtRect:self.dtx_bounds percent:nil error:NULL];
+	return [self dtx_isVisibleAtRect:self.dtx_bounds percent:nil error:nil];
 }
 
 - (BOOL)dtx_isVisibleAtRect:(CGRect)rect percent:(nullable NSNumber *)percent
-					  error:(NSError *__strong  _Nullable *)error {
+					  error:(NSError* __strong __nullable * __nullable)error {
 	return [self.dtx_view dtx_isVisibleAtRect:rect percent:percent error:error];
 }
 
 - (void)dtx_assertVisible {
-	[self dtx_assertVisibleAtRect:self.dtx_bounds percent:nil];
+	[self dtx_assertVisibleWithPercent:nil];
 }
 
-- (void)dtx_assertVisibleAtRect:(CGRect)rect percent:(NSNumber *)percent {
+- (void)dtx_assertVisibleWithPercent:(nullable NSNumber *)percent {
+  [self dtx_assertVisibleAtRect:self.dtx_bounds percent:percent];
+}
+
+- (void)dtx_assertVisibleAtRect:(CGRect)rect percent:(nullable NSNumber *)percent {
 	[self.dtx_view dtx_assertVisibleAtRect:rect percent:percent];
 }
 
@@ -165,25 +169,14 @@ BOOL __DTXPointEqualToPoint(CGPoint a, CGPoint b)
 	return YES;
 }
 
-- (BOOL)dtx_isHittableAtPoint:(CGPoint)point
-{
-	return YES;
+- (BOOL)dtx_isHittableAtPoint:(CGPoint)viewPoint
+						error:(NSError* __strong __nullable * __nullable)error {
+  return YES;
 }
 
-- (BOOL)dtx_isHittableAtPoint:(CGPoint)point error:(NSError* __strong * __nullable)error
-{
-	return YES;
-}
+- (void)dtx_assertHittable {}
 
-- (void)dtx_assertHittable
-{
-	
-}
-
-- (void)dtx_assertHittableAtPoint:(CGPoint)point
-{
-	
-}
+- (void)dtx_assertHittableAtPoint:(CGPoint)point {}
 
 - (NSString *)dtx_text
 {
@@ -267,7 +260,7 @@ BOOL __DTXPointEqualToPoint(CGPoint a, CGPoint b)
 		{
 			return;
 		}
-		
+
 		if([key isEqualToString:@"dtx_text"])
 		{
 			rv[@"text"] = obj;
@@ -379,9 +372,9 @@ BOOL __DTXPointEqualToPoint(CGPoint a, CGPoint b)
 	return rv;
 }
 
-- (NSDictionary<NSString *,id> *)dtx_elementDebugAttributes
+- (NSDictionary<NSString *, id> *)dtx_elementDebugAttributes
 {
-	NSMutableDictionary* rv = [NSMutableDictionary new];
+	NSMutableDictionary<NSString *, id> *rv = [NSMutableDictionary new];
 	[rv addEntriesFromDictionary:NSObject.dtx_genericElementDebugAttributes];
 	
 	rv[@"elementAttributes"] = [self dtx_attributes];
diff --git a/detox/ios/Detox/Utilities/UIResponder+First.h b/detox/ios/Detox/Utilities/UIResponder+First.h
new file mode 100644
index 000000000..393da605e
--- /dev/null
+++ b/detox/ios/Detox/Utilities/UIResponder+First.h
@@ -0,0 +1,21 @@
+//
+//  UIResponder+First.h
+//  Detox
+//
+//  Created by Asaf Korem on 02/12/2021.
+//  Copyright © 2021 Wix. All rights reserved.
+//
+
+#import <Foundation/Foundation.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface UIResponder (First)
+
+/// Finds the first reponder.
+/// @see https://stackoverflow.com/a/21330810/11686340
++ (instancetype)dtx_first;
+
+@end
+
+NS_ASSUME_NONNULL_END
diff --git a/detox/ios/Detox/Utilities/UIResponder+First.m b/detox/ios/Detox/Utilities/UIResponder+First.m
new file mode 100644
index 000000000..547e09b7d
--- /dev/null
+++ b/detox/ios/Detox/Utilities/UIResponder+First.m
@@ -0,0 +1,26 @@
+//
+//  UIResponder+First.m
+//  Detox
+//
+//  Created by Asaf Korem on 02/12/2021.
+//  Copyright © 2021 Wix. All rights reserved.
+//
+
+#import "UIResponder+First.h"
+
+static __weak UIResponder *_dtx_first;
+
+@implementation UIResponder (First)
+
++ (instancetype)dtx_first {
+  _dtx_first = nil;
+  [[UIApplication sharedApplication] sendAction:@selector(responderAction:) to:nil from:nil
+									   forEvent:nil];
+  return _dtx_first;
+}
+
+- (void)responderAction:(id)sender {
+  _dtx_first = self;
+}
+
+@end
diff --git a/detox/ios/Detox/Utilities/UIView+DetoxUtils.h b/detox/ios/Detox/Utilities/UIView+DetoxUtils.h
index 2a3a5b2fe..fbf139158 100644
--- a/detox/ios/Detox/Utilities/UIView+DetoxUtils.h
+++ b/detox/ios/Detox/Utilities/UIView+DetoxUtils.h
@@ -15,6 +15,7 @@ NS_ASSUME_NONNULL_BEGIN
 
 @property (nonatomic, readonly, weak) UIViewController* dtx_containingViewController;
 - (UIImage*)dtx_imageFromView;
+- (BOOL)isVisibleAroundPoint:(CGPoint)point;
 
 @end
 
diff --git a/detox/ios/Detox/Utilities/UIView+DetoxUtils.m b/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
index d1f4ddeb5..7707f4b2d 100644
--- a/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
+++ b/detox/ios/Detox/Utilities/UIView+DetoxUtils.m
@@ -13,6 +13,7 @@
 #import "UIView+Drawing.h"
 #import "DetoxPolicy.h"
 #import "NSURL+DetoxUtils.h"
+#import "UIResponder+First.h"
 
 @interface DTXTouchVisualizerWindow : UIWindow @end
 
@@ -28,29 +29,11 @@
 DTX_DIRECT_MEMBERS
 @implementation UIView (DetoxUtils)
 
-- (void)dtx_assertHittable
-{
-	[self _dtx_assertHittableAtPoint:self.dtx_accessibilityActivationPointInViewCoordinateSpace isAtActivationPoint:YES];
-}
-
 - (void)dtx_assertVisibleAtRect:(CGRect)rect percent:(nullable NSNumber *)percent {
 	NSError* error;
 	BOOL assert = [self dtx_isVisibleAtRect:rect percent:percent error:&error];
-	
-	DTXViewAssert(assert, self.dtx_elementDebugAttributes, @"%@", error.localizedDescription);
-}
 
-- (void)dtx_assertHittableAtPoint:(CGPoint)point
-{
-	[self _dtx_assertHittableAtPoint:point isAtActivationPoint:NO];
-}
-
-- (void)_dtx_assertHittableAtPoint:(CGPoint)point isAtActivationPoint:(BOOL)isAtActivationPoint
-{
-	NSError* error;
-	BOOL assert = [self dtx_isHittableAtPoint:point error:&error];
-	
-	DTXViewAssert(assert == YES, self.dtx_elementDebugAttributes, @"%@", error.localizedDescription);
+	DTXViewAssert(assert, self.dtx_elementDebugAttributes, @"%@", error.localizedDescription);
 }
 
 - (NSString *)dtx_shortDescription
@@ -119,7 +102,7 @@ DTX_DIRECT_MEMBERS
 			visibleBounds = CGRectIntersection(boundsInSelfCoords, visibleBounds);
 		}
 		
-		if(CGRectIsNull(visibleBounds))
+		if(CGRectIsEmpty(visibleBounds))
 		{
 			break;
 		}
@@ -131,7 +114,7 @@ DTX_DIRECT_MEMBERS
 }
 
 - (BOOL)dtx_isVisibleAtRect:(CGRect)rect percent:(nullable NSNumber *)percent {
-	return [self dtx_isVisibleAtRect:rect percent:percent error:NULL];
+	return [self dtx_isVisibleAtRect:rect percent:percent error:nil];
 }
 
 - (UIImage*)dtx_imageFromView
@@ -197,7 +180,7 @@ DTX_DIRECT_MEMBERS
 			[color setStroke];
 			CGContextStrokeRect(ctx, testedRect);
 			
-//			*stop = YES;
+			//			*stop = YES;
 		}];
 	}
 	
@@ -208,41 +191,41 @@ DTX_DIRECT_MEMBERS
 }
 
 - (BOOL)_dtx_isTestedRegionObscuredWithVisiblePixels:(NSUInteger)visible
-										 totalPixels:(NSUInteger)total percent:(NSUInteger)percent
-											  ofView:(UIView*)lookingFor
-										 explanation:(NSString**)explanation {
+																				 totalPixels:(NSUInteger)total percent:(NSUInteger)percent
+																							ofView:(UIView*)lookingFor
+																				 explanation:(NSString**)explanation {
 	BOOL isRegionObscured = [self isRegionObscuredWithVisiblePixels:visible
-														totalPixels:total percent:percent];
+																											totalPixels:total percent:percent];
 	
 	if (isRegionObscured) {
 		*explanation = [NSString stringWithFormat:@"View does not pass visibility percent "
-						"threshold (%@)", [DetoxPolicy percentDescriptionForPercent:percent]];
+										"threshold (%lu)", (unsigned long)percent];
 	}
 	
 	return isRegionObscured;
 }
 
 - (BOOL)isRegionObscuredWithVisiblePixels:(NSUInteger)visible
-							  totalPixels:(NSUInteger)total percent:(NSUInteger)percent {
+															totalPixels:(NSUInteger)total percent:(NSUInteger)percent {
 	CGFloat visiblePercent = visible / (CGFloat)total * 100.;
 	return visiblePercent < (CGFloat)percent;
 }
 
 - (BOOL)_dtx_isRegionObscured:(CGRect)intersection fromTestedRegion:(CGRect)testedRegion
-					  percent:(NSUInteger)percent {
+											percent:(NSUInteger)percent {
 	CGFloat visible = intersection.size.width * intersection.size.height;
 	CGFloat total = testedRegion.size.width * testedRegion.size.height;
 	return [self isRegionObscuredWithVisiblePixels:visible totalPixels:total percent:percent];
 }
 
 - (BOOL)_dtx_isTestedRegionObscured:(CGRect)testedRegion inWindowBounds:(CGRect)windowBounds
-							percent:(NSUInteger)percent {
+														percent:(NSUInteger)percent {
 	CGRect intersection = CGRectIntersection(windowBounds, testedRegion);
 	return [self _dtx_isRegionObscured:intersection fromTestedRegion:testedRegion percent:percent];
 }
 
 - (BOOL)_dtx_testVisibilityInRect:(CGRect)rect percent:(NSUInteger)percent
-							error:(NSError* __strong *)error {
+														error:(NSError* __strong __nullable * __nullable)error {
 	NSString* prefix = [NSString stringWithFormat:@"View “%@” is not visible:", self.dtx_shortDescription];
 	
 	if(UIApplication.sharedApplication._isSpringBoardShowingAnAlert)
@@ -277,14 +260,11 @@ DTX_DIRECT_MEMBERS
 	CGRect testedRegionInWindowCoords = [windowToUse convertRect:rect fromView:self];
 	
 	CGRect visibleBounds = self.dtx_visibleBounds;
-	
-	if (CGRectIsNull(visibleBounds) || [self _dtx_isRegionObscured:visibleBounds
-		 										  fromTestedRegion:self.dtx_visibleBounds
-														   percent:percent]) {
+	if (CGRectIsEmpty(visibleBounds) ||
+			[self _dtx_isRegionObscured:visibleBounds fromTestedRegion:visibleBounds percent:percent]) {
 		auto errorDescription = [NSString stringWithFormat:@"View is clipped by one or more of its "
-								 "superviews' bounds and does not pass visibility percent "
-								 "threshold (%@)",
-								 [DetoxPolicy percentDescriptionForPercent:percent]];
+														 "superviews' bounds and does not pass visibility percent "
+														 "threshold (%lu)", (unsigned long)percent];
 		
 		auto userInfo = @{ NSLocalizedDescriptionKey: APPLY_PREFIX(errorDescription) };
 		
@@ -295,10 +275,9 @@ DTX_DIRECT_MEMBERS
 	}
 	
 	if ([self _dtx_isTestedRegionObscured:testedRegionInWindowCoords
-						   inWindowBounds:windowToUse.bounds percent:percent]) {
+												 inWindowBounds:windowToUse.bounds percent:percent]) {
 		auto errorDescription = [NSString stringWithFormat:@"View is obscured by its window bounds "
-								 "and does not pass visibility percent threshold (%@)",
-								 [DetoxPolicy percentDescriptionForPercent:percent]];
+														 "and does not pass visibility percent threshold (%lu)", (unsigned long)percent];
 		
 		auto userInfo = @{ NSLocalizedDescriptionKey: APPLY_PREFIX(errorDescription) };
 		
@@ -313,12 +292,12 @@ DTX_DIRECT_MEMBERS
 	
 	NSUInteger total;
 	NSUInteger visible = [image
-	    dtx_numberOfVisiblePixelsWithAlphaThreshold:DetoxPolicy.visibilityPixelAlphaThreshold
-		totalPixels:&total];
+												dtx_numberOfVisiblePixelsWithAlphaThreshold:DetoxPolicy.visibilityPixelAlphaThreshold
+												totalPixels:&total];
 	
 	NSString* explanation;
 	if ([self _dtx_isTestedRegionObscuredWithVisiblePixels:visible totalPixels:total percent:percent
-													ofView:self explanation:&explanation]) {
+																									ofView:self explanation:&explanation]) {
 		NSError* err = [NSError errorWithDomain:@"DetoxErrorDomain" code:0 userInfo:@{NSLocalizedDescriptionKey: APPLY_PREFIX(explanation)}];
 		_DTXPopulateError(err);
 		
@@ -335,31 +314,12 @@ DTX_DIRECT_MEMBERS
 }
 
 - (BOOL)dtx_isVisibleAtRect:(CGRect)rect percent:(nullable NSNumber *)percent
-					  error:(NSError* __strong *)error {
+											error:(NSError* __strong __nullable * __nullable)error {
 	NSUInteger percentValue = percent ? percent.unsignedIntegerValue :
-		DetoxPolicy.defaultPercentThresholdForVisibility;
+	DetoxPolicy.defaultPercentThresholdForVisibility;
 	return [self _dtx_testVisibilityInRect:rect percent:percentValue error:error];
 }
 
-- (BOOL)dtx_isHittable
-{
-	return [self dtx_isHittableAtPoint:self.dtx_accessibilityActivationPointInViewCoordinateSpace error:NULL];
-}
-
-- (BOOL)dtx_isHittableAtPoint:(CGPoint)point
-{
-	return [self dtx_isHittableAtPoint:point error:NULL];
-}
-
-- (CGRect)_dtx_hitBoundsAroundPoint:(CGPoint)point {
-	return CGRectIntersection(self.bounds, CGRectMake(point.x - 0.5, point.y - 0.5, 1, 1));
-}
-
-- (BOOL)dtx_isHittableAtPoint:(CGPoint)point error:(NSError* __strong *)error {
-	return [self _dtx_testVisibilityInRect:[self _dtx_hitBoundsAroundPoint:point] percent:100
-									 error:error];
-}
-
 - (BOOL)dtx_isEnabled
 {
 	BOOL enabled = self.userInteractionEnabled;
@@ -386,4 +346,174 @@ DTX_DIRECT_MEMBERS
 	return rv;
 }
 
+#pragma mark - Check Hitability
+
+- (BOOL)dtx_isHittable {
+	CGPoint point = [self findVisiblePoint];
+	return [self dtx_isHittableAtPoint:point error:nil];
+}
+
+- (CGPoint)findVisiblePoint {
+	CGRect visibleBounds = self.dtx_visibleBounds;
+	if (CGRectIsEmpty(visibleBounds)) {
+		return CGPointMake(NAN, NAN);
+	}
+
+	return CGPointMake(visibleBounds.origin.x + visibleBounds.size.width / 2,
+										 visibleBounds.origin.y + visibleBounds.size.height / 2);
+}
+
+- (BOOL)dtx_isHittableAtPoint:(CGPoint)viewPoint
+												error:(NSError* __strong __nullable * __nullable)error {
+	if (viewPoint.x == NAN || viewPoint.y == NAN) {
+		if (error) {
+			*error = [NSError
+								errorWithDomain:@"Detox" code:0
+								userInfo:@{NSLocalizedDescriptionKey:@"Given point coordinates are NaN"}];
+		}
+
+		return NO;
+	}
+
+	if (![self _isVisibleAroundPoint:viewPoint error:error]) {
+		if (error) {
+			NSString *description = [NSString stringWithFormat:@"View is not visible around" \
+															 " point.\n- view point: %@\n- visible bounds: %@" \
+															 "\n- view bounds: %@\n---\nError: %@",
+															 NSStringFromCGPoint(viewPoint),
+															 NSStringFromCGRect(self.dtx_visibleBounds),
+															 NSStringFromCGRect(self.frame), *error];
+
+			*error = [NSError errorWithDomain:@"Detox" code:0
+															 userInfo:@{NSLocalizedDescriptionKey:description}];
+
+		}
+
+		return NO;
+	}
+
+	CGPoint absPoint = [self calcAbsPointFromLocalPoint:viewPoint];
+
+	UIViewController * _Nullable topMostViewController = [self _topMostViewControllerAtPoint:absPoint];
+	if (!topMostViewController) {
+		if (error) {
+			NSString *description = [NSString stringWithFormat:@"Failed to interact with the screen "
+															 "at point: %@.", NSStringFromCGPoint(viewPoint)];
+			*error = [NSError
+								errorWithDomain:@"Detox" code:0
+								userInfo:@{NSLocalizedDescriptionKey:description}];
+		}
+
+		return NO;
+	}
+
+	UIView *visibleContainer = topMostViewController.view;
+
+	if ([self isDescendantOfView:visibleContainer]) {
+		return [self _canHitFromView:self atAbsPoint:absPoint error:error];
+	}
+
+	UIView *firstResponderInputView = UIResponder.dtx_first.inputView;
+	if ([self isDescendantOfView:firstResponderInputView]) {
+		return [self _canHitFromView:firstResponderInputView atAbsPoint:absPoint error:error];
+	}
+
+	return [self _canHitFromView:visibleContainer atAbsPoint:absPoint error:error];
+}
+
+- (BOOL)isVisibleAroundPoint:(CGPoint)point {
+	return [self _isVisibleAroundPoint:point error:nil];
+}
+
+- (BOOL)_isVisibleAroundPoint:(CGPoint)point error:(NSError* __strong __nullable * __nullable)error {
+	CGRect intersection = CGRectIntersection(
+	    self.dtx_visibleBounds, CGRectMake(point.x - 0.5, point.y - 0.5, 1, 1));
+	return [self _dtx_testVisibilityInRect:intersection percent:100 error:error];
+}
+
+- (BOOL)_canHitFromView:(UIView *)originView atAbsPoint:(CGPoint)point
+									error:(NSError* __strong __nullable * __nullable)error {
+	CGPoint absOrigin = [originView calcAbsOrigin];
+	CGPoint relativePoint = CGPointMake(point.x - absOrigin.x, point.y - absOrigin.y);
+
+	UIView *hitten = [originView hitTest:relativePoint withEvent:nil];
+
+	BOOL hitRemainedOnOrigin = !hitten;
+	if (hitRemainedOnOrigin && originView == self) {
+		return YES;
+	}
+
+	if ([hitten isDescendantOfView:self]) {
+		return YES;
+	}
+
+	if (error) {
+		NSString *hint = [self isDescendantOfView:hitten] ?
+				@"\n- Hint: The Target view is a descendant of the hitten view" : @"";
+
+		NSString *errorMessage =
+		[NSString stringWithFormat:@"Failed to hit view %@at point %@ with hit-test\n" \
+		 "- Origin view: %@\n" \
+		 "- Absolute origin: %@\n" \
+		 "- Hitten: %@%@\n" \
+		 "- Target view: %@\n" \
+		 "- Relative point: %@%@",
+		 self.accessibilityIdentifier ?
+				[NSString stringWithFormat:@"with identifier `%@` ", self.accessibilityIdentifier] :
+				@"",
+		 NSStringFromCGPoint(point),
+		 originView,
+		 NSStringFromCGPoint(absOrigin),
+		 hitten,
+		 hitten.accessibilityIdentifier ?
+				[NSString stringWithFormat:@" (identifier: `%@`)", hitten.accessibilityIdentifier] :
+				@"",
+		 self,
+		 NSStringFromCGPoint(relativePoint),
+		 hint
+		];
+
+		*error = [NSError errorWithDomain:@"Detox" code:0
+														 userInfo:@{NSLocalizedDescriptionKey:errorMessage}];
+	}
+
+	return NO;
+}
+
+- (nullable UIViewController *)_topMostViewControllerAtPoint:(CGPoint)point {
+	UIWindow * _Nullable topMostWindow = [UIWindow dtx_topMostWindowAtPoint:point];
+	if (!topMostWindow) {
+		return nil;
+	}
+
+	return [self _topMostViewControllerForViewController:topMostWindow.rootViewController];
+}
+
+- (UIViewController *)_topMostViewControllerForViewController:(UIViewController *)viewController {
+	if (viewController.presentedViewController) {
+		return [self _topMostViewControllerForViewController:viewController.presentedViewController];
+	}
+
+	return viewController;
+}
+
+- (void)dtx_assertHittable {
+	CGPoint point = [self findVisiblePoint];
+	[self dtx_assertHittableAtPoint:point];
+}
+
+- (void)dtx_assertHittableAtPoint:(CGPoint)point {
+	NSError *error;
+	DTXAssert([self dtx_isHittableAtPoint:point error:&error],
+						@"View is not hittable at its visible point. Error: %@", error.localizedDescription);
+}
+
+- (CGPoint)calcAbsOrigin {
+	return [self.superview calcAbsPointFromLocalPoint:self.frame.origin];
+}
+
+- (CGPoint)calcAbsPointFromLocalPoint:(CGPoint)localPoint {
+	return [self convertPoint:localPoint toView:nil];
+}
+
 @end
diff --git a/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.h b/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.h
index 902b67029..bbb74546e 100644
--- a/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.h
+++ b/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.h
@@ -27,6 +27,7 @@ NS_ASSUME_NONNULL_BEGIN
 + (void)dtx_enumerateAllWindowsUsingBlock:(void (NS_NOESCAPE ^)(UIWindow* obj, NSUInteger idx, BOOL *stop))block;
 + (void)dtx_enumerateKeyWindowSceneWindowsUsingBlock:(void (NS_NOESCAPE ^)(UIWindow* obj, NSUInteger idx, BOOL *stop))block;
 + (void)dtx_enumerateWindowsInScene:(nullable UIWindowScene*)scene usingBlock:(void (NS_NOESCAPE ^)(UIWindow* obj, NSUInteger idx, BOOL *stop))block;
++ (nullable UIWindow *)dtx_topMostWindowAtPoint:(CGPoint)point;
 
 @end
 
diff --git a/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.m b/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.m
index bd23c684c..589f5ae5a 100644
--- a/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.m
+++ b/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.m
@@ -7,8 +7,10 @@
 //
 
 #import "UIWindow+DetoxUtils.h"
-#import "NSObject+DetoxUtils.h"
+
 #import "DTXAppleInternals.h"
+#import "NSObject+DetoxUtils.h"
+#import "UIView+DetoxUtils.h"
 
 extern NSArray* DTXChildElements(id element);
 
@@ -136,17 +138,14 @@ static NSString* _DTXNSStringFromUISceneActivationState(UISceneActivationState s
 
 @implementation UIWindow (DetoxUtils)
 
-+ (UIWindow*)dtx_keyWindow
-{
-    UIWindow *foundWindow = nil;
-    NSArray *windows = [[UIApplication sharedApplication]windows];
-    for (UIWindow *window in windows) {
-        if (window.isKeyWindow) {
-            foundWindow = window;
-            break;
-        }
-    }
-    return foundWindow;
++ (UIWindow*)dtx_keyWindow {
+  NSArray *windows = [[UIApplication sharedApplication]windows];
+  for (UIWindow *window in windows) {
+	  if (window.isKeyWindow) {
+		  return window;
+	  }
+  }
+  return nil;
 }
 
 + (id)dtx_keyWindowScene
@@ -224,4 +223,38 @@ static NSString* _DTXNSStringFromUISceneActivationState(UISceneActivationState s
 	return [NSString stringWithFormat:@"<%@: %p; frame = (%@ %@; %@ %@);>", self.class, self, @(frame.origin.x), @(frame.origin.y), @(frame.size.width), @(frame.size.height)];
 }
 
++ (nullable UIWindow *)dtx_topMostWindowAtPoint:(CGPoint)point {
+  NSArray<UIWindow *> *windows = UIApplication.sharedApplication.windows;
+
+  NSArray<UIWindow *> *visibleWindowsAtPoint = [windows
+    filteredArrayUsingPredicate:[NSPredicate
+	predicateWithBlock:^BOOL(UIWindow *window, NSDictionary<NSString *, id> * _Nullable __unused bindings) {
+	  if (!CGRectContainsPoint(window.frame, point)) {
+		return NO;
+	  }
+
+	  if (![window isVisibleAroundPoint:point]) {
+		return NO;
+	  }
+
+	  UIView * _Nullable hitten = [window hitTest:point withEvent:nil];
+	  if (!hitten) {
+		// The point lies completely outside the windos's hierarchy.
+		return NO;
+	  }
+
+	  return YES;
+	}]];
+
+  if (!visibleWindowsAtPoint) {
+	return nil;
+  }
+
+  return [[visibleWindowsAtPoint
+	sortedArrayUsingComparator:^NSComparisonResult(UIWindow *window1, UIWindow *window2) {
+	  return window1.windowLevel - window2.windowLevel;
+	}]
+	lastObject];
+}
+
 @end
diff --git a/docs/Introduction.AndroidDevEnv.md b/docs/Introduction.AndroidDevEnv.md
index 036b522af..d17e5c2c6 100644
--- a/docs/Introduction.AndroidDevEnv.md
+++ b/docs/Introduction.AndroidDevEnv.md
@@ -89,18 +89,14 @@ While it’s possible to do this using Android Studio, we’ll focus on the comm
    _Note: It is OK if the emulator’s version is not aligned with the SDK or platform-tools' version you currently have installed (e.g. 30.x.x vs. SDK 29)_
 
    ```sh
-   $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --install emulator
+   $ANDROID_HOME/tools/bin/sdkmanager --install emulator
    ```
 
-:::note
-In previous Android Studio versions, the SDK-Manager path was located at `$ANDROID_HOME/tools/bin/sdkmanager`. We highly recommend working with the latest version, however, in case you are working with an old version, and this command fails, try this path instead.
-:::
-
 1. Install an emulator image without Google APIs:
 
    ```shell
-   $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager "system-images;android-28;default;x86_64"
-   $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses
+   $ANDROID_HOME/tools/bin/sdkmanager "system-images;android-28;default;x86_64"
+   $ANDROID_HOME/tools/bin/sdkmanager --licenses
    ```
 
    > - With `;android-28;`, we assumed SDK 28 here, but other APIs are supported just the same.
@@ -110,7 +106,7 @@ In previous Android Studio versions, the SDK-Manager path was located at `$ANDRO
 1. Create an emulator (i.e. AVD - Android Virtual Device):
 
    ```shell
-   $ANDROID_HOME/cmdline-tools/latest/bin/avdmanager create avd -n Pixel_API_28_AOSP -d pixel --package "system-images;android-28;default;x86_64"
+   $ANDROID_HOME/tools/bin/avdmanager create avd -n Pixel_API_28_AOSP -d pixel --package "system-images;android-28;default;x86_64"
    ```
 
    > - `Pixel_API_28_AOSP` is just a suggestion for a name. Any name can work here, even `Pixel_API_28` - but you might have to delete an existing non-AOSP emulator, first. In any case, the name used in Detox configuration (typically in `package.json`) should be identical to this one.


Test patch:
diff --git a/detox/test/e2e/03.actions-scroll.test.js b/detox/test/e2e/03.actions-scroll.test.js
index 738c5bb8f..e4cb32f44 100644
--- a/detox/test/e2e/03.actions-scroll.test.js
+++ b/detox/test/e2e/03.actions-scroll.test.js
@@ -6,7 +6,7 @@ describe('Actions - Scroll', () => {
     await element(by.text('Actions')).tap();
   });
 
-  custom.it.withFailureIf.android.rn58OrNewer('should scroll for a small amount in direction', async () => {
+  custom.it.withFailureIf.android('should scroll for a small amount in direction', async () => {
     await expect(element(by.text('Text1'))).toBeVisible();
     await expect(element(by.text('Text4'))).not.toBeVisible();
     await expect(element(by.id('ScrollView161'))).toBeVisible();
@@ -18,7 +18,7 @@ describe('Actions - Scroll', () => {
     await expect(element(by.text('Text4'))).not.toBeVisible();
   });
 
-  custom.it.withFailureIf.android.rn58OrNewer('should scroll for a large amount in direction', async () => {
+  custom.it.withFailureIf.android('should scroll for a large amount in direction', async () => {
     await expect(element(by.text('Text6'))).not.toBeVisible();
     await element(by.id('ScrollView161')).scroll(220, 'down');
     await expect(element(by.text('Text6'))).toBeVisible();
@@ -103,4 +103,26 @@ describe('Actions - Scroll', () => {
     await element(by.id('ScrollView161')).scrollToIndex(7);
     await expect(element(by.text('Text8'))).toBeVisible();
   });
+
+  it('should not scroll horizontally when scroll view is covered', async () => {
+    await element(by.id('toggleScrollOverlays')).tap();
+    await expect(element(by.text('HText1'))).toBeVisible(1);
+
+    try {
+      await element(by.id('ScrollViewH')).scroll(200, 'right');
+    } catch {}
+
+    await expect(element(by.text('HText1'))).toBeVisible(1);
+  });
+
+  it('should not scroll vertically when scroll view is covered', async () => {
+    await element(by.id('toggleScrollOverlays')).tap();
+    await expect(element(by.text('Text1'))).toBeVisible(1);
+
+    try {
+      await element(by.id('ScrollView161')).scroll(200, 'down');
+    } catch {}
+
+    await expect(element(by.text('Text1'))).toBeVisible(1);
+  });
 });
diff --git a/detox/test/e2e/03.actions.test.js b/detox/test/e2e/03.actions.test.js
index efb6e7f5a..7a7f93e7f 100644
--- a/detox/test/e2e/03.actions.test.js
+++ b/detox/test/e2e/03.actions.test.js
@@ -144,7 +144,7 @@ describe('Actions', () => {
     await element(by.id('ScrollView161')).swipe('up');
 
     if (device.getPlatform() === 'ios') {
-      // TODO: investigate why this assertion fails on Android
+      // This won't work in Android, see related issue: https://github.com/facebook/react-native/issues/23870
       await expect(element(by.text('Text1'))).not.toBeVisible();
     }
 
@@ -154,19 +154,35 @@ describe('Actions', () => {
 
   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 () => {
+  it('should swipe vertically 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);
+    if (device.getPlatform() === 'ios') {
+      // This won't work in Android, see related issue: https://github.com/facebook/react-native/issues/23870
+      await expect(element(by.text('Text1'))).not.toBeVisible(1);
+    }
+
     await element(by.id('ScrollView161')).swipe('down', 'fast', NaN, 0.1, 0.05);
+    await expect(element(by.text('Text1'))).toBeVisible(1);
+  });
+
+  it('should swipe horizontally by offset from specified positions ', async () => {
+    await element(by.id('toggleScrollOverlays')).tap();
+
     await element(by.id('ScrollViewH')).swipe('left', 'slow', 0.25, 0.85, 0.75);
+    await expect(element(by.text('HText1'))).not.toBeVisible(1);
+
     await element(by.id('ScrollViewH')).swipe('right', 'fast', 0.25, 0.15, 0.25);
+    await expect(element(by.text('HText1'))).toBeVisible(1);
   });
 
   it('should not wait for long timeout (>1.5s)', async () => {
diff --git a/detox/test/e2e/05.waitfor.test.js b/detox/test/e2e/05.waitfor.test.js
index 06c4adeeb..4aa095bd6 100644
--- a/detox/test/e2e/05.waitfor.test.js
+++ b/detox/test/e2e/05.waitfor.test.js
@@ -30,7 +30,7 @@ describe('WaitFor', () => {
     await expect(element(by.id('deletedFromHierarchyText'))).not.toBeVisible();
   });
 
-  custom.it.withFailureIf.android.rn58OrNewer('should find element by scrolling until it is visible', async () => {
+  custom.it.withFailureIf.android('should find element by scrolling until it is visible', async () => {
     await expect(element(by.text('Text5'))).not.toBeVisible();
     await element(by.id('GoButton')).tap();
     await waitFor(element(by.text('Text5'))).toBeVisible().whileElement(by.id('ScrollView')).scroll(50, 'down');
diff --git a/detox/test/e2e/32.visibility-debug-artifacts.test.js b/detox/test/e2e/32.visibility-debug-artifacts.test.js
index 1967a1251..b9d8a9d37 100644
--- a/detox/test/e2e/32.visibility-debug-artifacts.test.js
+++ b/detox/test/e2e/32.visibility-debug-artifacts.test.js
@@ -13,7 +13,7 @@ describe(':ios: Visibility Debug Artifacts', () => {
   it('should not be able to tap an overlayed button', async () => {
     await expectToThrow(
       () => element(by.text('Button 1')).tap(),
-      `View does not pass visibility percent threshold (100%)`,
+      `View is not hittable at its visible point. Error: View is not visible around point.`,
     );
   });
 
diff --git a/detox/test/e2e/33.attributes.test.js b/detox/test/e2e/33.attributes.test.js
index c675dc0db..4b1f07f60 100644
--- a/detox/test/e2e/33.attributes.test.js
+++ b/detox/test/e2e/33.attributes.test.js
@@ -215,8 +215,8 @@ describe('Attributes', () => {
 
         const innerViews = attributesArray.filter(a => a.identifier);
         expect(innerViews.length).toBe(2);
-        expect(innerViews[0]).toMatchObject({ ...viewShape, hittable: true });
-        expect(innerViews[1]).toMatchObject({ ...viewShape, hittable: false });
+        expect(innerViews[0]).toMatchObject({ ...viewShape });
+        expect(innerViews[1]).toMatchObject({ ...viewShape });
       });
     });
 
diff --git a/detox/test/e2e/34.visibility.test.js b/detox/test/e2e/34.visibility.test.js
index 32f6b3325..64d9b76df 100644
--- a/detox/test/e2e/34.visibility.test.js
+++ b/detox/test/e2e/34.visibility.test.js
@@ -1,13 +1,10 @@
 describe('visibility expectation', () => {
+  let halfVisibleElement;
+
   beforeEach(async () => {
     await device.reloadReactNative();
     await element(by.text('Visibility Expectation')).tap();
-  });
-
-  let halfVisibleElement;
-
-  beforeEach(() => {
-    halfVisibleElement = element(by.id('halfVisible'));
+    halfVisibleElement = await element(by.id('halfVisible'));
   });
 
   it(`should be truthy when at least 50% visibility is required`, async () => {
diff --git a/detox/test/e2e/35.overlay.test.js b/detox/test/e2e/35.overlay.test.js
new file mode 100644
index 000000000..3274762fc
--- /dev/null
+++ b/detox/test/e2e/35.overlay.test.js
@@ -0,0 +1,80 @@
+const { expectToThrow } = require('./utils/custom-expects');
+const custom = require('./utils/custom-it');
+
+describe(':ios: Overlay', () => {
+  let showAlertButton;
+  let showOverlayButton;
+  let verticalScrollView;
+
+  beforeEach(async () => {
+    await device.reloadReactNative();
+    await device.launchApp({newInstance: true});
+
+    await element(by.text('Overlay')).tap();
+
+    showOverlayButton = await element(by.id('ShowOverlayButton'));
+    await expect(showOverlayButton).toBeVisible();
+
+    showAlertButton = await element(by.id('ShowDismissibleAlertButton'));
+    await expect(showAlertButton).toBeVisible();
+
+    verticalScrollView = await element(by.id('VerticalScrollView'));
+  });
+
+  describe('default behaviour', () => {
+    it('should be able to scroll elements', async () => {
+      await verticalScrollView.scrollTo('bottom');
+      await expect(showOverlayButton).not.toBeVisible();
+    });
+  });
+
+  describe('alert window', () => {
+    let dismissAlertButton;
+
+    beforeEach(async () => {
+      await showAlertButton.tap();
+      dismissAlertButton = await element(by.text('Dismiss'));
+    });
+
+    describe('when shown', () => {
+      it('should not be able to tap on elements', async () => {
+        await expectToThrow(() => showOverlayButton.tap());
+      });
+
+      it('should not be able to scroll elements', async () => {
+        await expectToThrow(() => verticalScrollView.scrollTo('bottom'));
+      });
+    });
+
+    describe('after dismiss', () => {
+      beforeEach(async () => {
+        await dismissAlertButton.tap();
+      });
+
+      custom.it.withFailureIf.iOSWithRNLessThan67('should be able to tap on elements', async () => {
+        await showOverlayButton.tap();
+      });
+
+      custom.it.withFailureIf.iOSWithRNLessThan67('should be able to scroll elements', async () => {
+        await verticalScrollView.scrollTo('bottom');
+        await expect(showOverlayButton).not.toBeVisible();
+      });
+    });
+  });
+
+  describe('overlay window', () => {
+    describe('when shown', () => {
+      beforeEach(async () => {
+        await showOverlayButton.tap();
+      });
+
+      it('should not be able to tap on elements', async () => {
+        await expectToThrow(() => showOverlayButton.tap());
+      });
+
+      it('should not be able to scroll elements', async () => {
+        await expectToThrow(() => verticalScrollView.scrollTo('bottom'));
+      });
+    });
+  });
+});
diff --git a/detox/test/e2e/utils/custom-it.js b/detox/test/e2e/utils/custom-it.js
index 931849a75..1b2037242 100644
--- a/detox/test/e2e/utils/custom-it.js
+++ b/detox/test/e2e/utils/custom-it.js
@@ -3,9 +3,8 @@ const rnMinorVer = require('./rn-consts').rnVersion.minor;
 
 const _it = {
   withFailureIf: {
-    android: {
-      rn58OrNewer: (spec, specFn) => runOrExpectFailByPredicates(spec, specFn, platformIs('android'), rnVerAtLeast(58)),
-    }
+    android: (spec, specFn) => runOrExpectFailByPredicates(spec, specFn, platformIs('android')),
+    iOSWithRNLessThan67: (spec, specFn) => runOrExpectFailByPredicates(spec, specFn, platformIs('ios'), rnVerLessThan(67)),
   },
 };
 
@@ -20,7 +19,7 @@ function runOrExpectFailByPredicates(spec, specFn, ...predicateFuncs) {
 }
 
 const platformIs = (platform) => () => (device.getPlatform() === platform);
-const rnVerAtLeast = (rnVer) => () => (rnMinorVer >= rnVer);
+const rnVerLessThan = (rnVer) => () => (rnMinorVer < rnVer);
 const allPredicatesTrue = (predicateFuncs) => _.reduce(predicateFuncs, (result, predicate) => (result && predicate()), true);
 
 async function expectSpecFail(specFn) {
diff --git a/detox/test/ios/example/NativeModule.m b/detox/test/ios/example/NativeModule.m
index f4d0240c7..4c9dfdac6 100644
--- a/detox/test/ios/example/NativeModule.m
+++ b/detox/test/ios/example/NativeModule.m
@@ -87,4 +87,18 @@ RCT_EXPORT_METHOD(sendNotification:(NSString*)notification name:(NSString*)name)
 	});
 }
 
+RCT_EXPORT_METHOD(presentOverlayWindow) {
+    static UIWindow *overlayWindow;
+
+    dispatch_async(dispatch_get_main_queue(), ^{
+        CGRect screenBounds = UIScreen.mainScreen.bounds;
+        overlayWindow = [[UIWindow alloc] initWithFrame:screenBounds];
+
+        [overlayWindow setWindowLevel:UIWindowLevelStatusBar];
+        [overlayWindow setHidden:NO];
+
+        [overlayWindow makeKeyAndVisible];
+    });
+}
+
 @end
diff --git a/detox/test/package.json b/detox/test/package.json
index beffc788e..bffaf589e 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -31,7 +31,7 @@
   },
   "dependencies": {
     "@react-native-async-storage/async-storage": "^1.17.3",
-    "@react-native-community/checkbox": "0.5.12",
+    "@react-native-community/checkbox": "^0.5.11",
     "@react-native-community/geolocation": "^2.0.2",
     "@react-native-community/slider": "4.2.4",
     "@react-native-picker/picker": "^2.1.0",
diff --git a/detox/test/src/Screens/OverlayScreen.js b/detox/test/src/Screens/OverlayScreen.js
new file mode 100644
index 000000000..6f0227c27
--- /dev/null
+++ b/detox/test/src/Screens/OverlayScreen.js
@@ -0,0 +1,76 @@
+import React, { Component } from 'react';
+import { View, ScrollView, TouchableOpacity, StyleSheet, Text, NativeModules, Dimensions, Alert } from 'react-native';
+
+const { NativeModule } = NativeModules;
+
+export default class OverlayScreen extends Component {
+  render() {
+    return (
+      <ScrollView style={styles.container} testID='VerticalScrollView'>
+        <TouchableOpacity
+          onPress={() => {
+            Alert.alert('Dismiss Alert?', 'Are you sure you want to dismiss the alert?', [
+              {
+                text: 'Dismiss',
+                style: 'cancel',
+              }
+            ])
+          }}
+          style={styles.button}
+          testID='ShowDismissibleAlertButton'
+        >
+          <Text style={styles.text}>
+            Show Alert
+          </Text>
+        </TouchableOpacity>
+
+        <TouchableOpacity onPress={() => NativeModule.presentOverlayWindow()} style={styles.button} testID='ShowOverlayButton'>
+          <Text style={styles.text}>Show Overlay</Text>
+        </TouchableOpacity>
+
+        <View style={styles.item}><Text style={styles.itemText}>Text1</Text></View>
+        <View style={styles.item}><Text style={styles.itemText}>Text2</Text></View>
+        <View style={styles.item}><Text style={styles.itemText}>Text3</Text></View>
+        <View style={styles.item}><Text style={styles.itemText}>Text4</Text></View>
+        <View style={styles.item}><Text style={styles.itemText}>Text5</Text></View>
+        <View style={styles.item}><Text style={styles.itemText}>Text6</Text></View>
+        <View style={styles.item}><Text style={styles.itemText}>Text7</Text></View>
+        <View style={styles.item}><Text style={styles.itemText}>Text8</Text></View>
+        <View style={styles.item}><Text style={styles.itemText}>Text9</Text></View>
+        <View style={styles.item}><Text style={styles.itemText}>Text10</Text></View>
+      </ScrollView>
+    );
+  }
+}
+
+const { height } = Dimensions.get('window');
+const itemHeight = height / 10 + 50;
+const styles = StyleSheet.create({
+  container: {
+    flex: 1
+  },
+  button: {
+    backgroundColor: '#ffd9d9',
+    height: itemHeight,
+    justifyContent: "center",
+    alignItems: "center",
+    margin: 10
+  },
+  text: {
+    fontSize: 18,
+    color: '#000000',
+    textAlign: 'center'
+  },
+  item: {
+    height: itemHeight,
+    backgroundColor: '#d9d9ff',
+    justifyContent: "center",
+    alignItems: "center",
+    margin: 10
+  },
+  itemText: {
+    fontSize: 18,
+    color: '#525252',
+    textAlign: 'center'
+  }
+});
diff --git a/detox/test/src/Screens/index.js b/detox/test/src/Screens/index.js
index 82d9848e0..dbc95dfa5 100644
--- a/detox/test/src/Screens/index.js
+++ b/detox/test/src/Screens/index.js
@@ -21,6 +21,7 @@ import LaunchArgsScreen from './LaunchArgsScreen';
 import LaunchNotificationScreen from './LaunchNotificationScreen';
 import PickerViewScreen from './PickerViewScreen';
 import DeviceScreen from './DeviceScreen';
+import OverlayScreen from './OverlayScreen';
 import ElementScreenshotScreen from './ElementScreenshotScreen';
 import VirtualizedListStressScreen from './VirtualizedListStressScreen';
 import WebViewScreen from './WebViewScreen';
@@ -52,6 +53,7 @@ export {
   LaunchArgsScreen,
   LaunchNotificationScreen,
   DeviceScreen,
+  OverlayScreen,
   ElementScreenshotScreen,
   WebViewScreen,
   VirtualizedListStressScreen,
diff --git a/detox/test/src/app.js b/detox/test/src/app.js
index 7f5a91791..21833aae0 100644
--- a/detox/test/src/app.js
+++ b/detox/test/src/app.js
@@ -105,7 +105,7 @@ export default class example extends Component {
         {this.renderScreenButton('Matchers', Screens.MatchersScreen)}
         {this.renderScreenButton('Actions', Screens.ActionsScreen)}
         {this.renderScreenButton('Visibility Expectation', Screens.VisibilityExpectationScreen)}
-        {!isAndroid && this.renderScreenButton('Visibility Debug Artifacts', Screens.VisibilityScreen)}
+        {isIos && this.renderScreenButton('Visibility Debug Artifacts', Screens.VisibilityScreen)}
         {this.renderScreenButton('Integrative Actions', Screens.IntegActionsScreen)}
         {this.renderScreenButton('FS Scroll Actions', Screens.ScrollActionsScreen)}
         {this.renderScreenButton('Assertions', Screens.AssertionsScreen)}
@@ -118,9 +118,10 @@ export default class example extends Component {
         {this.renderScreenButton('Network', Screens.NetworkScreen)}
         {this.renderAnimationScreenButtons()}
         {this.renderScreenButton('Device', Screens.DeviceScreen)}
+        {isIos && this.renderScreenButton('Overlay', Screens.OverlayScreen)}
         {this.renderScreenButton('Location', Screens.LocationScreen)}
-        {!isAndroid && this.renderScreenButton('DatePicker', Screens.DatePickerScreen)}
-        {!isAndroid && this.renderScreenButton('Picker', Screens.PickerViewScreen)}
+        {isIos && this.renderScreenButton('DatePicker', Screens.DatePickerScreen)}
+        {isIos && this.renderScreenButton('Picker', Screens.PickerViewScreen)}
         {isAndroid && this.renderScreenButton('WebView', Screens.WebViewScreen)}
         {this.renderScreenButton('Attributes', Screens.AttributesScreen)}