Skip to content

Commit

Permalink
[Accessibility] Add .isAccessibilityContainer property, allowing auto…
Browse files Browse the repository at this point in the history
…matic aggregation of children's a11y labels. (TextureGroup#468)

After consulting Apple documentation and working with some a11y experts,
we've found that aggregating objects that have a11y labels but are not
themselves interactable is significantly preferred for these users.

It makes it much quicker to navigate scrolling content if VoiceOver only
stops to select entire cells, and then allows drilling down into the cell
to select individual components. This implementation achieves that behavior.

We should consider enabling isAccessibilityContainer by default on ASCellNode.
This would be an improvement for 95% of a11y use cases. Aggregation can be
enabled or disabled on any node.
  • Loading branch information
appleguy authored and bernieperez committed Apr 25, 2018
1 parent 3bdf996 commit 298831b
Show file tree
Hide file tree
Showing 6 changed files with 120 additions and 3 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## master

* Add your own contributions to the next release on the line below this with your name.
- [Accessibility] Add .isAccessibilityContainer property, allowing automatic aggregation of children's a11y labels. [#468][Scott Goodson](https://github.com/appleguy)
- [ASImageNode] Enabled .clipsToBounds by default, fixing the use of .cornerRadius and clipping of GIFs. [Scott Goodson](https://github.com/appleguy) [#466](https://github.com/TextureGroup/Texture/pull/466)
- Fix an issue in layout transition that causes it to unexpectedly use the old layout [Huy Nguyen](https://github.com/nguyenhuy) [#464](https://github.com/TextureGroup/Texture/pull/464)
- Add -[ASDisplayNode detailedLayoutDescription] property to aid debugging. [Adlai Holler](https://github.com/Adlai-Holler) [#476](https://github.com/TextureGroup/Texture/pull/476)
Expand Down
13 changes: 13 additions & 0 deletions Source/ASDisplayNode+Beta.h
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,19 @@ typedef struct {
@property (nonatomic, strong, readonly) ASEventLog *eventLog;
#endif

/**
* @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.
*/
@property (nonatomic, assign) BOOL isAccessibilityContainer;

/**
* @abstract Invoked when a user performs a custom action on an accessible node. Nodes that are children of accessibility containers, have
* an accessibity label and have an interactive UIAccessibilityTrait will automatically receive custom-action handling.
*/
- (void)performAccessibilityCustomAction:(UIAccessibilityCustomAction *)action;

/**
* @abstract Currently used by ASNetworkImageNode and ASMultiplexImageNode to allow their placeholders to stay if they are loading an image from the network.
* Otherwise, a display pass is scheduled and completes, but does not actually draw anything - and ASDisplayNode considers the element finished.
Expand Down
6 changes: 6 additions & 0 deletions Source/ASDisplayNode+Yoga.mm
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

#if YOGA /* YOGA */

#import <AsyncDisplayKit/_ASDisplayViewAccessiblity.h>
#import <AsyncDisplayKit/ASYogaLayoutSpec.h>
#import <AsyncDisplayKit/ASYogaUtilities.h>
#import <AsyncDisplayKit/ASDisplayNode+Beta.h>
Expand Down Expand Up @@ -235,6 +236,11 @@ - (void)calculateLayoutFromYogaRoot:(ASSizeRange)rootConstrainedSize
yogaFloatForCGFloat(rootConstrainedSize.max.height),
YGDirectionInherit);

// Reset accessible elements, since layout may have changed.
ASPerformBlockOnMainThread(^{
[(_ASDisplayView *)self.view setAccessibleElements:nil];
});

ASDisplayNodePerformBlockOnEveryYogaChild(self, ^(ASDisplayNode * _Nonnull node) {
[node setupYogaCalculatedLayout];
node.yogaLayoutInProgress = NO;
Expand Down
13 changes: 13 additions & 0 deletions Source/ASDisplayNode.mm
Original file line number Diff line number Diff line change
Expand Up @@ -3171,6 +3171,19 @@ - (ASDisplayNodePerformanceMeasurements)performanceMeasurements
return measurements;
}

#pragma mark - Accessibility

- (void)setIsAccessibilityContainer:(BOOL)isAccessibilityContainer
{
ASDN::MutexLocker l(__instanceLock__);
_isAccessibilityContainer = isAccessibilityContainer;
}

- (BOOL)isAccessibilityContainer
{
ASDN::MutexLocker l(__instanceLock__);
return _isAccessibilityContainer;
}

#pragma mark - Debugging (Private)

Expand Down
89 changes: 86 additions & 3 deletions Source/Details/_ASDisplayViewAccessiblity.mm
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,21 @@
#import <AsyncDisplayKit/ASDisplayNode+Beta.h>
#import <AsyncDisplayKit/ASDisplayNodeInternal.h>

#import <queue>

NS_INLINE UIAccessibilityTraits InteractiveAccessibilityTraitsMask() {
return UIAccessibilityTraitLink | UIAccessibilityTraitKeyboardKey | UIAccessibilityTraitButton;
}

#pragma mark - UIAccessibilityElement

typedef NSComparisonResult (^SortAccessibilityElementsComparator)(UIAccessibilityElement *, UIAccessibilityElement *);
@protocol ASAccessibilityElementPositioning

@property (nonatomic, readonly) CGRect accessibilityFrame;

@end

typedef NSComparisonResult (^SortAccessibilityElementsComparator)(id<ASAccessibilityElementPositioning>, id<ASAccessibilityElementPositioning>);

/// Sort accessiblity elements first by y and than by x origin.
static void SortAccessibilityElements(NSMutableArray *elements)
Expand All @@ -35,7 +47,7 @@ static void SortAccessibilityElements(NSMutableArray *elements)
static SortAccessibilityElementsComparator comparator = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
comparator = ^NSComparisonResult(UIAccessibilityElement *a, UIAccessibilityElement *b) {
comparator = ^NSComparisonResult(id<ASAccessibilityElementPositioning> a, id<ASAccessibilityElementPositioning> b) {
CGPoint originA = a.accessibilityFrame.origin;
CGPoint originB = b.accessibilityFrame.origin;
if (originA.y == originB.y) {
Expand All @@ -50,7 +62,7 @@ static void SortAccessibilityElements(NSMutableArray *elements)
[elements sortUsingComparator:comparator];
}

@interface ASAccessibilityElement : UIAccessibilityElement
@interface ASAccessibilityElement : UIAccessibilityElement<ASAccessibilityElementPositioning>

@property (nonatomic, strong) ASDisplayNode *node;
@property (nonatomic, strong) ASDisplayNode *containerNode;
Expand Down Expand Up @@ -85,6 +97,25 @@ - (CGRect)accessibilityFrame

#pragma mark - _ASDisplayView / UIAccessibilityContainer

@interface ASAccessibilityCustomAction : UIAccessibilityCustomAction<ASAccessibilityElementPositioning>

@property (nonatomic, strong) UIView *container;
@property (nonatomic, strong) ASDisplayNode *node;
@property (nonatomic, strong) ASDisplayNode *containerNode;

@end

@implementation ASAccessibilityCustomAction

- (CGRect)accessibilityFrame
{
CGRect accessibilityFrame = [self.containerNode convertRect:self.node.bounds fromNode:self.node];
accessibilityFrame = UIAccessibilityConvertFrameToScreenCoordinates(accessibilityFrame, self.container);
return accessibilityFrame;
}

@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
static void CollectUIAccessibilityElementsForNode(ASDisplayNode *node, ASDisplayNode *containerNode, id container, NSMutableArray *elements)
{
Expand All @@ -100,12 +131,64 @@ static void CollectUIAccessibilityElementsForNode(ASDisplayNode *node, ASDisplay
});
}

static void CollectAccessibilityElementsForContainer(ASDisplayNode *container, _ASDisplayView *view, NSMutableArray *elements) {
UIAccessibilityElement *accessiblityElement = [ASAccessibilityElement accessibilityElementWithContainer:view node:container containerNode:container];

NSMutableArray<ASAccessibilityElement *> *labeledNodes = [NSMutableArray array];
NSMutableArray<ASAccessibilityCustomAction *> *actions = [NSMutableArray array];
std::queue<ASDisplayNode *> queue;
queue.push(container);

ASDisplayNode *node;
while (!queue.empty()) {
node = queue.front();
queue.pop();

if (node != container && node.isAccessibilityContainer) {
CollectAccessibilityElementsForContainer(node, view, elements);
continue;
}

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;
[actions addObject:action];
} else {
// 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];
[labeledNodes addObject:nonInteractiveElement];
}
}

for (ASDisplayNode *subnode in node.subnodes) {
queue.push(subnode);
}
}

SortAccessibilityElements(labeledNodes);
NSArray *labels = [labeledNodes valueForKey:@"accessibilityLabel"];
accessiblityElement.accessibilityLabel = [labels componentsJoinedByString:@", "];

SortAccessibilityElements(actions);
accessiblityElement.accessibilityCustomActions = actions;

[elements addObject:accessiblityElement];
}

/// Collect all accessibliity elements for a given view and view node
static void CollectAccessibilityElementsForView(_ASDisplayView *view, NSMutableArray *elements)
{
ASDisplayNodeCAssertNotNil(elements, @"Should pass in a NSMutableArray");

ASDisplayNode *node = view.asyncdisplaykit_node;

if (node.isAccessibilityContainer) {
CollectAccessibilityElementsForContainer(node, view, elements);
return;
}

// Handle rasterize case
if (node.rasterizesSubtree) {
Expand Down
1 change: 1 addition & 0 deletions Source/Private/ASDisplayNodeInternal.h
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ FOUNDATION_EXPORT NSString * const ASRenderingEngineDidDisplayNodesScheduledBefo
NSArray *_accessibilityHeaderElements;
CGPoint _accessibilityActivationPoint;
UIBezierPath *_accessibilityPath;
BOOL _isAccessibilityContainer;

// performance measurement
ASDisplayNodePerformanceMeasurementOptions _measurementOptions;
Expand Down

0 comments on commit 298831b

Please sign in to comment.