blob: e85f6e01f412518aadfc176962de15e0b4c0d832 [file] [log] [blame]
//
// Copyright 2018 Google Inc.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
#import <UIKit/UIKit.h>
#import "GTXAccessibilityTree.h"
#import "GTXAssertions.h"
#import "GTXTreeIteratorContext.h"
/**
* There seems to be errors in accessibility children reported by some UIKit classes especially
* UITextEffectsWindow which reports 9223372036854775807 possibly due to internal type conversions
* with -1, we use this bounds value to detect that case..
*/
static const NSInteger kAccessibilityChildrenUpperBound = 50000;
/**
* The class name for @c UIPickerTableView elements. Must be accessed via @c NSClassFromString
* because it is a private class.
*/
static NSString *const kUIPickerTableViewClassName = @"UIPickerTableView";
/**
* The class name for accessibility children of @c UIPickerTableView elements. Must be accessed via
* @c NSClassFromString because it is a private class.
*/
static NSString *const kUIPickerTableViewAccessibilityElementClassName =
@"UITableViewCellAccessibilityElement";
@implementation GTXAccessibilityTree {
// Context for this object's NSEnumerator.
GTXTreeIteratorContext *_enumeratorContext;
// Root elements that this object handles.
NSArray *_rootElements;
}
- (instancetype)initWithRootElements:(NSArray *)rootElements {
self = [super init];
if (self) {
for (id element in rootElements) {
if ([element isKindOfClass:[UIViewController class]]) {
GTX_ASSERT(NO,
@"Invalid root element %@ found. Check GTXToolkit docs to learn more about "
@"valid root elements. Did you mean to use the .view property instead?",
element);
}
}
_enumeratorContext = [[GTXTreeIteratorContext alloc] initWithElements:rootElements];
_rootElements = rootElements;
}
return self;
}
- (void)iterateAllElementsWithBlock:(GTXTreeIterationBlock)block {
// Create a new tree object for iteration since the current object may be in the middle of a
// for-in loop.
GTXAccessibilityTree *tree = [[GTXAccessibilityTree alloc] initWithRootElements:_rootElements];
GTXTreeIteratorElement *iteratorElement;
while ((iteratorElement = [tree gtx_nextObject])) {
block(iteratorElement);
}
}
#pragma mark - NSEnumerator
- (id)nextObject {
id nextObject = [self gtx_nextObject].current;
if (!nextObject) {
// Allow the tree object to be re-enumerated once through.
_enumeratorContext = [[GTXTreeIteratorContext alloc] initWithElements:_rootElements];
}
return nextObject;
}
#pragma mark - NSExtendedEnumerator
- (NSArray *)allObjects {
NSMutableArray *allObjects = [[NSMutableArray alloc] init];
[self iterateAllElementsWithBlock:^(GTXTreeIteratorElement *_Nonnull iteratorElement) {
[allObjects addObject:iteratorElement.current];
}];
return allObjects;
}
#pragma mark - Private
/**
* @return The next @c GTXTreeIteratorElement for the current @c GTXTreeIteratorContext.
*/
- (GTXTreeIteratorElement *)gtx_nextObject {
if (![_enumeratorContext hasElementsInQueue]) {
return nil;
}
id nextInQueue;
GTXTreeIteratorElement *nextIterationElementInQueue;
// Get the next "unvisited" element.
do {
GTXTreeIteratorElement *nextIterationElementCandidate = [_enumeratorContext peekNextElement];
id nextCandidate = nextIterationElementCandidate.current;
[_enumeratorContext dequeueNextElement];
if (![_enumeratorContext didVisitElement:nextCandidate]) {
if (![self gtx_isAccessibilityHiddenElement:nextCandidate]) {
nextInQueue = nextCandidate;
nextIterationElementInQueue = nextIterationElementCandidate;
}
}
} while ([_enumeratorContext hasElementsInQueue] && !nextInQueue);
if (!nextInQueue) {
return nil;
}
[_enumeratorContext visitElement:nextInQueue];
if ([nextInQueue respondsToSelector:@selector(isAccessibilityElement)]) {
if (![nextInQueue isAccessibilityElement]) {
// nextInQueue could be an accessibility container, if so enqueue its children.
// There are two ways of getting the children of an accessibility container:
// First, using @selector(accessibilityElements)
NSArray *axElements = [self gtx_accessibilityElementsOfElement:nextInQueue];
// Second, using @selector(accessibilityElementAtIndex:)
NSArray *axElementsFromIndices =
[self gtx_accessibilityElementsFromIndicesOfElement:nextInQueue];
// Ensure that either the children are available only through one method or elements via both
// are the same. Otherwise we must fail as the the accessibility tree is inconsistent.
if (axElements && axElementsFromIndices) {
NSSet *accessibilityElementsSet = [NSSet setWithArray:axElements];
NSSet *accessibilityElementsFromIndicesSet = [NSSet setWithArray:axElementsFromIndices];
NSAssert([accessibilityElementsSet isEqualToSet:accessibilityElementsFromIndicesSet],
@"Accessibility elements obtained from -accessibilityElements and"
@" -accessibilityElementAtIndex: are different - they must not be. Either provide"
@" elements via one method or provide the same elements.\nDetails:\nElements via"
@" accessibilityElements:%@\nElements via accessibilityElementAtIndex:\n"
@"accessibilityElementCount:%@\nElements:%@",
accessibilityElementsSet, @([nextInQueue accessibilityElementCount]),
accessibilityElementsFromIndicesSet);
// Ensure accessibilityElements* are marked as used even if NSAssert is removed.
(void)accessibilityElementsSet;
(void)accessibilityElementsFromIndicesSet;
} else {
// Set accessibilityElements to whichever is non nil or leave it as is.
axElements = axElementsFromIndices ? axElementsFromIndices : axElements;
}
if (![nextInQueue respondsToSelector:@selector(accessibilityElementsHidden)] ||
![nextInQueue accessibilityElementsHidden]) {
for (id element in axElements) {
if (![_enumeratorContext didVisitElement:element]) {
[_enumeratorContext
queueElement:[[GTXTreeIteratorElement alloc] initWithElement:element
inContainer:nextInQueue]];
}
}
}
// nextInQueue could be a UIView subclass, if so enqueue its subviews.
NSArray *subViews;
if ([nextInQueue isKindOfClass:[UITableViewCell class]] ||
[nextInQueue isKindOfClass:[UICollectionViewCell class]]) {
subViews = [nextInQueue contentView].subviews;
} else if ([nextInQueue respondsToSelector:@selector(subviews)]) {
subViews = [nextInQueue subviews];
}
if ([nextInQueue respondsToSelector:@selector(isHidden)] && ![nextInQueue isHidden]) {
for (id child in subViews) {
if (![_enumeratorContext didVisitElement:child]) {
[_enumeratorContext
queueElement:[[GTXTreeIteratorElement alloc] initWithElement:child
inContainer:nextInQueue]];
}
}
}
}
}
return nextIterationElementInQueue;
}
/**
* @return An array of accessible children of the given @c element as reported by the selector
* -[NSObject(UIAccessibility) accessibilityElements].
*/
- (NSArray *)gtx_accessibilityElementsOfElement:(id)element {
if ([element respondsToSelector:@selector(accessibilityElements)]) {
return [element accessibilityElements];
}
return nil;
}
/**
* @return An array of accessible children of the given @c element as reported by the selector
* -[NSObject(UIAccessibility) accessibilityElementAtIndex:].
*/
- (NSArray *)gtx_accessibilityElementsFromIndicesOfElement:(id)element {
NSMutableArray *axElementsFromIndices;
if ([element respondsToSelector:@selector(accessibilityElementAtIndex:)] &&
[element respondsToSelector:@selector(accessibilityElementCount)]) {
NSInteger childrenCount = [element accessibilityElementCount];
// This is a workaround to deal with UIKit classes that are reporting incorrect
// accessibilityElementCount, see kAccessibilityChildrenUpperBound.
if (childrenCount > 0 && childrenCount < kAccessibilityChildrenUpperBound) {
axElementsFromIndices = [[NSMutableArray alloc] initWithCapacity:(NSUInteger)childrenCount];
for (NSInteger index = 0; index < childrenCount; index++) {
[axElementsFromIndices addObject:[element accessibilityElementAtIndex:index]];
}
}
}
return axElementsFromIndices;
}
/**
* Elements are hidden from accessibility trees
*
* @return @c YES if the element is hidden from accessibility tree @c NO otherwise.
*/
- (BOOL)gtx_isAccessibilityHiddenElement:(id)element {
BOOL isHidden = NO;
BOOL isAccessibilityHidden = NO;
BOOL isHiddenDueToAccessibilityFrame = NO;
BOOL isHiddenDueToFrame = NO;
if ([element respondsToSelector:@selector(isHidden)]) {
isHidden = [element isHidden];
}
if ([element respondsToSelector:@selector(accessibilityElementsHidden)]) {
isAccessibilityHidden = [element accessibilityElementsHidden];
}
if ([element respondsToSelector:@selector(accessibilityFrame)]) {
CGRect accessibilityFrame = [element accessibilityFrame];
isHiddenDueToAccessibilityFrame =
(accessibilityFrame.size.width == 0 || accessibilityFrame.size.height == 0);
}
if ([element respondsToSelector:@selector(frame)]) {
CGRect frame = [element frame];
isHiddenDueToFrame = frame.size.width == 0 || frame.size.height == 0;
}
return (isHidden || isAccessibilityHidden ||
(isHiddenDueToFrame && isHiddenDueToAccessibilityFrame) ||
[self gtx_isElementOffscreenPickerViewElement:element]);
}
/**
* Determines if the element represents an accessibility element in a @c UIPickerTableView, and the
* element is offscreen.
*
* @param element The accessibility element to check.
* @return @c YES if the element is an offscreen accessibility element whose container is a
* @c UIPickerTableView, @c NO otherwise.
*/
- (BOOL)gtx_isElementOffscreenPickerViewElement:(id)element {
if (![element respondsToSelector:@selector(accessibilityFrame)] ||
![element respondsToSelector:@selector(accessibilityContainer)]) {
return NO;
}
id accessibilityContainer = [element accessibilityContainer];
if ([accessibilityContainer isKindOfClass:NSClassFromString(kUIPickerTableViewClassName)] &&
[element isKindOfClass:NSClassFromString(kUIPickerTableViewAccessibilityElementClassName)]) {
CGRect containerAccessibilityFrame = [accessibilityContainer accessibilityFrame];
CGRect childAccessibilityFrame = [element accessibilityFrame];
if (!CGRectIntersectsRect(childAccessibilityFrame, containerAccessibilityFrame)) {
return YES;
}
}
return NO;
}
@end