diff --git a/Podfile.lock b/Podfile.lock index df3c6fcb8..ce77f9d72 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -2,35 +2,16 @@ PODS: - FBSnapshotTestCase/Core (2.1.4) - JGMethodSwizzler (2.0.1) - OCMock (3.4.1) - - PINCache (3.0.1-beta.7): - - PINCache/Arc-exception-safe (= 3.0.1-beta.7) - - PINCache/Core (= 3.0.1-beta.7) - - PINCache/Arc-exception-safe (3.0.1-beta.7): - - PINCache/Core - - PINCache/Core (3.0.1-beta.7): - - PINOperation (~> 1.1.1) - - PINOperation (1.1.1) - - PINRemoteImage (3.0.0-beta.14): - - PINRemoteImage/PINCache (= 3.0.0-beta.14) - - PINRemoteImage/Core (3.0.0-beta.14): - - PINOperation - - PINRemoteImage/PINCache (3.0.0-beta.14): - - PINCache (= 3.0.1-beta.7) - - PINRemoteImage/Core DEPENDENCIES: - FBSnapshotTestCase/Core (~> 2.1) - JGMethodSwizzler (from `https://github.com/JonasGessner/JGMethodSwizzler`, branch `master`) - OCMock (= 3.4.1) - - PINRemoteImage (= 3.0.0-beta.14) SPEC REPOS: - https://github.com/cocoapods/specs.git: + https://github.com/CocoaPods/Specs.git: - FBSnapshotTestCase - OCMock - - PINCache - - PINOperation - - PINRemoteImage EXTERNAL SOURCES: JGMethodSwizzler: @@ -46,10 +27,7 @@ SPEC CHECKSUMS: FBSnapshotTestCase: 094f9f314decbabe373b87cc339bea235a63e07a JGMethodSwizzler: 7328146117fffa8a4038c42eb7cd3d4c75006f97 OCMock: 2cd0716969bab32a2283ff3a46fd26a8c8b4c5e3 - PINCache: 7cb9ae068c8f655717f7c644ef1dff9fd573e979 - PINOperation: a6219e6fc9db9c269eb7a7b871ac193bcf400aac - PINRemoteImage: 81bbff853acc71c6de9e106e9e489a791b8bbb08 -PODFILE CHECKSUM: 445046ac151568c694ff286684322273f0b597d6 +PODFILE CHECKSUM: 345a6700f5fdec438ef5553e1eebf62653862733 -COCOAPODS: 1.6.0 +COCOAPODS: 1.9.1 diff --git a/Schemas/configuration.json b/Schemas/configuration.json index e18d4380b..d2d041851 100644 --- a/Schemas/configuration.json +++ b/Schemas/configuration.json @@ -24,6 +24,7 @@ "exp_did_enter_preload_skip_asm_layout", "exp_dispatch_apply", "exp_oom_bg_dealloc_disable", + "exp_do_not_cache_accessibility_elements", ] } } diff --git a/Source/ASExperimentalFeatures.h b/Source/ASExperimentalFeatures.h index bec8614bb..82995b214 100644 --- a/Source/ASExperimentalFeatures.h +++ b/Source/ASExperimentalFeatures.h @@ -31,6 +31,7 @@ typedef NS_OPTIONS(NSUInteger, ASExperimentalFeatures) { ASExperimentalDrawingGlobal = 1 << 10, // exp_drawing_global ASExperimentalOptimizeDataControllerPipeline = 1 << 11, // exp_optimize_data_controller_pipeline ASExperimentalTraitCollectionDidChangeWithPreviousCollection = 1 << 12, // exp_trait_collection_did_change_with_previous_collection + ASExperimentalDoNotCacheAccessibilityElements = 1 << 13, // exp_do_not_cache_accessibility_elements ASExperimentalFeatureAll = 0xFFFFFFFF }; diff --git a/Source/ASExperimentalFeatures.mm b/Source/ASExperimentalFeatures.mm index eb9848292..f936525bc 100644 --- a/Source/ASExperimentalFeatures.mm +++ b/Source/ASExperimentalFeatures.mm @@ -24,7 +24,8 @@ @"exp_oom_bg_dealloc_disable", @"exp_drawing_global", @"exp_optimize_data_controller_pipeline", - @"exp_trait_collection_did_change_with_previous_collection"])); + @"exp_trait_collection_did_change_with_previous_collection", + @"exp_do_not_cache_accessibility_elements"])); if (flags == ASExperimentalFeatureAll) { return allNames; } diff --git a/Source/Details/_ASDisplayViewAccessiblity.h b/Source/Details/_ASDisplayViewAccessiblity.h index 192b24d2c..7f159d1d0 100644 --- a/Source/Details/_ASDisplayViewAccessiblity.h +++ b/Source/Details/_ASDisplayViewAccessiblity.h @@ -14,3 +14,17 @@ // should still work as long as accessibility is enabled, this framework provides no guarantees on // their correctness. For details, see // https://developer.apple.com/documentation/objectivec/nsobject/1615147-accessibilityelements + +// After recusively collecting all of the accessibility elements of a node, they get sorted. This sort determines +// the order that a screen reader will traverse the elements. By default, we sort these elements based on their +// origin: lower y origin comes first, then lower x origin. If 2 nodes have an equal origin, the node with the smaller +// height is placed before the node with the smaller width. If two nodes have the exact same rect, we throw up our hands +// and return NSOrderedSame. +// +// In general this seems to work fairly well. However, if you want to provide a custom sort you can do so via +// setUserDefinedAccessibilitySortComparator(). The two elements you are comparing are NSObjects, which conforms to the +// informal UIAccessibility protocol, so you can safely compare properties like accessibilityFrame. +typedef NSComparisonResult (^ASSortAccessibilityElementsComparator)(NSObject *, NSObject *); + +// Use this method to supply your own custom sort comparator used to determine the order of the accessibility elements +void setUserDefinedAccessibilitySortComparator(ASSortAccessibilityElementsComparator userDefinedComparator); diff --git a/Source/Details/_ASDisplayViewAccessiblity.mm b/Source/Details/_ASDisplayViewAccessiblity.mm index fa90666dd..8115eea53 100644 --- a/Source/Details/_ASDisplayViewAccessiblity.mm +++ b/Source/Details/_ASDisplayViewAccessiblity.mm @@ -10,6 +10,7 @@ #ifndef ASDK_ACCESSIBILITY_DISABLE #import +#import #import #import #import @@ -21,35 +22,47 @@ #pragma mark - UIAccessibilityElement -@protocol ASAccessibilityElementPositioning +static ASSortAccessibilityElementsComparator currentAccessibilityComparator = nil; +static ASSortAccessibilityElementsComparator defaultAccessibilityComparator = nil; -@property (nonatomic, readonly) CGRect accessibilityFrame; - -@end - -typedef NSComparisonResult (^SortAccessibilityElementsComparator)(id, id); +void setUserDefinedAccessibilitySortComparator(ASSortAccessibilityElementsComparator userDefinedComparator) { + currentAccessibilityComparator = userDefinedComparator ?: defaultAccessibilityComparator; +} /// Sort accessiblity elements first by y and than by x origin. -static void SortAccessibilityElements(NSMutableArray *elements) +void SortAccessibilityElements(NSMutableArray *elements) { ASDisplayNodeCAssertNotNil(elements, @"Should pass in a NSMutableArray"); - - static SortAccessibilityElementsComparator comparator = nil; + static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - comparator = ^NSComparisonResult(id a, id b) { - CGPoint originA = a.accessibilityFrame.origin; - CGPoint originB = b.accessibilityFrame.origin; - if (originA.y == originB.y) { - if (originA.x == originB.x) { - return NSOrderedSame; + defaultAccessibilityComparator = ^NSComparisonResult(NSObject *a, NSObject *b) { + CGPoint originA = a.accessibilityFrame.origin; + CGPoint originB = b.accessibilityFrame.origin; + if (originA.y == originB.y) { + if (originA.x == originB.x) { + // if we have the same origin, favor shorter items. If heights are the same, favor thinner items. If size is the same ¯\_(ツ)_/¯ + CGSize sizeA = a.accessibilityFrame.size; + CGSize sizeB = b.accessibilityFrame.size; + if (sizeA.height == sizeB.height) { + if (sizeA.width == sizeB.width) { + return NSOrderedSame; + } + return (sizeA.width < sizeB.width) ? NSOrderedAscending : NSOrderedDescending; } - return (originA.x < originB.x) ? NSOrderedAscending : NSOrderedDescending; + return (sizeA.height < sizeB.height) ? NSOrderedAscending : NSOrderedDescending; } - return (originA.y < originB.y) ? NSOrderedAscending : NSOrderedDescending; - }; + return (originA.x < originB.x) ? NSOrderedAscending : NSOrderedDescending; + } + return (originA.y < originB.y) ? NSOrderedAscending : NSOrderedDescending; + }; + + if (!currentAccessibilityComparator) { + currentAccessibilityComparator = defaultAccessibilityComparator; + } }); - [elements sortUsingComparator:comparator]; + + [elements sortUsingComparator:currentAccessibilityComparator]; } static CGRect ASAccessibilityFrameForNode(ASDisplayNode *node) { @@ -57,7 +70,7 @@ static CGRect ASAccessibilityFrameForNode(ASDisplayNode *node) { return [layer convertRect:node.bounds toLayer:ASFindWindowOfLayer(layer).layer]; } -@interface ASAccessibilityElement : UIAccessibilityElement +@interface ASAccessibilityElement : UIAccessibilityElement @property (nonatomic) ASDisplayNode *node; @@ -93,7 +106,7 @@ - (CGRect)accessibilityFrame #pragma mark - _ASDisplayView / UIAccessibilityContainer -@interface ASAccessibilityCustomAction : UIAccessibilityCustomAction +@interface ASAccessibilityCustomAction : UIAccessibilityCustomAction @property (nonatomic) ASDisplayNode *node; @@ -214,6 +227,11 @@ static void CollectAccessibilityElements(ASDisplayNode *node, NSMutableArray *el UIView *view = node.view; + // If we don't have a window, let's just bail out + if (!view.window) { + return; + } + if (node.isAccessibilityContainer && !anySubNodeIsCollection) { CollectAccessibilityElementsForContainer(node, view, elements); return; @@ -224,8 +242,18 @@ static void CollectAccessibilityElements(ASDisplayNode *node, NSMutableArray *el CollectUIAccessibilityElementsForNode(node, node, view, elements); return; } - + for (ASDisplayNode *subnode in node.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; + } + // If a subnode is outside of the view's window, exclude it + CGRect nodeInWindowCoords = [node convertRect:subnode.frame toNode:nil]; + if (!CGRectIntersectsRect(view.window.frame, nodeInWindowCoords)) { + continue; + } + if (subnode.isAccessibilityElement) { // An accessiblityElement can either be a UIView or a UIAccessibilityElement if (subnode.isLayerBacked) { @@ -275,7 +303,9 @@ - (NSArray *)accessibilityElements return @[]; } - if (_accessibilityElements == nil) { + // when items become hidden/visible we have to manually clear the _accessibilityElements in order to get an updated version + // Instead, let's try computing the elements every time and see how badly it affects performance. + if (_accessibilityElements == nil || ASActivateExperimentalFeature(ASExperimentalDoNotCacheAccessibilityElements)) { _accessibilityElements = [viewNode accessibilityElements]; } return _accessibilityElements; diff --git a/Tests/ASConfigurationTests.mm b/Tests/ASConfigurationTests.mm index a0eefcaa2..67a0b0090 100644 --- a/Tests/ASConfigurationTests.mm +++ b/Tests/ASConfigurationTests.mm @@ -29,7 +29,9 @@ ASExperimentalDispatchApply, ASExperimentalOOMBackgroundDeallocDisable, ASExperimentalDrawingGlobal, - ASExperimentalOptimizeDataControllerPipeline + ASExperimentalOptimizeDataControllerPipeline, + ASExperimentalTraitCollectionDidChangeWithPreviousCollection, + ASExperimentalDoNotCacheAccessibilityElements, }; @interface ASConfigurationTests : ASTestCase @@ -53,7 +55,9 @@ + (NSArray *)names { @"exp_dispatch_apply", @"exp_oom_bg_dealloc_disable", @"exp_drawing_global", - @"exp_optimize_data_controller_pipeline" + @"exp_optimize_data_controller_pipeline", + @"exp_trait_collection_did_change_with_previous_collection", + @"exp_do_not_cache_accessibility_elements", ]; } diff --git a/Tests/ASDisplayViewAccessibilityTests.mm b/Tests/ASDisplayViewAccessibilityTests.mm index f2476a00d..d52151cd6 100644 --- a/Tests/ASDisplayViewAccessibilityTests.mm +++ b/Tests/ASDisplayViewAccessibilityTests.mm @@ -13,15 +13,19 @@ #import #import +#import #import #import #import #import #import #import +#import #import #import "ASDisplayNodeTestsHelper.h" +extern void SortAccessibilityElements(NSMutableArray *elements); + @interface ASDisplayViewAccessibilityTests : XCTestCase @end @@ -34,6 +38,11 @@ - (void)testAccessibilityElementsAccessors ASDisplayNode *subnode = nil; node = [[ASDisplayNode alloc] init]; subnode = [[ASDisplayNode alloc] init]; + + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 560)]; + [window addSubnode:node]; + [window makeKeyAndVisible]; + NSString *label = @"foo"; subnode.isAccessibilityElement = YES; subnode.accessibilityLabel = label; @@ -56,6 +65,10 @@ - (void)testThatSubnodeAccessibilityLabelAggregationWorks innerNode1 = [[ASDisplayNode alloc] init]; innerNode2 = [[ASDisplayNode alloc] init]; + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 560)]; + [window addSubnode:node]; + [window makeKeyAndVisible]; + // Initialize nodes with relevant accessibility data node.isAccessibilityContainer = YES; innerNode1.accessibilityLabel = @"hello"; @@ -78,6 +91,10 @@ - (void)testThatContainerAccessibilityLabelOverrideStopsAggregation node = [[ASDisplayNode alloc] init]; innerNode = [[ASDisplayNode alloc] init]; + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 560)]; + [window addSubnode:node]; + [window makeKeyAndVisible]; + // Initialize nodes with relevant accessibility data node.isAccessibilityContainer = YES; node.accessibilityLabel = @"hello"; @@ -94,6 +111,10 @@ - (void)testThatContainerAccessibilityLabelOverrideStopsAggregation - (void)testAccessibilityLayerBackedContainerWithinAccessibilityContainer { ASDisplayNode *container = [[ASDisplayNode alloc] init]; + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 560)]; + [window addSubnode:container]; + [window makeKeyAndVisible]; + container.frame = CGRectMake(50, 50, 200, 600); container.isAccessibilityContainer = YES; @@ -202,7 +223,12 @@ - (void)testActionForwarding { - (void)fakeSelector:(id)sender { } - (void)testThatAccessibilityElementsWorks { + ASDisplayNode *containerNode = [[ASDisplayNode alloc] init]; + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 560)]; + [window addSubnode:containerNode]; + [window makeKeyAndVisible]; + containerNode.frame = CGRectMake(0, 0, 100, 200); ASTextNode *label = [[ASTextNode alloc] init]; @@ -251,4 +277,161 @@ - (void)testThatAccessibilityElementsOverrideWorks { XCTAssertEqual(elements.firstObject, label); } +- (void)testHiddenAccessibilityElements { + ASDisplayNode *containerNode = [[ASDisplayNode alloc] init]; + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 560)]; + [window addSubnode:containerNode]; + [window makeKeyAndVisible]; + + containerNode.frame = CGRectMake(0, 0, 100, 200); + + ASTextNode *label = [[ASTextNode alloc] init]; + label.attributedText = [[NSAttributedString alloc] initWithString:@"test label"]; + label.frame = CGRectMake(0, 0, 100, 20); + + ASTextNode *hiddenLabel = [[ASTextNode alloc] init]; + hiddenLabel.attributedText = [[NSAttributedString alloc] initWithString:@"hidden label"]; + hiddenLabel.frame = CGRectMake(0, 24, 100, 20); + hiddenLabel.hidden = YES; + + [containerNode addSubnode:label]; + [containerNode addSubnode:hiddenLabel]; + + // force load + __unused UIView *view = containerNode.view; + + NSArray *elements = [containerNode accessibilityElements]; + XCTAssertTrue(elements.count == 1); + XCTAssertEqual(elements.firstObject, label.view); +} + +- (void)testTransparentAccessibilityElements { + ASDisplayNode *containerNode = [[ASDisplayNode alloc] init]; + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 560)]; + [window addSubnode:containerNode]; + [window makeKeyAndVisible]; + containerNode.frame = CGRectMake(0, 0, 100, 200); + + ASTextNode *label = [[ASTextNode alloc] init]; + label.attributedText = [[NSAttributedString alloc] initWithString:@"test label"]; + label.frame = CGRectMake(0, 0, 100, 20); + + ASTextNode *hiddenLabel = [[ASTextNode alloc] init]; + hiddenLabel.attributedText = [[NSAttributedString alloc] initWithString:@"hidden label"]; + hiddenLabel.frame = CGRectMake(0, 24, 100, 20); + hiddenLabel.alpha = 0.0; + + [containerNode addSubnode:label]; + [containerNode addSubnode:hiddenLabel]; + + // force load + __unused UIView *view = containerNode.view; + + NSArray *elements = [containerNode accessibilityElements]; + XCTAssertTrue(elements.count == 1); + XCTAssertEqual(elements.firstObject, label.view); +} + +- (void)testAccessibilityElementsNotInAppWindow { + + 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]; + + CGSize windowSize = window.frame.size; + ASTextNode *label = [[ASTextNode alloc] init]; + label.attributedText = [[NSAttributedString alloc] initWithString:@"on screen"]; + label.frame = CGRectMake(0, 0, 100, 20); + + ASTextNode *partiallyOnScreenNodeY = [[ASTextNode alloc] init]; + partiallyOnScreenNodeY.attributedText = [[NSAttributedString alloc] initWithString:@"partially on screen y"]; + partiallyOnScreenNodeY.frame = CGRectMake(0, windowSize.height - 10, 100, 20); + + ASTextNode *partiallyOnScreenNodeX = [[ASTextNode alloc] init]; + partiallyOnScreenNodeX.attributedText = [[NSAttributedString alloc] initWithString:@"partially on screen x"]; + partiallyOnScreenNodeX.frame = CGRectMake(windowSize.width - 10, 100, 100, 20); + + ASTextNode *offScreenNodeY = [[ASTextNode alloc] init]; + offScreenNodeY.attributedText = [[NSAttributedString alloc] initWithString:@"off screen y"]; + offScreenNodeY.frame = CGRectMake(0, windowSize.height + 10, 100, 20); + + ASTextNode *offScreenNodeX = [[ASTextNode alloc] init]; + offScreenNodeX.attributedText = [[NSAttributedString alloc] initWithString:@"off screen x"]; + offScreenNodeX.frame = CGRectMake(windowSize.width + 1, 200, 100, 20); + + ASTextNode *offScreenNode = [[ASTextNode alloc] init]; + offScreenNode.attributedText = [[NSAttributedString alloc] initWithString:@"off screen"]; + offScreenNode.frame = CGRectMake(windowSize.width + 1, windowSize.height + 1, 100, 20); + + [node addSubnode:label]; + [node addSubnode:partiallyOnScreenNodeY]; + [node addSubnode:partiallyOnScreenNodeX]; + [node addSubnode:offScreenNodeY]; + [node addSubnode:offScreenNodeX]; + [node addSubnode:offScreenNode]; + + NSArray *elements = [node accessibilityElements]; + XCTAssertTrue(elements.count == 3); + XCTAssertTrue([elements containsObject:label.view]); + XCTAssertTrue([elements containsObject:partiallyOnScreenNodeX.view]); + XCTAssertTrue([elements containsObject:partiallyOnScreenNodeY.view]); +} + +- (void)testAccessibilitySort { + ASDisplayNode *node1 = [[ASDisplayNode alloc] init]; + node1.accessibilityFrame = CGRectMake(0, 0, 50, 200); + + ASDisplayNode *node2 = [[ASDisplayNode alloc] init]; + node2.accessibilityFrame = CGRectMake(0, 0, 100, 200); + + ASDisplayNode *node3 = [[ASDisplayNode alloc] init]; + node3.accessibilityFrame = CGRectMake(0, 1, 100, 200); + + ASDisplayNode *node4 = [[ASDisplayNode alloc] init]; + node4.accessibilityFrame = CGRectMake(1, 1, 100, 200); + + NSMutableArray *elements = [@[node2, node4, node3, node1] mutableCopy]; + SortAccessibilityElements(elements); + XCTAssertEqual(elements[0], node1); + XCTAssertEqual(elements[1], node2); + XCTAssertEqual(elements[2], node3); + XCTAssertEqual(elements[3], node4); +} + +- (void)testCustomAccessibilitySort { + + // silly custom sorter that puts items with the largest height first. + setUserDefinedAccessibilitySortComparator(^NSComparisonResult(NSObject *a, NSObject *b) { + if (a.accessibilityFrame.size.height == b.accessibilityFrame.size.height) { + return NSOrderedSame; + } + return a.accessibilityFrame.size.height > b.accessibilityFrame.size.height ? NSOrderedAscending : NSOrderedDescending; + }); + + ASDisplayNode *node1 = [[ASDisplayNode alloc] init]; + node1.accessibilityFrame = CGRectMake(0, 0, 50, 300); + + ASDisplayNode *node2 = [[ASDisplayNode alloc] init]; + node2.accessibilityFrame = CGRectMake(0, 0, 100, 250); + + ASDisplayNode *node3 = [[ASDisplayNode alloc] init]; + node3.accessibilityFrame = CGRectMake(0, 0, 100, 200); + + ASDisplayNode *node4 = [[ASDisplayNode alloc] init]; + node4.accessibilityFrame = CGRectMake(0, 0, 100, 150); + + NSMutableArray *elements = [@[node2, node4, node3, node1] mutableCopy]; + SortAccessibilityElements(elements); + XCTAssertEqual(elements[0], node1); + XCTAssertEqual(elements[1], node2); + XCTAssertEqual(elements[2], node3); + XCTAssertEqual(elements[3], node4); +} + + @end diff --git a/Tests/ASScrollNodeTests.mm b/Tests/ASScrollNodeTests.mm index f9dbd5152..1f7e8c176 100644 --- a/Tests/ASScrollNodeTests.mm +++ b/Tests/ASScrollNodeTests.mm @@ -170,6 +170,10 @@ - (void)testAutomaticallyManagesContentSizeWithInvalidCalculatedSizeForLayout - (void)testASScrollNodeAccessibility { ASDisplayNode *scrollNode = [[ASDisplayNode alloc] init]; + UIWindow *window = [[UIWindow alloc] initWithFrame:CGRectMake(0, 0, 320, 560)]; + [window addSubnode:scrollNode]; + [window makeKeyAndVisible]; + ASDisplayNode *node = [[ASDisplayNode alloc] init]; node.isAccessibilityContainer = YES; node.accessibilityLabel = @"node";