From d5c1783a231aac9b7dc4d6434c089566ee4d5cd1 Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Fri, 24 Jul 2026 10:45:43 +0200 Subject: [PATCH 1/2] Reveal in-bounds elements covered by floating overlays before failing tappability viewContainingAccessibilityElement:tappable:error:disableScroll: only auto-scrolls when the element's frame lies outside its scroll view's visible rect. An element that is inside the visible rect but occluded by a floating overlay (a tab bar or toolbar hovering over the last rows of a scroll view) skips that scroll and hard-fails the tappability check with "may be blocked by other views". When the tappability check fails and scrolling is allowed, center the element vertically in its nearest scroll ancestor's inset-adjusted viewport, which moves it clear of edge-anchored overlays, then resolve once more with scrolling disabled. If the element is already as centered as the content allows, scrolling cannot uncover it, so the original error is preserved for genuinely blocked elements. --- .../UIAccessibilityElement-KIFAdditions.m | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/Sources/KIF/Additions/UIAccessibilityElement-KIFAdditions.m b/Sources/KIF/Additions/UIAccessibilityElement-KIFAdditions.m index 1cdda39c..ea27a72c 100644 --- a/Sources/KIF/Additions/UIAccessibilityElement-KIFAdditions.m +++ b/Sources/KIF/Additions/UIAccessibilityElement-KIFAdditions.m @@ -256,15 +256,63 @@ + (UIView *)viewContainingAccessibilityElement:(UIAccessibilityElement *)element } if (mustBeTappable && !view.isProbablyTappable) { + // The scroll pass above only scrolls when the element lies outside its scroll view's + // visible rect. An element inside the visible rect can still fail hit-testing when a + // floating overlay (e.g. a tab bar or toolbar) covers it. Center the element in its + // scroll ancestor's viewport, which moves it clear of edge-anchored overlays, and + // resolve once more with scrolling disabled. + if (!scrollDisabled && [self KIF_revealPossiblyCoveredAccessibilityElement:element view:view]) { + return [self viewContainingAccessibilityElement:element tappable:mustBeTappable error:error disableScroll:YES]; + } if (error) { *error = [NSError KIFErrorWithFormat:@"Accessibility element %@ for view %@ with label \"%@\" is not tappable. It may be blocked by other views.", element, view, element.accessibilityLabel]; } return nil; } - + return view; } +// Attempts to uncover an element that resolved to a view but failed the tappability check by +// centering it vertically in its nearest scroll ancestor's inset-adjusted viewport. +// Returns YES if the scroll view was scrolled, NO if there is no scroll ancestor or the +// element is already as centered as the content allows (in which case scrolling cannot +// uncover it and the caller should fail with the original error). ++ (BOOL)KIF_revealPossiblyCoveredAccessibilityElement:(UIAccessibilityElement *)element view:(UIView *)view; +{ + UIScrollView *scrollView = nil; + for (UIView *superview = view.superview; superview; superview = superview.superview) { + if ([superview isKindOfClass:[UIScrollView class]]) { + scrollView = (UIScrollView *)superview; + break; + } + } + if (!scrollView) { + return NO; + } + + CGRect elementFrame = [view.window convertRect:element.accessibilityFrame toView:scrollView]; + UIEdgeInsets contentInset = scrollView.adjustedContentInset; + CGFloat visibleHeight = CGRectGetHeight(scrollView.bounds) - contentInset.top - contentInset.bottom; + if (visibleHeight <= 0) { + return NO; + } + + CGFloat minOffsetY = -contentInset.top; + CGFloat maxOffsetY = MAX(minOffsetY, scrollView.contentSize.height + contentInset.bottom - CGRectGetHeight(scrollView.bounds)); + CGFloat targetOffsetY = CGRectGetMidY(elementFrame) - contentInset.top - visibleHeight / 2; + targetOffsetY = MIN(MAX(targetOffsetY, minOffsetY), maxOffsetY); + if (fabs(targetOffsetY - scrollView.contentOffset.y) < 1) { + return NO; + } + + BOOL animationEnabled = [KIFUITestActor testActorAnimationsEnabled]; + [scrollView setContentOffset:CGPointMake(scrollView.contentOffset.x, targetOffsetY) animated:animationEnabled]; + CFTimeInterval delay = animationEnabled ? 0.3 : 0.05; + KIFRunLoopRunInModeRelativeToAnimationSpeed(kCFRunLoopDefaultMode, delay, false); + return YES; +} + + (NSError *)errorForFailingPredicate:(NSPredicate*)failingPredicate disableScroll:(BOOL) scrollDisabled; { NSPredicate *closestMatchingPredicate = [self findClosestMatchingPredicate:failingPredicate disableScroll:scrollDisabled]; From fb3dfbb3973a1f866230f610f54b0148f26a99bf Mon Sep 17 00:00:00 2001 From: Alex Odawa Date: Fri, 24 Jul 2026 14:45:14 +0200 Subject: [PATCH 2/2] Consider the containing view itself when locating the scroll ancestor A scroll view acting as the accessibility container resolves as the element's containing view, so the reveal search must start at the view rather than its superview, matching the existing scroll pass. --- .../KIF/Additions/UIAccessibilityElement-KIFAdditions.m | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Sources/KIF/Additions/UIAccessibilityElement-KIFAdditions.m b/Sources/KIF/Additions/UIAccessibilityElement-KIFAdditions.m index ea27a72c..11e82755 100644 --- a/Sources/KIF/Additions/UIAccessibilityElement-KIFAdditions.m +++ b/Sources/KIF/Additions/UIAccessibilityElement-KIFAdditions.m @@ -280,10 +280,12 @@ + (UIView *)viewContainingAccessibilityElement:(UIAccessibilityElement *)element // uncover it and the caller should fail with the original error). + (BOOL)KIF_revealPossiblyCoveredAccessibilityElement:(UIAccessibilityElement *)element view:(UIView *)view; { + // The view itself is a candidate: a scroll view acting as the accessibility + // container resolves as the element's containing view. UIScrollView *scrollView = nil; - for (UIView *superview = view.superview; superview; superview = superview.superview) { - if ([superview isKindOfClass:[UIScrollView class]]) { - scrollView = (UIScrollView *)superview; + for (UIView *candidate = view; candidate; candidate = candidate.superview) { + if ([candidate isKindOfClass:[UIScrollView class]]) { + scrollView = (UIScrollView *)candidate; break; } }