diff --git a/Source/ASDisplayNode+Beta.h b/Source/ASDisplayNode+Beta.h index 4a8f4f451..cd2c78ba7 100644 --- a/Source/ASDisplayNode+Beta.h +++ b/Source/ASDisplayNode+Beta.h @@ -84,9 +84,13 @@ typedef struct { @property (readonly) ASDisplayNodePerformanceMeasurements performanceMeasurements; /** - * @abstract Whether this node acts as an accessibility container. If set to YES, then this node's accessibility label will represent - * an aggregation of all child nodes' accessibility labels. Nodes in this node's subtree that are also accessibility containers will - * not be included in this aggregation, and will be exposed as separate accessibility elements to UIKit. + * @abstract Whether this node acts as an accessibility container. If set to YES, then this node's + * accessibility label will represent an aggregation of all child nodes' accessibility labels and + * this node's accessibility custom actions will be represented will be the aggregation of all + * child nodes that have a accessibility trait of UIAccessibilityTraitLink, + * UIAccessibilityTraitKeyboardKey or UIAccessibilityTraitButton. + * Nodes in this node's subtree that are also accessibility containers will not be included in this + * aggregation, and will be exposed as separate accessibility elements to UIKit. */ @property BOOL isAccessibilityContainer; diff --git a/Source/ASTextNode2.mm b/Source/ASTextNode2.mm index f0251ddc3..9957f9bbe 100644 --- a/Source/ASTextNode2.mm +++ b/Source/ASTextNode2.mm @@ -329,27 +329,39 @@ - (UIAccessibilityTraits)defaultAccessibilityTraits return UIAccessibilityTraitStaticText; } -static void ASUpdateAccessibilityFrame(ASTextNodeAccessiblityElement *accessibilityElement, ASTextLayout *layout, UIView *view) { +static void ASUpdateAccessibilityFrame(ASTextNodeAccessiblityElement *accessibilityElement, ASTextLayout *layout, ASDisplayNode *node) { + ASDisplayNode *containerNode = ASFirstNonLayerBackedSupernodeForNode(node); if (accessibilityElement.accessibilityRange.location == NSNotFound) { // If no accessibilityRange was specified (as is done for the text element), just use the label's frame. - CGRect range = [layout rectForRange:[ASTextRange rangeWithRange:NSMakeRange(0, accessibilityElement.accessibilityLabel.length)]]; + CGRect frame = [layout rectForRange:[ASTextRange rangeWithRange:NSMakeRange(0, accessibilityElement.accessibilityLabel.length)]]; // Handle text is too large for the layout's text container size - if (range.origin.x == INFINITY) { - range = [layout rectForRange:[ASTextRange rangeWithRange:layout.visibleRange]]; + if (frame.origin.x == INFINITY) { + frame = [layout rectForRange:[ASTextRange rangeWithRange:layout.visibleRange]]; } - accessibilityElement.accessibilityFrame = UIAccessibilityConvertFrameToScreenCoordinates(range, view); + CGRect accessibilityFrame = [containerNode convertRect:frame fromNode:node]; + accessibilityElement.accessibilityFrame = UIAccessibilityConvertFrameToScreenCoordinates(accessibilityFrame, containerNode.view); } else { CGRect frame = [layout rectForRange:[ASTextRange rangeWithRange:accessibilityElement.accessibilityRange]]; - accessibilityElement.accessibilityFrame = UIAccessibilityConvertFrameToScreenCoordinates(frame, view); + CGRect accessibilityFrame = [containerNode convertRect:frame fromNode:node]; + accessibilityElement.accessibilityFrame = UIAccessibilityConvertFrameToScreenCoordinates(accessibilityFrame, containerNode.view); } } +static ASDisplayNode *ASFirstNonLayerBackedSupernodeForNode(ASDisplayNode *node) { + ASDisplayNode *containerNode = node; + while (containerNode.isLayerBacked) { + containerNode = containerNode.supernode; + } + return containerNode; +} + // Overwrite accessibilityElementAtIndex: so we can update the element's accessibilityFrame when it is requested. - (id)accessibilityElementAtIndex:(NSInteger)index { ASTextNodeAccessiblityElement *accessibilityElement = self.accessibilityElements[index]; + ASTextLayout *layout = ASTextNodeCompatibleLayoutWithContainerAndText(_textContainer, _attributedText); - ASUpdateAccessibilityFrame(accessibilityElement, layout, self.view); + ASUpdateAccessibilityFrame(accessibilityElement, layout, self); return accessibilityElement; } @@ -369,16 +381,17 @@ - (NSArray *)accessibilityElements return _accessibilityElements; } - UIView *view = self.view; + // Searc the first node that is not layer backed + ASDisplayNode *containerNode = ASFirstNonLayerBackedSupernodeForNode(self); UIAccessibilityTraits accessibilityTraits = self.accessibilityTraits; ASTextLayout *layout = ASTextNodeCompatibleLayoutWithContainerAndText(_textContainer, attributedText); // Create an accessibility element to represent the label's text. It's not necessary to specify // a accessibilityRange here, as the entirety of the text is being represented. - ASTextNodeAccessiblityElement *accessibilityElement = [[ASTextNodeAccessiblityElement alloc] initWithAccessibilityContainer:self]; + ASTextNodeAccessiblityElement *accessibilityElement = [[ASTextNodeAccessiblityElement alloc] initWithAccessibilityContainer:containerNode.view]; accessibilityElement.accessibilityTraits = accessibilityTraits; accessibilityElement.accessibilityLabel = self.accessibilityLabel; - ASUpdateAccessibilityFrame(accessibilityElement, layout, view); + ASUpdateAccessibilityFrame(accessibilityElement, layout, self); [accessibilityElements addObject:accessibilityElement]; if (ASActivateExperimentalFeature(ASExperimentalExposeTextLinksForA11Y)) { @@ -393,7 +406,7 @@ - (NSArray *)accessibilityElements accessibilityElement.accessibilityTraits = accessibilityTraits; accessibilityElement.accessibilityLabel = [attributedText attributedSubstringFromRange:range].string; accessibilityElement.accessibilityRange = range; - ASUpdateAccessibilityFrame(accessibilityElement, layout, view); + ASUpdateAccessibilityFrame(accessibilityElement, layout, self); [accessibilityElements addObject:accessibilityElement]; }]; } diff --git a/Source/Details/_ASDisplayViewAccessiblity.mm b/Source/Details/_ASDisplayViewAccessiblity.mm index 1cd906649..df264030a 100644 --- a/Source/Details/_ASDisplayViewAccessiblity.mm +++ b/Source/Details/_ASDisplayViewAccessiblity.mm @@ -58,18 +58,18 @@ static void SortAccessibilityElements(NSMutableArray *elements) @interface ASAccessibilityElement : UIAccessibilityElement -@property (nonatomic) ASDisplayNode *node; @property (nonatomic) ASDisplayNode *containerNode; +@property (nonatomic) ASDisplayNode *node; -+ (ASAccessibilityElement *)accessibilityElementWithContainer:(UIView *)container node:(ASDisplayNode *)node containerNode:(ASDisplayNode *)containerNode; ++ (ASAccessibilityElement *)accessibilityElementWithContainerNode:(ASDisplayNode *)containerNode node:(ASDisplayNode *)node; @end @implementation ASAccessibilityElement -+ (ASAccessibilityElement *)accessibilityElementWithContainer:(UIView *)container node:(ASDisplayNode *)node containerNode:(ASDisplayNode *)containerNode ++ (ASAccessibilityElement *)accessibilityElementWithContainerNode:(ASDisplayNode *)containerNode node:(ASDisplayNode *)node { - ASAccessibilityElement *accessibilityElement = [[ASAccessibilityElement alloc] initWithAccessibilityContainer:container]; + ASAccessibilityElement *accessibilityElement = [[ASAccessibilityElement alloc] initWithAccessibilityContainer:containerNode.view]; accessibilityElement.node = node; accessibilityElement.containerNode = containerNode; accessibilityElement.accessibilityIdentifier = node.accessibilityIdentifier; @@ -97,9 +97,8 @@ - (CGRect)accessibilityFrame @interface ASAccessibilityCustomAction : UIAccessibilityCustomAction -@property (nonatomic) UIView *container; -@property (nonatomic) ASDisplayNode *node; @property (nonatomic) ASDisplayNode *containerNode; +@property (nonatomic) ASDisplayNode *node; @end @@ -107,34 +106,44 @@ @implementation ASAccessibilityCustomAction - (CGRect)accessibilityFrame { - CGRect accessibilityFrame = [self.containerNode convertRect:self.node.bounds fromNode:self.node]; - return UIAccessibilityConvertFrameToScreenCoordinates(accessibilityFrame, self.container); + ASDisplayNode *containerNode = self.containerNode; + ASDisplayNode *node = self.node; + ASDisplayNodeCAssertNotNil(containerNode, @"ASAccessibilityCustomAction needs a container node."); + ASDisplayNodeCAssertNotNil(node, @"ASAccessibilityCustomAction needs a node."); + CGRect accessibilityFrame = [containerNode convertRect:node.bounds fromNode:node]; + return UIAccessibilityConvertFrameToScreenCoordinates(accessibilityFrame, containerNode.view); } @end /// Collect all subnodes for the given node by walking down the subnode tree and calculates the screen coordinates based on the containerNode and container. This is necessary for layer backed nodes or rasterrized subtrees as no UIView instance for this node exists. -static void CollectUIAccessibilityElementsForNode(ASDisplayNode *node, ASDisplayNode *containerNode, UIView *container, NSMutableArray *elements) +static void CollectAccessibilityElementsForLayerBackedOrRasterizedNode(ASDisplayNode *containerNode, ASDisplayNode *node, NSMutableArray *elements) { ASDisplayNodeCAssertNotNil(elements, @"Should pass in a NSMutableArray"); - + + // Iterate any node in the tree and either collect nodes that are accessibility elements + // or leaf nodes that are accessibility containers ASDisplayNodePerformBlockOnEveryNodeBFS(node, ^(ASDisplayNode * _Nonnull currentNode) { - // For every subnode that is layer backed or it's supernode has subtree rasterization enabled - // we have to create a UIAccessibilityElement as no view for this node exists if (currentNode != containerNode) { if (currentNode.isAccessibilityElement) { - UIAccessibilityElement *accessibilityElement = [ASAccessibilityElement accessibilityElementWithContainer:container node:currentNode containerNode:containerNode]; + // For every subnode that is layer backed or it's supernode has subtree rasterization enabled + // we have to create a UIAccessibilityElement as no view for this node exists + UIAccessibilityElement *accessibilityElement = [ASAccessibilityElement accessibilityElementWithContainerNode:containerNode node:currentNode]; [elements addObject:accessibilityElement]; - } else { + } else if (currentNode.subnodes.count == 0) { + // In leaf nodes that are acting as accessibility container we call + // through to the accessibilityElements method. + // It's important that leaf layer backed nodes that are UIAccessibilityContainer + // implement accessibilityElements with not having to have a view loaded [elements addObjectsFromArray:currentNode.accessibilityElements]; } } }); } -/// Called from the usual acccessibility elements collection function for a container to collect all subnodes accessibilityLabels -static void CollectAccessibilityElementsForContainer(ASDisplayNode *container, UIView *view, NSMutableArray *elements) { - UIAccessibilityElement *accessiblityElement = [ASAccessibilityElement accessibilityElementWithContainer:view node:container containerNode:container]; +/// Called from the usual accessibility elements collection function for a container to collect all subnodes accessibilityLabels +static void AggregateSublabelsOrCustomActionsForContainerNode(ASDisplayNode *container, NSMutableArray *elements) { + UIAccessibilityElement *accessiblityElement = [ASAccessibilityElement accessibilityElementWithContainerNode:container node:container]; NSMutableArray *labeledNodes = [[NSMutableArray alloc] init]; NSMutableArray *actions = [[NSMutableArray alloc] init]; @@ -153,21 +162,24 @@ static void CollectAccessibilityElementsForContainer(ASDisplayNode *container, U node = queue.front(); queue.pop(); + // Only handle accessibility containers if (node != container && node.isAccessibilityContainer) { - CollectAccessibilityElementsForContainer(node, view, elements); + AggregateSublabelsOrCustomActionsForContainerNode(node, elements); continue; } + // Aggregate either custom actions for specific accessibility traits or the accessibility labels + // of the node if (node.accessibilityLabel.length > 0) { if (node.accessibilityTraits & InteractiveAccessibilityTraitsMask()) { ASAccessibilityCustomAction *action = [[ASAccessibilityCustomAction alloc] initWithName:node.accessibilityLabel target:node selector:@selector(performAccessibilityCustomAction:)]; - action.node = node; action.containerNode = node.supernode; - action.container = node.supernode.view; + action.node = node; [actions addObject:action]; } else if (node == container || shouldAggregateSubnodeLabels) { - // Even though not surfaced to UIKit, create a non-interactive element for purposes of building sorted aggregated label. - ASAccessibilityElement *nonInteractiveElement = [ASAccessibilityElement accessibilityElementWithContainer:view node:node containerNode:container]; + // Even though not surfaced to UIKit, create a non-interactive element for purposes + // of building sorted aggregated label. + ASAccessibilityElement *nonInteractiveElement = [ASAccessibilityElement accessibilityElementWithContainerNode:container node:node]; [labeledNodes addObject:nonInteractiveElement]; } } @@ -200,12 +212,10 @@ static void CollectAccessibilityElementsForContainer(ASDisplayNode *container, U [elements addObject:accessiblityElement]; } -/// Collect all accessibliity elements for a given view and view node -static void CollectAccessibilityElementsForView(UIView *view, NSMutableArray *elements) +/// Collect all accessibliity elements for a given node +static void CollectAccessibilityElements(ASDisplayNode *node, NSMutableArray *elements) { ASDisplayNodeCAssertNotNil(elements, @"Should pass in a NSMutableArray"); - - ASDisplayNode *node = view.asyncdisplaykit_node; BOOL anySubNodeIsCollection = (nil != ASDisplayNodeFindFirstNode(node, ^BOOL(ASDisplayNode *nodeToCheck) { @@ -213,14 +223,15 @@ static void CollectAccessibilityElementsForView(UIView *view, NSMutableArray *el ASDynamicCast(nodeToCheck, ASTableNode) != nil; })); + // Handle an accessibility container (collects accessibility labels or custom actions) if (node.isAccessibilityContainer && !anySubNodeIsCollection) { - CollectAccessibilityElementsForContainer(node, view, elements); + AggregateSublabelsOrCustomActionsForContainerNode(node, elements); return; } - // Handle rasterize case + // Handle a rasterize node if (node.rasterizesSubtree) { - CollectUIAccessibilityElementsForNode(node, node, view, elements); + CollectAccessibilityElementsForLayerBackedOrRasterizedNode(node, node, elements); return; } @@ -230,16 +241,17 @@ static void CollectAccessibilityElementsForView(UIView *view, NSMutableArray *el // An accessiblityElement can either be a UIView or a UIAccessibilityElement if (subnode.isLayerBacked) { // No view for layer backed nodes exist. It's necessary to create a UIAccessibilityElement that represents this node - UIAccessibilityElement *accessiblityElement = [ASAccessibilityElement accessibilityElementWithContainer:view node:subnode containerNode:node]; + UIAccessibilityElement *accessiblityElement = [ASAccessibilityElement accessibilityElementWithContainerNode:node node:subnode]; [elements addObject:accessiblityElement]; } else { // Accessiblity element is not layer backed just add the view as accessibility element [elements addObject:subnode.view]; } } else if (subnode.isLayerBacked) { - // Go down the hierarchy for layer backed subnodes and collect all of the UIAccessibilityElement - CollectUIAccessibilityElementsForNode(subnode, node, view, elements); - } else if ([subnode accessibilityElementCount] > 0) { + // Go down the hierarchy for layer backed subnodes which are also accessibility containe + // and collect all of the UIAccessibilityElement + CollectAccessibilityElementsForLayerBackedOrRasterizedNode(node, subnode, elements); + } else if (subnode.accessibilityElementCount > 0) { // _ASDisplayView is itself a UIAccessibilityContainer just add it, UIKit will call the accessiblity // methods of the nodes _ASDisplayView [elements addObject:subnode.view]; @@ -350,7 +362,7 @@ - (NSArray *)accessibilityElements if (_accessibilityElements == nil) { NSMutableArray *accessibilityElements = [[NSMutableArray alloc] init]; - CollectAccessibilityElementsForView(self.view, accessibilityElements); + CollectAccessibilityElements(self, accessibilityElements); SortAccessibilityElements(accessibilityElements); _accessibilityElements = accessibilityElements; } diff --git a/Tests/ASTextNode2Tests.mm b/Tests/ASTextNode2Tests.mm index bb8d55846..bb60e05eb 100644 --- a/Tests/ASTextNode2Tests.mm +++ b/Tests/ASTextNode2Tests.mm @@ -77,7 +77,7 @@ - (void)testTruncation XCTAssertTrue(_textNode.isTruncated, @"Text Node should be truncated"); } -- (void)testAccessibility +- (void)testBasicAccessibility { XCTAssertFalse(_textNode.isAccessibilityElement, @"Is not an accessiblity element as it's a UIAccessibilityContainer"); XCTAssertTrue(_textNode.accessibilityTraits == UIAccessibilityTraitStaticText, @@ -104,7 +104,62 @@ - (void)testAccessibility [_textNode.accessibilityElements[0] accessibilityLabel], _textNode.accessibilityLabel); } -- (void)testExposeA11YLinks +- (void)testAccessibilityLayerBackedContainerAndTextNode2 +{ + ASDisplayNode *container = [[ASDisplayNode alloc] init]; + container.frame = CGRectMake(50, 50, 200, 600); + container.backgroundColor = [UIColor grayColor]; + + ASDisplayNode *layerBackedContainer = [[ASDisplayNode alloc] init]; + layerBackedContainer.layerBacked = YES; + layerBackedContainer.frame = CGRectMake(50, 50, 200, 600); + layerBackedContainer.backgroundColor = [UIColor grayColor]; + [container addSubnode:layerBackedContainer]; + + ASTextNode2 *text = [[ASTextNode2 alloc] init]; + text.layerBacked = YES; + text.attributedText = [[NSAttributedString alloc] initWithString:@"hello"]; + text.frame = CGRectMake(50, 100, 200, 200); + [layerBackedContainer addSubnode:text]; + + ASTextNode2 *text2 = [[ASTextNode2 alloc] init]; + text2.layerBacked = YES; + text2.attributedText = [[NSAttributedString alloc] initWithString:@"world"]; + text2.frame = CGRectMake(50, 100, 200, 200); + [layerBackedContainer addSubnode:text2]; + + NSArray *elements = container.view.accessibilityElements; + XCTAssertTrue(elements.count == 2); + XCTAssertTrue([[elements[0] accessibilityLabel] isEqualToString:@"hello"]); + XCTAssertTrue([[elements[1] accessibilityLabel] isEqualToString:@"world"]); +} + +- (void)testAccessibilityLayerBackedTextNode2 +{ + ASDisplayNode *container = [[ASDisplayNode alloc] init]; + container.frame = CGRectMake(50, 50, 200, 600); + container.backgroundColor = [UIColor grayColor]; + + ASTextNode2 *text = [[ASTextNode2 alloc] init]; + text.layerBacked = YES; + text.attributedText = [[NSAttributedString alloc] initWithString:@"hello"]; + text.frame = CGRectMake(50, 100, 200, 200); + [container addSubnode:text]; + + // Trigger calculation of layouts on both nodes manually otherwise the internal + // text container will not have any size +// (void)[text layoutThatFits:ASSizeRangeMake(CGSizeZero, container.frame.size)]; +// (void)[container layoutThatFits:ASSizeRangeMake(CGSizeZero, container.frame.size)]; +// [container layoutIfNeeded]; +// [container.layer displayIfNeeded]; + + NSArray *elements = container.view.accessibilityElements; + XCTAssertTrue(elements.count == 1); + XCTAssertTrue([[elements.firstObject accessibilityLabel] isEqualToString:@"hello"]); + // TODO: Also check for accessibilityFrame +} + +- (void)testAccessibilityExposeA11YLinks { NSString *link = @"https://texturegroup.com"; NSMutableAttributedString *attributedText = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"Texture Website: %@", link]];