Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve UIAccessiblityContainer / -accessibilityElements override support #1525

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Schemas/configuration.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"exp_dispatch_apply",
"exp_oom_bg_dealloc_disable",
"exp_transaction_operation_retain_cycle",
"exp_remove_textkit_initialising_lock"
"exp_text_node_2_a11y_container",
"exp_expose_text_links_a11y"
]
}
}
Expand Down
10 changes: 7 additions & 3 deletions Source/ASDisplayNode+Beta.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
3 changes: 2 additions & 1 deletion Source/ASExperimentalFeatures.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,11 @@ typedef NS_OPTIONS(NSUInteger, ASExperimentalFeatures) {
ASExperimentalSkipClearData = 1 << 6, // exp_skip_clear_data
ASExperimentalDidEnterPreloadSkipASMLayout = 1 << 7, // exp_did_enter_preload_skip_asm_layout
ASExperimentalDispatchApply = 1 << 8, // exp_dispatch_apply
ASExperimentalOOMBackgroundDeallocDisable = 1 << 9, // exp_oom_bg_dealloc_disable
ASExperimentalOOMBackgroundDeallocDisable = 1 << 9, // exp_oom_bg_dealloc_disable
ASExperimentalTransactionOperationRetainCycle = 1 << 10, // exp_transaction_operation_retain_cycle
ASExperimentalRemoveTextKitInitialisingLock = 1 << 11, // exp_remove_textkit_initialising_lock
ASExperimentalDrawingGlobal = 1 << 12, // exp_drawing_global
ASExperimentalExposeTextLinksForA11Y = 1 << 13, // exp_expose_text_links_a11y
ASExperimentalFeatureAll = 0xFFFFFFFF
};

Expand Down
4 changes: 3 additions & 1 deletion Source/ASExperimentalFeatures.mm
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
@"exp_oom_bg_dealloc_disable",
@"exp_transaction_operation_retain_cycle",
@"exp_remove_textkit_initialising_lock",
@"exp_drawing_global"]));
@"exp_drawing_global",
@"exp_text_node_2_a11y_container",
@"exp_expose_text_links_a11y"]));
if (flags == ASExperimentalFeatureAll) {
return allNames;
}
Expand Down
173 changes: 164 additions & 9 deletions Source/ASTextNode2.mm
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,23 @@
#import <AsyncDisplayKit/ASTextLayout.h>
#import <AsyncDisplayKit/ASThread.h>

@interface ASTextNodeAccessiblityElement : UIAccessibilityElement
@property (assign) NSRange accessibilityRange;
@end

@implementation ASTextNodeAccessiblityElement

- (instancetype)initWithAccessibilityContainer:(id)container
{
self = [super initWithAccessibilityContainer:container];
if (self) {
_accessibilityRange = NSMakeRange(NSNotFound, 0);
}
return self;
}

@end

@interface ASTextCacheValue : NSObject {
@package
AS::Mutex _m;
Expand Down Expand Up @@ -215,9 +232,9 @@ - (instancetype)init
self.linkAttributeNames = DefaultLinkAttributeNames();

// Accessibility
self.isAccessibilityElement = YES;
self.isAccessibilityElement = !ASActivateExperimentalFeature(ASExperimentalTextNode2A11YContainer);
self.accessibilityTraits = self.defaultAccessibilityTraits;

// Placeholders
// Disabled by default in ASDisplayNode, but add a few options for those who toggle
// on the special placeholder behavior of ASTextNode.
Expand Down Expand Up @@ -315,6 +332,142 @@ - (UIAccessibilityTraits)defaultAccessibilityTraits
return UIAccessibilityTraitStaticText;
}

/// Uses the given layout, node and container node to calculate the accessibiliyty frame for the given ASTextNodeAccessiblityElement in screen coordinates.
static void ASUpdateAccessibilityFrame(ASTextNodeAccessiblityElement *accessibilityElement, ASTextLayout *layout, ASDisplayNode * _Nullable containerNode, ASDisplayNode *node) {
containerNode = containerNode ?: ASFirstNonLayerBackedSupernodeForNode(node);
CGRect textLayoutFrame = CGRectZero;
if (accessibilityElement.accessibilityRange.location == NSNotFound) {
// If no accessibilityRange was specified (as is done for the text element), just use the
// label's range and clampt to the visible range otherwise the returned rect would be invalid.
NSRange range = NSMakeRange(0, accessibilityElement.accessibilityLabel.length);
range = NSIntersectionRange(range, layout.visibleRange);
textLayoutFrame = [layout rectForRange:[ASTextRange rangeWithRange:range]];
} else {
textLayoutFrame = [layout rectForRange:[ASTextRange rangeWithRange:accessibilityElement.accessibilityRange]];
}
CGRect accessibilityFrame = [node convertRect:textLayoutFrame toNode:containerNode];
accessibilityElement.accessibilityFrame = UIAccessibilityConvertFrameToScreenCoordinates(accessibilityFrame, containerNode.view);
}

/// Walks up the node tree and searches for the first node that is not layer backed
static ASDisplayNode *ASFirstNonLayerBackedSupernodeForNode(ASDisplayNode *node) {
ASDisplayNode *containerNode = node;
while (containerNode.isLayerBacked) {
containerNode = containerNode.supernode;
}
return containerNode;
}

- (NSInteger)accessibilityElementCount
{
if (
!ASActivateExperimentalFeature(ASExperimentalTextNode2A11YContainer)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blank line

return [super accessibilityElementCount];
}

return self.accessibilityElements.count;
}

/// Overwrite accessibilityElementAtIndex: so we can update the element's accessibilityFrame when it is requested.
- (id)accessibilityElementAtIndex:(NSInteger)index
{
if (!ASActivateExperimentalFeature(ASExperimentalTextNode2A11YContainer)) {
return [super accessibilityElementAtIndex:index];
}

ASTextNodeAccessiblityElement *accessibilityElement = self.accessibilityElements[index];

ASTextLayout *layout = ASTextNodeCompatibleLayoutWithContainerAndText(_textContainer, _attributedText);
ASUpdateAccessibilityFrame(accessibilityElement, layout, nil, self);
return accessibilityElement;
}

- (NSArray *)accessibilityElements
{
if (!ASActivateExperimentalFeature(ASExperimentalTextNode2A11YContainer)) {
return [super accessibilityElements];
}

if (_accessibilityElements != nil) {
return _accessibilityElements;
}

NSAttributedString *attributedText = _attributedText;
NSInteger attributedTextLength = attributedText.length;
if (attributedTextLength == 0) {
_accessibilityElements = @[];
return _accessibilityElements;
}

NSMutableArray<ASTextNodeAccessiblityElement *> *accessibilityElements = [[NSMutableArray alloc] init];

// Search the first node that is not layer backed
ASDisplayNode *containerNode = ASFirstNonLayerBackedSupernodeForNode(self);
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:containerNode.view];
accessibilityElement.accessibilityIdentifier = self.accessibilityIdentifier;
accessibilityElement.accessibilityLabel = self.accessibilityLabel;
accessibilityElement.accessibilityHint = self.accessibilityHint;
accessibilityElement.accessibilityValue = self.accessibilityValue;
accessibilityElement.accessibilityTraits = self.accessibilityTraits;
if (AS_AVAILABLE_IOS_TVOS(11, 11)) {
accessibilityElement.accessibilityAttributedLabel = self.accessibilityAttributedLabel;
accessibilityElement.accessibilityAttributedHint = self.accessibilityAttributedHint;
accessibilityElement.accessibilityAttributedValue = self.accessibilityAttributedValue;
}
ASUpdateAccessibilityFrame(accessibilityElement, layout, containerNode, self);
[accessibilityElements addObject:accessibilityElement];

if (ASActivateExperimentalFeature(ASExperimentalExposeTextLinksForA11Y)) {
// Collect all links as accessiblity items
for (NSString *linkAttributeName in _linkAttributeNames) {
[attributedText enumerateAttribute:linkAttributeName inRange:NSMakeRange(0, attributedTextLength) options:NSAttributedStringEnumerationLongestEffectiveRangeNotRequired usingBlock:^(id _Nullable value, NSRange range, BOOL * _Nonnull stop) {
if (value == nil) {
return;
}

ASTextNodeAccessiblityElement *accessibilityElement = [[ASTextNodeAccessiblityElement alloc] initWithAccessibilityContainer:self];
accessibilityElement.accessibilityTraits = UIAccessibilityTraitLink;;
accessibilityElement.accessibilityLabel = [attributedText.string substringWithRange:range];
accessibilityElement.accessibilityRange = range;
if (AS_AVAILABLE_IOS_TVOS(11, 11)) {
accessibilityElement.accessibilityAttributedLabel = [attributedText attributedSubstringFromRange:range];
}
ASUpdateAccessibilityFrame(accessibilityElement, layout, containerNode, self);
[accessibilityElements addObject:accessibilityElement];
}];
}
}
_accessibilityElements = accessibilityElements;
return _accessibilityElements;
}

- (void)setIsAccessibilityElement:(BOOL)isAccessibilityElement
{
if (ASActivateExperimentalFeature(ASExperimentalTextNode2A11YContainer)) {
// Instead of relying on labels accessibility, We implement UIAccessibilityContainer and
// handle accessibility with ASTextNode2
return;
}

[super setIsAccessibilityElement:isAccessibilityElement];

}

- (BOOL)isAccessibilityElement {
if (ASActivateExperimentalFeature(ASExperimentalTextNode2A11YContainer)) {
// Instead of relying on labels accessibility, We implement UIAccessibilityContainer and
// handle accessibility with ASTextNode2
return NO;
}

// Use whatever the default is
return [super isAccessibilityElement];
}

#pragma mark - Layout and Sizing

- (void)setTextContainerInset:(UIEdgeInsets)textContainerInset
Expand Down Expand Up @@ -414,20 +567,22 @@ - (void)setAttributedText:(NSAttributedString *)attributedText
style.ascender = [[self class] ascenderWithAttributedString:attributedText];
style.descender = [[attributedText attribute:NSFontAttributeName atIndex:attributedText.length - 1 effectiveRange:NULL] descender];
}

// Tell the display node superclasses that the cached layout is incorrect now
[self setNeedsLayout];

// Force display to create renderer with new size and redisplay with new string
[self setNeedsDisplay];

// Accessiblity
self.accessibilityLabel = self.defaultAccessibilityLabel;

// We update the isAccessibilityElement setting if this node is not switching between strings.
if (oldAttributedText.length == 0 || length == 0) {
// We're an accessibility element by default if there is a string.
self.isAccessibilityElement = (length != 0);

if (!ASActivateExperimentalFeature(ASExperimentalTextNode2A11YContainer)) {
// We update the isAccessibilityElement setting if this node is not switching between strings.
if (oldAttributedText.length == 0 || length == 0) {
// We're an accessibility element by default if there is a string.
self.isAccessibilityElement = (length != 0);
}
}

#if AS_TEXTNODE2_RECORD_ATTRIBUTED_STRINGS
Expand Down
2 changes: 1 addition & 1 deletion Source/Details/_ASDisplayView.mm
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ @implementation _ASDisplayView
} _internalFlags;

NSArray *_accessibilityElements;
CGRect _lastAccessibilityElementsFrame;
_ASDisplayViewAccessibilityFlags _accessibilityFlags;
}

#pragma mark - Class
Expand Down
7 changes: 7 additions & 0 deletions Source/Details/_ASDisplayViewAccessiblity.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,10 @@
// 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

struct _ASDisplayViewAccessibilityFlags {
unsigned inAccessibilityElementCount:1;
unsigned inIndexOfAccessibilityElement:1;
unsigned inAccessibilityElementAtIndex:1;
unsigned inSetAccessibilityElements:1;
};
Loading