Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
Extended Accessibility Actions Support (#24695)
Summary:
This is a reconstitution of #24190. It extends accessibility actions to include both a name and user facing label. These extensions support both standard and custom actions.

We've also added actions support on Android, and added examples to RNTester showing how both standard and custom accessibility actions are used.

## Changelog

[general] [changed] - Enhanced accessibility actions support
Pull Request resolved: #24695

Differential Revision: D15391408

Pulled By: cpojer

fbshipit-source-id: 5ed48004d46d9887da53baea7fdcd0e7e15c5739
  • Loading branch information
xuelgong authored and facebook-github-bot committed May 20, 2019
1 parent 933e65e commit 14b4668
Show file tree
Hide file tree
Showing 13 changed files with 504 additions and 281 deletions.
15 changes: 15 additions & 0 deletions Libraries/Components/View/ViewAccessibility.js
Expand Up @@ -10,6 +10,8 @@

'use strict';

import type {SyntheticEvent} from 'CoreEventTypes';

// This must be kept in sync with the AccessibilityRolesMask in RCTViewManager.m
export type AccessibilityRole =
| 'none'
Expand Down Expand Up @@ -51,3 +53,16 @@ export type AccessibilityStates = $ReadOnlyArray<
| 'collapsed'
| 'hasPopup',
>;

// the info associated with an accessibility action
export type AccessibilityActionInfo = $ReadOnly<{
name: string,
label?: string,
}>;

// The info included in the event sent to onAccessibilityAction
export type AccessibilityActionEvent = SyntheticEvent<
$ReadOnly<{
actionName: string,
}>,
>;
23 changes: 13 additions & 10 deletions Libraries/Components/View/ViewPropTypes.js
Expand Up @@ -15,7 +15,12 @@ import type {EdgeInsetsProp} from '../../StyleSheet/EdgeInsetsPropType';
import type {Node} from 'react';
import type {ViewStyleProp} from '../../StyleSheet/StyleSheet';
import type {TVViewProps} from '../AppleTV/TVViewPropTypes';
import type {AccessibilityRole, AccessibilityStates} from './ViewAccessibility';
import type {
AccessibilityRole,
AccessibilityStates,
AccessibilityActionEvent,
AccessibilityActionInfo,
} from './ViewAccessibility';

export type ViewLayout = Layout;
export type ViewLayoutEvent = LayoutEvent;
Expand All @@ -25,9 +30,8 @@ type DirectEventProps = $ReadOnly<{|
* When `accessible` is true, the system will try to invoke this function
* when the user performs an accessibility custom action.
*
* @platform ios
*/
onAccessibilityAction?: ?(string) => void,
onAccessibilityAction?: ?(event: AccessibilityActionEvent) => void,

/**
* When `accessible` is true, the system will try to invoke this function
Expand Down Expand Up @@ -321,13 +325,6 @@ type AndroidViewProps = $ReadOnly<{|
|}>;

type IOSViewProps = $ReadOnly<{|
/**
* Provides an array of custom actions available for accessibility.
*
* @platform ios
*/
accessibilityActions?: ?$ReadOnlyArray<string>,

/**
* Prevents view from being inverted if set to true and color inversion is turned on.
*
Expand Down Expand Up @@ -417,6 +414,12 @@ export type ViewProps = $ReadOnly<{|
*/
accessibilityStates?: ?AccessibilityStates,

/**
* Provides an array of custom actions available for accessibility.
*
*/
accessibilityActions?: ?$ReadOnlyArray<AccessibilityActionInfo>,

/**
* Used to locate this view in end-to-end tests.
*
Expand Down
72 changes: 72 additions & 0 deletions RNTester/js/AccessibilityExample.js
Expand Up @@ -410,6 +410,72 @@ class AccessibilityRoleAndStateExample extends React.Component<{}> {
}
}

class AccessibilityActionsExample extends React.Component {
render() {
return (
<View>
<RNTesterBlock title="Non-touchable with activate action">
<View
accessible={true}
accessibilityActions={[{name: 'activate'}]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'activate':
Alert.alert('Alert', 'View is clicked');
break;
}
}}>
<Text>Click me</Text>
</View>
</RNTesterBlock>

<RNTesterBlock title="View with multiple actions">
<View
accessible={true}
accessibilityActions={[
{name: 'cut', label: 'cut label'},
{name: 'copy', label: 'copy label'},
{name: 'paste', label: 'paste label'},
]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'cut':
Alert.alert('Alert', 'cut action success');
break;
case 'copy':
Alert.alert('Alert', 'copy action success');
break;
case 'paste':
Alert.alert('Alert', 'paste action success');
break;
}
}}>
<Text>This view supports many actions.</Text>
</View>
</RNTesterBlock>

<RNTesterBlock title="Adjustable with increment/decrement actions">
<View
accessible={true}
accessibilityRole="adjustable"
accessibilityActions={[{name: 'increment'}, {name: 'decrement'}]}
onAccessibilityAction={event => {
switch (event.nativeEvent.actionName) {
case 'increment':
Alert.alert('Alert', 'increment action success');
break;
case 'decrement':
Alert.alert('Alert', 'decrement action success');
break;
}
}}>
<Text>Slider</Text>
</View>
</RNTesterBlock>
</View>
);
}
}
class ScreenReaderStatusExample extends React.Component<{}> {
state = {
screenReaderEnabled: false,
Expand Down Expand Up @@ -483,6 +549,12 @@ exports.examples = [
return <AccessibilityRoleAndStateExample />;
},
},
{
title: 'Accessibility action examples',
render(): React.Element<typeof AccessibilityActionsExample> {
return <AccessibilityActionsExample />;
},
},
{
title: 'Check if the screen reader is enabled',
render(): React.Element<typeof ScreenReaderStatusExample> {
Expand Down
31 changes: 21 additions & 10 deletions RNTester/js/AccessibilityIOSExample.js
Expand Up @@ -21,22 +21,33 @@ class AccessibilityIOSExample extends React.Component<Props> {
return (
<RNTesterBlock title="Accessibility iOS APIs">
<View
onAccessibilityTap={() =>
Alert.alert('Alert', 'onAccessibilityTap success')
}
accessible={true}>
onAccessibilityAction={event => {
if (event.nativeEvent.actionName === 'activate') {
Alert.alert('Alert', 'onAccessibilityTap success');
}
}}
accessible={true}
accessibilityActions={[{name: 'activate'}]}>
<Text>Accessibility normal tap example</Text>
</View>
<View
onMagicTap={() => Alert.alert('Alert', 'onMagicTap success')}
accessible={true}>
onAccessibilityAction={event => {
if (event.nativeEvent.actionName === 'magicTap') {
Alert.alert('Alert', 'onMagicTap success');
}
}}
accessible={true}
accessibilityActions={[{name: 'magicTap'}]}>
<Text>Accessibility magic tap example</Text>
</View>
<View
onAccessibilityEscape={() =>
Alert.alert('onAccessibilityEscape success')
}
accessible={true}>
onAccessibilityAction={event => {
if (event.nativeEvent.actionName === 'escape') {
Alert.alert('onAccessibilityEscape success');
}
}}
accessible={true}
accessibilityActions={[{name: 'escape'}]}>
<Text>Accessibility escape example</Text>
</View>
<View accessibilityElementsHidden={true}>
Expand Down
1 change: 1 addition & 0 deletions React/Views/RCTView.h
Expand Up @@ -23,6 +23,7 @@ extern const UIAccessibilityTraits SwitchAccessibilityTrait;
/**
* Accessibility event handlers
*/
@property (nonatomic, copy) NSArray <NSDictionary *> *accessibilityActions;
@property (nonatomic, copy) RCTDirectEventBlock onAccessibilityAction;
@property (nonatomic, copy) RCTDirectEventBlock onAccessibilityTap;
@property (nonatomic, copy) RCTDirectEventBlock onMagicTap;
Expand Down
79 changes: 67 additions & 12 deletions React/Views/RCTView.m
Expand Up @@ -101,6 +101,8 @@ - (UIView *)react_findClipView
@implementation RCTView
{
UIColor *_backgroundColor;
NSMutableDictionary<NSString *, NSDictionary *> *accessibilityActionsNameMap;
NSMutableDictionary<NSString *, NSDictionary *> *accessibilityActionsLabelMap;
}

- (instancetype)initWithFrame:(CGRect)frame
Expand Down Expand Up @@ -156,33 +158,57 @@ - (NSString *)accessibilityLabel
return RCTRecursiveAccessibilityLabel(self);
}

-(void)setAccessibilityActions:(NSArray *)actions
{
if (!actions) {
return;
}
accessibilityActionsNameMap = [[NSMutableDictionary alloc] init];
accessibilityActionsLabelMap = [[NSMutableDictionary alloc] init];
for (NSDictionary *action in actions) {
if (action[@"name"]) {
accessibilityActionsNameMap[action[@"name"]] = action;
}
if (action[@"label"]) {
accessibilityActionsLabelMap[action[@"label"]] = action;
}
}
_accessibilityActions = [actions copy];
}

- (NSArray <UIAccessibilityCustomAction *> *)accessibilityCustomActions
{
if (!self.accessibilityActions.count) {
return nil;
}

NSMutableArray *actions = [NSMutableArray array];
for (NSString *action in self.accessibilityActions) {
[actions addObject:[[UIAccessibilityCustomAction alloc] initWithName:action
target:self
selector:@selector(didActivateAccessibilityCustomAction:)]];
for (NSDictionary *action in self.accessibilityActions) {
if (action[@"label"]) {
[actions addObject:[[UIAccessibilityCustomAction alloc] initWithName:action[@"label"]
target:self
selector:@selector(didActivateAccessibilityCustomAction:)]];
}
}

return [actions copy];
}

- (BOOL)didActivateAccessibilityCustomAction:(UIAccessibilityCustomAction *)action
{
if (!_onAccessibilityAction) {
if (!_onAccessibilityAction || !accessibilityActionsLabelMap) {
return NO;
}

_onAccessibilityAction(@{
@"action": action.name,
@"target": self.reactTag
});
// iOS defines the name as the localized label, so use our map to convert this back to the non-localized action namne when passing to JS. This allows for standard action names across platforms.

NSDictionary *actionObject = accessibilityActionsLabelMap[action.name];
if (actionObject) {
_onAccessibilityAction(@{
@"actionName": actionObject[@"name"],
@"actionTarget": self.reactTag
});
}
return YES;
}

Expand Down Expand Up @@ -327,9 +353,24 @@ - (BOOL)isAccessibilityElement
return NO;
}

- (BOOL)performAccessibilityAction:(NSString *) name
{
if (_onAccessibilityAction && accessibilityActionsNameMap[name]) {
_onAccessibilityAction(@{
@"actionName" : name,
@"actionTarget" : self.reactTag
});
return YES;
}
return NO;
}

- (BOOL)accessibilityActivate
{
if (_onAccessibilityTap) {
if ([self performAccessibilityAction:@"activate"]) {
return YES;
}
else if (_onAccessibilityTap) {
_onAccessibilityTap(nil);
return YES;
} else {
Expand All @@ -339,7 +380,9 @@ - (BOOL)accessibilityActivate

- (BOOL)accessibilityPerformMagicTap
{
if (_onMagicTap) {
if ([self performAccessibilityAction:@"magicTap"]) {
return YES;
} else if (_onMagicTap) {
_onMagicTap(nil);
return YES;
} else {
Expand All @@ -349,14 +392,26 @@ - (BOOL)accessibilityPerformMagicTap

- (BOOL)accessibilityPerformEscape
{
if (_onAccessibilityEscape) {
if ([self performAccessibilityAction:@"escape"]) {
return YES;
} else if (_onAccessibilityEscape) {
_onAccessibilityEscape(nil);
return YES;
} else {
return NO;
}
}

- (void)accessibilityIncrement
{
[self performAccessibilityAction:@"increment"];
}

- (void)accessibilityDecrement
{
[self performAccessibilityAction:@"decrement"];
}

- (NSString *)description
{
NSString *superDescription = super.description;
Expand Down
2 changes: 1 addition & 1 deletion React/Views/RCTViewManager.m
Expand Up @@ -123,7 +123,7 @@ - (RCTShadowView *)shadowView

// Acessibility related properties
RCT_REMAP_VIEW_PROPERTY(accessible, reactAccessibilityElement.isAccessibilityElement, BOOL)
RCT_REMAP_VIEW_PROPERTY(accessibilityActions, reactAccessibilityElement.accessibilityActions, NSArray<NSString *>)
RCT_REMAP_VIEW_PROPERTY(accessibilityActions, reactAccessibilityElement.accessibilityActions, NSDictionaryArray)
RCT_REMAP_VIEW_PROPERTY(accessibilityLabel, reactAccessibilityElement.accessibilityLabel, NSString)
RCT_REMAP_VIEW_PROPERTY(accessibilityHint, reactAccessibilityElement.accessibilityHint, NSString)
RCT_REMAP_VIEW_PROPERTY(accessibilityViewIsModal, reactAccessibilityElement.accessibilityViewIsModal, BOOL)
Expand Down
1 change: 0 additions & 1 deletion React/Views/UIView+React.h
Expand Up @@ -116,7 +116,6 @@
/**
* Accessibility properties
*/
@property (nonatomic, copy) NSArray <NSString *> *accessibilityActions;
@property (nonatomic, copy) NSString *accessibilityRole;
@property (nonatomic, copy) NSArray <NSString *> *accessibilityStates;

Expand Down

0 comments on commit 14b4668

Please sign in to comment.