Skip to content

Commit

Permalink
Introducing tryFindingViewInFrame() to avoid scroll (#1296)
Browse files Browse the repository at this point in the history
Introduce `-[KIFUITestActor tryFindingViewInFrame]` and `-[KIFUIViewTestActor usingCurrentFrame]`.

This can be used to avoid scrolling when looking for a view. This can be helpful where implicit table/collection view scrolling that happens when attempting to find UI elements triggers unwanted behavior (pagination fetch, abort pull to refresh, etc).

This includes a behavior change in `isTappable` where it no longer automatically treats an element as tappable just for having a tap gesture recognizer on it, even though it might be occluded. See this discussion for more context:
#1296 (comment)

---------

Authored-by: Simone Scionti <[email protected]>
  • Loading branch information
SimoncelloCT authored Sep 19, 2024
1 parent 8757ba8 commit 29ac3e1
Show file tree
Hide file tree
Showing 17 changed files with 306 additions and 84 deletions.
21 changes: 21 additions & 0 deletions KIF Tests/AccessibilityIdentifierTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,27 @@ - (void)testTryFindingViewWithAccessibilityIdentifier
}
}

- (void)testTryFindingOutOfFrameViewWithAccessibilityIdentifier
{
if (![tester tryFindingViewWithAccessibilityIdentifier:@"outOfFrameView"])
{
[tester fail];
}
}

- (void)testTryFindingViewInFrameWithAccessibilityIdentifier
{
if (![tester tryFindingViewInFrameWithAccessibilityIdentifier:@"idGreeting"])
{
[tester fail];
}

if ([tester tryFindingViewInFrameWithAccessibilityIdentifier:@"outOfFrameView"])
{
[tester fail];
}
}

- (void) testTappingStepperIncrement
{
UILabel *uiLabel = (UILabel *)[tester waitForViewWithAccessibilityIdentifier:@"tapViewController.stepperValue"];
Expand Down
43 changes: 43 additions & 0 deletions KIF Tests/AccessibilityIdentifierTests_ViewTestActor.m
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,49 @@ - (void)testClearingAndEnteringTextIntoViewWithAccessibilityLabel
[[viewTester usingIdentifier:@"idGreeting"] clearAndEnterText:@"Yo"];
}

- (void)testTryFindingOutOfFrameViewWithAccessibilityIdentifier
{
if (![[viewTester usingIdentifier:@"outOfFrameView"] tryFindingView])
{
[tester fail];
}
}

- (void)testTryFindingViewInFrameWithAccessibilityIdentifier
{
if (![[[viewTester usingCurrentFrame] usingIdentifier:@"idGreeting"] tryFindingView])
{
[tester fail];
}

if ([[[viewTester usingCurrentFrame] usingIdentifier:@"outOfFrameView"] tryFindingView])
{
[tester fail];
}
}

- (void)testTryFindingTappableViewInFrameWithAccessibilityIdentifier
{
if (![[[viewTester usingCurrentFrame] usingIdentifier:@"idGreeting"] tryFindingTappableView])
{
[tester fail];
}

if ([[[viewTester usingCurrentFrame] usingIdentifier:@"outOfFrameView"] tryFindingTappableView])
{
[tester fail];
}
}

- (void)testTryFindingOccludedTappableViewInFrameWithAccessibilityIdentifier
{

if ([[[viewTester usingCurrentFrame] usingIdentifier:@"occludedView"] tryFindingTappableView])
{
[tester fail];
}
}

- (void)afterEach
{
[[[viewTester usingLabel:@"Test Suite"] usingTraits:UIAccessibilityTraitButton] tap];
Expand Down
23 changes: 23 additions & 0 deletions Sources/KIF/Additions/UIAccessibilityElement-KIFAdditions.h
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,17 @@
*/
+ (BOOL)accessibilityElement:(out UIAccessibilityElement **)foundElement view:(out UIView **)foundView withElementMatchingPredicate:(NSPredicate *)predicate tappable:(BOOL)mustBeTappable error:(out NSError **)error;

/*!
@abstract Finds an accessibility element and view where the element passes the predicate, optionally passing a tappability test.
@param foundElement The found accessibility element or @c nil if the method returns @c NO. Can be @c NULL.
@param foundView The first matching view for @c foundElement as determined by the accessibility API or @c nil if the view is hidden or fails the tappability test. Can be @c NULL.
@param predicate The predicate to test the accessibility element on.
@param error A reference to an error object to be populated when no matching element or view is found. Can be @c NULL.
@param scrollDisabled Disable scroll performing the search only in the current visible frame.
@result @c YES if the element and view were found. Otherwise @c NO.
*/
+ (BOOL)accessibilityElement:(out UIAccessibilityElement **)foundElement view:(out UIView **)foundView withElementMatchingPredicate:(NSPredicate *)predicate tappable:(BOOL)mustBeTappable error:(out NSError **)error disableScroll:(BOOL)scrollDisabled;

/*!
@abstract Finds and attempts to make visible a view for a given accessibility element.
@discussion If the element is found, off screen, and is inside a scroll view, this method will attempt to programmatically scroll the view onto the screen before performing any logic as to if the view is tappable.
Expand All @@ -103,6 +114,18 @@
*/
+ (UIView *)viewContainingAccessibilityElement:(UIAccessibilityElement *)element tappable:(BOOL)mustBeTappable error:(NSError **)error;

/*!
@abstract Finds and attempts to make visible a view for a given accessibility element.
@discussion If the element is found, off screen, and is inside a scroll view, this method will attempt to programmatically scroll the view onto the screen before performing any logic as to if the view is tappable.
@param element The accessibility element.
@param mustBeTappable If @c YES, a tappability test will be performed.
@param error A reference to an error object to be populated when no element is found. Can be @c NULL.
@param scrollDisabled Disable scroll performing the search only in the current visible frame.
@return The first matching view as determined by the accessibility API or nil if the view is hidden or fails the tappability test.
*/
+ (UIView *)viewContainingAccessibilityElement:(UIAccessibilityElement *)element tappable:(BOOL)mustBeTappable error:(NSError **)error disableScroll:(BOOL)scrollDisabled;

/*!
@abstract Returns a human readable string of UIAccessiblityTrait names, derived from UIAccessibilityConstants.h.
@param traits The accessibility traits to list.
Expand Down
140 changes: 81 additions & 59 deletions Sources/KIF/Additions/UIAccessibilityElement-KIFAdditions.m
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,18 @@ + (BOOL)accessibilityElement:(out UIAccessibilityElement **)foundElement view:(o
}

+ (BOOL)accessibilityElement:(out UIAccessibilityElement **)foundElement view:(out UIView **)foundView withLabel:(NSString *)label value:(NSString *)value traits:(UIAccessibilityTraits)traits fromRootView:(UIView *)fromView tappable:(BOOL)mustBeTappable error:(out NSError **)error
{
return [self accessibilityElement:foundElement view:foundView withLabel:label value:value traits:traits fromRootView:fromView tappable:mustBeTappable error:error disableScroll:NO];
}

+ (BOOL)accessibilityElement:(out UIAccessibilityElement **)foundElement view:(out UIView **)foundView withLabel:(NSString *)label value:(NSString *)value traits:(UIAccessibilityTraits)traits fromRootView:(UIView *)fromView tappable:(BOOL)mustBeTappable error:(out NSError **)error disableScroll:(BOOL)scrollDisabled
{
UIAccessibilityElement *element = [self accessibilityElementWithLabel:label value:value traits:traits fromRootView:fromView error:error];
if (!element) {
return NO;
}

UIView *view = [self viewContainingAccessibilityElement:element tappable:mustBeTappable error:error];
UIView *view = [self viewContainingAccessibilityElement:element tappable:mustBeTappable error:error disableScroll:scrollDisabled];
if (!view) {
return NO;
}
Expand All @@ -82,19 +87,24 @@ + (BOOL)accessibilityElement:(out UIAccessibilityElement **)foundElement view:(o
}

+ (BOOL)accessibilityElement:(out UIAccessibilityElement **)foundElement view:(out UIView **)foundView withElementMatchingPredicate:(NSPredicate *)predicate tappable:(BOOL)mustBeTappable error:(out NSError **)error;
{
return [self accessibilityElement:foundElement view:foundView withElementMatchingPredicate:predicate tappable:mustBeTappable error:error disableScroll:NO];
}

+ (BOOL)accessibilityElement:(out UIAccessibilityElement **)foundElement view:(out UIView **)foundView withElementMatchingPredicate:(NSPredicate *)predicate tappable:(BOOL)mustBeTappable error:(out NSError **)error disableScroll:(BOOL)scrollDisabled;
{
UIAccessibilityElement *element = [[UIApplication sharedApplication] accessibilityElementMatchingBlock:^BOOL(UIAccessibilityElement *element) {
return [predicate evaluateWithObject:element];
}];
} disableScroll: scrollDisabled];

if (!element) {
if (error) {
*error = [self errorForFailingPredicate:predicate];
*error = [self errorForFailingPredicate:predicate disableScroll:scrollDisabled];
}
return NO;
}

UIView *view = [UIAccessibilityElement viewContainingAccessibilityElement:element tappable:mustBeTappable error:error];
UIView *view = [UIAccessibilityElement viewContainingAccessibilityElement:element tappable:mustBeTappable error:error disableScroll:scrollDisabled];
if (!view) {
return NO;
}
Expand All @@ -105,19 +115,24 @@ + (BOOL)accessibilityElement:(out UIAccessibilityElement **)foundElement view:(o
}

+ (BOOL)accessibilityElement:(out UIAccessibilityElement *__autoreleasing *)foundElement view:(out UIView *__autoreleasing *)foundView withElementMatchingPredicate:(NSPredicate *)predicate fromRootView:(UIView *)fromView tappable:(BOOL)mustBeTappable error:(out NSError *__autoreleasing *)error
{
return [self accessibilityElement:foundElement view:foundView withElementMatchingPredicate:predicate fromRootView:fromView tappable:mustBeTappable error:error disableScroll:NO];
}

+ (BOOL)accessibilityElement:(out UIAccessibilityElement *__autoreleasing *)foundElement view:(out UIView *__autoreleasing *)foundView withElementMatchingPredicate:(NSPredicate *)predicate fromRootView:(UIView *)fromView tappable:(BOOL)mustBeTappable error:(out NSError *__autoreleasing *)error disableScroll:(BOOL)scrollDisabled
{
UIAccessibilityElement *element = [fromView accessibilityElementMatchingBlock:^BOOL(UIAccessibilityElement *element) {
return [predicate evaluateWithObject:element];
}];
} disableScroll:scrollDisabled];

if (!element) {
if (error) {
*error = [NSError KIFErrorWithFormat:@"Could not find view matching: %@", predicate];
}
return NO;
}

UIView *view = [UIAccessibilityElement viewContainingAccessibilityElement:element tappable:mustBeTappable error:error];
UIView *view = [UIAccessibilityElement viewContainingAccessibilityElement:element tappable:mustBeTappable error:error disableScroll:scrollDisabled];
if (!view) {
return NO;
}
Expand Down Expand Up @@ -163,6 +178,11 @@ + (UIAccessibilityElement *)accessibilityElementWithLabel:(NSString *)label valu
}

+ (UIView *)viewContainingAccessibilityElement:(UIAccessibilityElement *)element tappable:(BOOL)mustBeTappable error:(NSError **)error;
{
return [self viewContainingAccessibilityElement:element tappable:mustBeTappable error:error disableScroll:NO];
}

+ (UIView *)viewContainingAccessibilityElement:(UIAccessibilityElement *)element tappable:(BOOL)mustBeTappable error:(NSError **)error disableScroll:(BOOL)scrollDisabled;
{
// Small safety mechanism. If someone calls this method after a failing call to accessibilityElementWithLabel:..., we don't want to wipe out the error message.
if (!element && error && *error) {
Expand All @@ -178,60 +198,62 @@ + (UIView *)viewContainingAccessibilityElement:(UIAccessibilityElement *)element
return nil;
}

// Scroll the view (and superviews) to be visible if necessary
UIView *superview = view;
while (superview) {
if ([superview isKindOfClass:[UIScrollView class]]) {
UIScrollView *scrollView = (UIScrollView *)superview;
BOOL animationEnabled = [KIFUITestActor testActorAnimationsEnabled];

if (((UIAccessibilityElement *)view == element) && ![view isKindOfClass:[UITableViewCell class]]) {
[scrollView scrollViewToVisible:view animated:animationEnabled];
} else {
if ([view isKindOfClass:[UITableViewCell class]] && [scrollView.superview isKindOfClass:[UITableView class]]) {
UITableViewCell *cell = (UITableViewCell *)view;
UITableView *tableView = (UITableView *)scrollView.superview;
NSIndexPath *indexPath = [tableView indexPathForCell:cell];
[tableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionNone animated:animationEnabled];
if(!scrollDisabled) {
// Scroll the view (and superviews) to be visible if necessary
UIView *superview = view;
while (superview) {
if ([superview isKindOfClass:[UIScrollView class]]) {
UIScrollView *scrollView = (UIScrollView *)superview;
BOOL animationEnabled = [KIFUITestActor testActorAnimationsEnabled];

if (((UIAccessibilityElement *)view == element) && ![view isKindOfClass:[UITableViewCell class]]) {
[scrollView scrollViewToVisible:view animated:animationEnabled];
} else {
CGRect elementFrame = [view.window convertRect:element.accessibilityFrame toView:scrollView];
CGRect visibleRect = CGRectMake(scrollView.contentOffset.x, scrollView.contentOffset.y, CGRectGetWidth(scrollView.bounds), CGRectGetHeight(scrollView.bounds));

UIEdgeInsets contentInset;
#ifdef __IPHONE_11_0
if (@available(iOS 11.0, *)) {
contentInset = scrollView.adjustedContentInset;
} else {
if ([view isKindOfClass:[UITableViewCell class]] && [scrollView.superview isKindOfClass:[UITableView class]]) {
UITableViewCell *cell = (UITableViewCell *)view;
UITableView *tableView = (UITableView *)scrollView.superview;
NSIndexPath *indexPath = [tableView indexPathForCell:cell];
[tableView scrollToRowAtIndexPath:indexPath atScrollPosition:UITableViewScrollPositionNone animated:animationEnabled];
} else {
CGRect elementFrame = [view.window convertRect:element.accessibilityFrame toView:scrollView];
CGRect visibleRect = CGRectMake(scrollView.contentOffset.x, scrollView.contentOffset.y, CGRectGetWidth(scrollView.bounds), CGRectGetHeight(scrollView.bounds));

UIEdgeInsets contentInset;
#ifdef __IPHONE_11_0
if (@available(iOS 11.0, *)) {
contentInset = scrollView.adjustedContentInset;
} else {
contentInset = scrollView.contentInset;
}
#else
contentInset = scrollView.contentInset;
#endif
visibleRect = UIEdgeInsetsInsetRect(visibleRect, contentInset);

// Only call scrollRectToVisible if the element isn't already visible
// iOS 8 will sometimes incorrectly scroll table views so the element scrolls out of view
if (!CGRectContainsRect(visibleRect, elementFrame)) {
[scrollView scrollRectToVisible:elementFrame animated:animationEnabled];
}
#else
contentInset = scrollView.contentInset;
#endif
visibleRect = UIEdgeInsetsInsetRect(visibleRect, contentInset);

// Only call scrollRectToVisible if the element isn't already visible
// iOS 8 will sometimes incorrectly scroll table views so the element scrolls out of view
if (!CGRectContainsRect(visibleRect, elementFrame)) {
[scrollView scrollRectToVisible:elementFrame animated:animationEnabled];
}
}

// Give the scroll view a small amount of time to perform the scroll.
CFTimeInterval delay = animationEnabled ? 0.3 : 0.05;
KIFRunLoopRunInModeRelativeToAnimationSpeed(kCFRunLoopDefaultMode, delay, false);

// Because of cell reuse the first found view could be different after we scroll.
// Find the same element's view to ensure that after we have scrolled we get the same view back.
UIView *checkedView = [UIAccessibilityElement viewContainingAccessibilityElement:element];
// intentionally doing a memory address check vs a isEqual check because
// we want to ensure that the memory address hasn't changed after scroll.
if(view != checkedView) {
view = checkedView;
// Give the scroll view a small amount of time to perform the scroll.
CFTimeInterval delay = animationEnabled ? 0.3 : 0.05;
KIFRunLoopRunInModeRelativeToAnimationSpeed(kCFRunLoopDefaultMode, delay, false);

// Because of cell reuse the first found view could be different after we scroll.
// Find the same element's view to ensure that after we have scrolled we get the same view back.
UIView *checkedView = [UIAccessibilityElement viewContainingAccessibilityElement:element];
// intentionally doing a memory address check vs a isEqual check because
// we want to ensure that the memory address hasn't changed after scroll.
if(view != checkedView) {
view = checkedView;
}
}
}

superview = superview.superview;
}

superview = superview.superview;
}

if ([[UIApplication sharedApplication] isIgnoringInteractionEvents]) {
Expand Down Expand Up @@ -259,9 +281,9 @@ + (UIView *)viewContainingAccessibilityElement:(UIAccessibilityElement *)element
return view;
}

+ (NSError *)errorForFailingPredicate:(NSPredicate*)failingPredicate;
+ (NSError *)errorForFailingPredicate:(NSPredicate*)failingPredicate disableScroll:(BOOL) scrollDisabled;
{
NSPredicate *closestMatchingPredicate = [self findClosestMatchingPredicate:failingPredicate];
NSPredicate *closestMatchingPredicate = [self findClosestMatchingPredicate:failingPredicate disableScroll:scrollDisabled];
if (closestMatchingPredicate) {
return [NSError KIFErrorWithFormat:@"Found element with %@ but not %@", \
closestMatchingPredicate.kifPredicateDescription, \
Expand All @@ -270,15 +292,15 @@ + (NSError *)errorForFailingPredicate:(NSPredicate*)failingPredicate;
return [NSError KIFErrorWithFormat:@"Could not find element with %@", failingPredicate.kifPredicateDescription];
}

+ (NSPredicate *)findClosestMatchingPredicate:(NSPredicate *)aPredicate;
+ (NSPredicate *)findClosestMatchingPredicate:(NSPredicate *)aPredicate disableScroll:(BOOL) scrollDisabled;
{
if (!aPredicate) {
return nil;
}

UIAccessibilityElement *match = [[UIApplication sharedApplication] accessibilityElementMatchingBlock:^BOOL (UIAccessibilityElement *element) {
return [aPredicate evaluateWithObject:element];
}];
} disableScroll:scrollDisabled];
if (match) {
return aPredicate;
}
Expand All @@ -296,7 +318,7 @@ + (NSPredicate *)findClosestMatchingPredicate:(NSPredicate *)aPredicate;
if (predicateMinusOneCondition) {
UIAccessibilityElement *match = [[UIApplication sharedApplication] accessibilityElementMatchingBlock:^BOOL (UIAccessibilityElement *element) {
return [predicateMinusOneCondition evaluateWithObject:element];
}];
} disableScroll:scrollDisabled];
if (match) {
return predicateMinusOneCondition;
}
Expand Down
7 changes: 7 additions & 0 deletions Sources/KIF/Additions/UIApplication-KIFAdditions.h
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ CF_EXPORT SInt32 KIFRunLoopRunInModeRelativeToAnimationSpeed(CFStringRef mode, C
@param matchBlock A block to be performed on each element to see if it passes.
*/
- (UIAccessibilityElement *)accessibilityElementMatchingBlock:(BOOL(^)(UIAccessibilityElement *))matchBlock;
/*!
@abstract Finds an accessibility element where @c matchBlock returns @c YES, across all windows in the application starting at the fronmost window.
@discussion This method should be used if @c accessibilityElementWithLabel:accessibilityValue:traits: does not meet your requirements. For example, if you are searching for an element that begins with a pattern or if of a certain view type.
@param matchBlock A block to be performed on each element to see if it passes.
@param scrollDisabled Disable scroll performing the search only in the current visible frame.
*/
- (UIAccessibilityElement *)accessibilityElementMatchingBlock:(BOOL(^)(UIAccessibilityElement *))matchBlock disableScroll:(BOOL)scrollDisabled;

/*!
@returns The window containing the keyboard or @c nil if the keyboard is not visible.
Expand Down
Loading

0 comments on commit 29ac3e1

Please sign in to comment.