diff --git a/Source/Details/_ASDisplayViewAccessiblity.mm b/Source/Details/_ASDisplayViewAccessiblity.mm index 367f5368..7324c5c2 100644 --- a/Source/Details/_ASDisplayViewAccessiblity.mm +++ b/Source/Details/_ASDisplayViewAccessiblity.mm @@ -254,7 +254,21 @@ static void CollectAccessibilityElements(ASDisplayNode *node, NSMutableArray *el return; } - for (ASDisplayNode *subnode in node.subnodes) { + // see if one of the subnodes is modal. If it is, then we only need to collect accessibilityElements from that + // node. If more than one subnode is modal, UIKit uses the last view in subviews as the modal view (it appears to + // be based on the index in the subviews array, not the location on screen). Let's do the same. + ASDisplayNode *modalSubnode = nil; + for (ASDisplayNode *subnode in node.subnodes.reverseObjectEnumerator) { + if (subnode.accessibilityViewIsModal) { + modalSubnode = subnode; + break; + } + } + + // If we have a modal subnode, just use that. Otherwise, use all subnodes + NSArray *subnodes = modalSubnode ? @[ modalSubnode ] : node.subnodes; + + for (ASDisplayNode *subnode in subnodes) { // If a node is hidden or has an alpha of 0.0 we should not include it if (subnode.hidden || subnode.alpha == 0.0) { continue; diff --git a/Source/Private/ASDisplayNode+UIViewBridge.mm b/Source/Private/ASDisplayNode+UIViewBridge.mm index 32f95b98..94d8ace0 100644 --- a/Source/Private/ASDisplayNode+UIViewBridge.mm +++ b/Source/Private/ASDisplayNode+UIViewBridge.mm @@ -1137,6 +1137,22 @@ nodeProperty = nodeValueExpr; _setToViewOnly(viewAndPendingViewStateProperty, vi @implementation ASDisplayNode (UIViewBridgeAccessibility) +// Walks up the view tree to nil out all the cached accsesibilityElements. This is required when changing +// accessibility properties like accessibilityViewIsModal. +- (void)invalidateAccessibilityElements +{ + // If we are not caching accessibilityElements we don't need to do anything here. + if (ASActivateExperimentalFeature(ASExperimentalDoNotCacheAccessibilityElements)) { + return; + } + + // we want to check if we are on the main thread first, since _loaded checks the layer and can only be done on main + if (ASDisplayNodeThreadIsMain() && _loaded(self)) { + self.view.accessibilityElements = nil; + [self.supernode invalidateAccessibilityElements]; + } +} + - (BOOL)isAccessibilityElement { _bridge_prologue_read; @@ -1306,9 +1322,16 @@ nodeProperty = nodeValueExpr; _setToViewOnly(viewAndPendingViewStateProperty, vi - (void)setAccessibilityViewIsModal:(BOOL)accessibilityViewIsModal { _bridge_prologue_write; + BOOL oldAccessibilityViewIsModal = _getFromViewOnly(accessibilityViewIsModal); _setAccessibilityToViewAndProperty(_flags.accessibilityViewIsModal, accessibilityViewIsModal, accessibilityViewIsModal, accessibilityViewIsModal); + + // if we made a change, we need to clear the view's accessibilityElements cache. + if (!ASActivateExperimentalFeature(ASExperimentalDoNotCacheAccessibilityElements) && self.isNodeLoaded && oldAccessibilityViewIsModal != accessibilityViewIsModal) { + [self invalidateAccessibilityElements]; + } } + - (BOOL)shouldGroupAccessibilityChildren { _bridge_prologue_read; diff --git a/Tests/ASDisplayViewAccessibilityTests.mm b/Tests/ASDisplayViewAccessibilityTests.mm index 4dc68e37..02d99d79 100644 --- a/Tests/ASDisplayViewAccessibilityTests.mm +++ b/Tests/ASDisplayViewAccessibilityTests.mm @@ -247,7 +247,7 @@ extern void SortAccessibilityElements(NSMutableArray *elements); // force load __unused UIView *view = containerNode.view; - NSArray *elements = [containerNode accessibilityElements]; + NSArray *elements = [containerNode.view accessibilityElements]; XCTAssertTrue(elements.count == 2); XCTAssertEqual([elements.firstObject asyncdisplaykit_node], label); XCTAssertEqual([elements.lastObject asyncdisplaykit_node], button); @@ -273,7 +273,7 @@ extern void SortAccessibilityElements(NSMutableArray *elements); // force load __unused UIView *view = containerNode.view; - NSArray *elements = [containerNode accessibilityElements]; + NSArray *elements = [containerNode.view accessibilityElements]; XCTAssertTrue(elements.count == 1); XCTAssertEqual(elements.firstObject, label); } @@ -301,7 +301,7 @@ extern void SortAccessibilityElements(NSMutableArray *elements); // force load __unused UIView *view = containerNode.view; - NSArray *elements = [containerNode accessibilityElements]; + NSArray *elements = [containerNode.view accessibilityElements]; XCTAssertTrue(elements.count == 1); XCTAssertEqual(elements.firstObject, label.view); } @@ -328,7 +328,7 @@ extern void SortAccessibilityElements(NSMutableArray *elements); // force load __unused UIView *view = containerNode.view; - NSArray *elements = [containerNode accessibilityElements]; + NSArray *elements = [containerNode.view accessibilityElements]; XCTAssertTrue(elements.count == 1); XCTAssertEqual(elements.firstObject, label.view); } @@ -376,7 +376,7 @@ extern void SortAccessibilityElements(NSMutableArray *elements); [node addSubnode:offScreenNodeX]; [node addSubnode:offScreenNode]; - NSArray *elements = [node accessibilityElements]; + NSArray *elements = [node.view accessibilityElements]; XCTAssertTrue(elements.count == 3); XCTAssertTrue([elements containsObject:label.view]); XCTAssertTrue([elements containsObject:partiallyOnScreenNodeX.view]); @@ -427,7 +427,7 @@ extern void SortAccessibilityElements(NSMutableArray *elements); [node addSubnode:offScreenNodeX]; [node addSubnode:offScreenNode]; - NSArray *elements = [node accessibilityElements]; + NSArray *elements = [node.view accessibilityElements]; XCTAssertTrue(elements.count == 6); XCTAssertTrue([elements containsObject:label.view]); XCTAssertTrue([elements containsObject:partiallyOnScreenNodeX.view]); @@ -488,5 +488,108 @@ extern void SortAccessibilityElements(NSMutableArray *elements); XCTAssertEqual(elements[3], node4); } +- (void)testSubnodeIsModal { + + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 568)]; + ASDisplayNode *node = [[ASDisplayNode alloc] init]; + node.automaticallyManagesSubnodes = YES; + + ASViewController *vc = [[ASViewController alloc] initWithNode:node]; + window.rootViewController = vc; + [window makeKeyAndVisible]; + [window layoutIfNeeded]; + + ASTextNode *label1 = [[ASTextNode alloc] init]; + label1.attributedText = [[NSAttributedString alloc] initWithString:@"label1"]; + label1.frame = CGRectMake(10, 80, 300, 20); + [node addSubnode:label1]; + + ASTextNode *label2 = [[ASTextNode alloc] init]; + label2.attributedText = [[NSAttributedString alloc] initWithString:@"label2"]; + label2.frame = CGRectMake(10, CGRectGetMaxY(label1.frame) + 8, 300, 20); + [node addSubnode:label2]; + + ASDisplayNode *modalNode = [[ASDisplayNode alloc] init]; + modalNode.frame = CGRectInset(CGRectUnion(label1.frame, label2.frame), -8, -8); + + // This is kind of cheating. When voice over is activated, the modal node will end up reporting that it + // has 1 accessibilityElement. But getting that to happen in a unit test doesn't seem possible. + id modalMock = OCMPartialMock(modalNode); + OCMStub([modalMock accessibilityElementCount]).andReturn(1); + [node addSubnode:modalMock]; + + ASTextNode *label3 = [[ASTextNode alloc] init]; + label3.attributedText = [[NSAttributedString alloc] initWithString:@"label6"]; + label3.frame = CGRectMake(8, 4, 200, 20); + + [modalNode addSubnode:label3]; + modalNode.accessibilityViewIsModal = YES; + NSArray *elements = [node.view accessibilityElements]; + XCTAssertTrue(elements.count == 1); + XCTAssertTrue([elements containsObject:modalNode.view]); +} + +- (void)testMultipleSubnodesAreModal { + + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 568)]; + ASDisplayNode *node = [[ASDisplayNode alloc] init]; + node.automaticallyManagesSubnodes = YES; + + ASViewController *vc = [[ASViewController alloc] initWithNode:node]; + window.rootViewController = vc; + [window makeKeyAndVisible]; + [window layoutIfNeeded]; + + ASTextNode *label1 = [[ASTextNode alloc] init]; + label1.attributedText = [[NSAttributedString alloc] initWithString:@"label1"]; + label1.frame = CGRectMake(10, 80, 300, 20); + [node addSubnode:label1]; + + ASTextNode *label2 = [[ASTextNode alloc] init]; + label2.attributedText = [[NSAttributedString alloc] initWithString:@"label2"]; + label2.frame = CGRectMake(10, CGRectGetMaxY(label1.frame) + 8, 300, 20); + [node addSubnode:label2]; + + ASDisplayNode *modalNode1 = [[ASDisplayNode alloc] init]; + modalNode1.frame = CGRectInset(CGRectUnion(label1.frame, label2.frame), -8, -8); + + // This is kind of cheating. When voice over is activated, the modal node will end up reporting that it + // has 1 accessibilityElement. But getting that to happen in a unit test doesn't seem possible. + id modalMock1 = OCMPartialMock(modalNode1); + OCMStub([modalMock1 accessibilityElementCount]).andReturn(1); + + ASTextNode *label3 = [[ASTextNode alloc] init]; + label3.attributedText = [[NSAttributedString alloc] initWithString:@"label6"]; + label3.frame = CGRectMake(8, 4, 200, 20); + [modalNode1 addSubnode:label3]; + modalNode1.accessibilityViewIsModal = YES; + + ASDisplayNode *modalNode2 = [[ASDisplayNode alloc] init]; + modalNode2.frame = CGRectOffset(modalNode1.frame, 0, modalNode1.frame.size.height + 10); + id modalMock2 = OCMPartialMock(modalNode2); + OCMStub([modalMock2 accessibilityElementCount]).andReturn(1); + + ASTextNode *label4 = [[ASTextNode alloc] init]; + label4.attributedText = [[NSAttributedString alloc] initWithString:@"label6"]; + label4.frame = CGRectMake(8, 4, 200, 20); + [modalNode2 addSubnode:label4]; + modalNode2.accessibilityViewIsModal = YES; + + // add modalNode1 last, and assert that it is the one that appears in accessibilityElements + // (UIKit uses the last modal subview in subviews as the modal element). + [node addSubnode:modalMock2]; + [node addSubnode:modalMock1]; + + NSArray *elements = [node.view accessibilityElements]; + XCTAssertTrue(elements.count == 1); + XCTAssertTrue([elements containsObject:modalNode1.view]); + + // let's change which node is modal and make sure the elements get updated. + modalNode1.accessibilityViewIsModal = NO; + elements = [node.view accessibilityElements]; + XCTAssertTrue(elements.count == 1); + XCTAssertTrue([elements containsObject:modalNode2.view]); +} + @end