From 14b4668947789220617d6f54bb2fcdcfae844497 Mon Sep 17 00:00:00 2001 From: Sharon Gong Date: Mon, 20 May 2019 01:24:35 -0700 Subject: [PATCH] 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: https://github.com/facebook/react-native/pull/24695 Differential Revision: D15391408 Pulled By: cpojer fbshipit-source-id: 5ed48004d46d9887da53baea7fdcd0e7e15c5739 --- .../Components/View/ViewAccessibility.js | 15 + Libraries/Components/View/ViewPropTypes.js | 23 +- RNTester/js/AccessibilityExample.js | 72 +++++ RNTester/js/AccessibilityIOSExample.js | 31 +- React/Views/RCTView.h | 1 + React/Views/RCTView.m | 79 ++++- React/Views/RCTViewManager.m | 2 +- React/Views/UIView+React.h | 1 - .../uimanager/AccessibilityDelegateUtil.java | 244 --------------- .../react/uimanager/BaseViewManager.java | 24 +- .../uimanager/ReactAccessibilityDelegate.java | 288 ++++++++++++++++++ .../main/res/views/uimanager/values/ids.xml | 3 + .../react/uimanager/BaseViewManagerTest.java | 2 +- 13 files changed, 504 insertions(+), 281 deletions(-) delete mode 100644 ReactAndroid/src/main/java/com/facebook/react/uimanager/AccessibilityDelegateUtil.java create mode 100644 ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java diff --git a/Libraries/Components/View/ViewAccessibility.js b/Libraries/Components/View/ViewAccessibility.js index 316ca494b71cc3..ab23281eecea2a 100644 --- a/Libraries/Components/View/ViewAccessibility.js +++ b/Libraries/Components/View/ViewAccessibility.js @@ -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' @@ -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, + }>, +>; diff --git a/Libraries/Components/View/ViewPropTypes.js b/Libraries/Components/View/ViewPropTypes.js index d0a35c7dac58a2..861f105b455995 100644 --- a/Libraries/Components/View/ViewPropTypes.js +++ b/Libraries/Components/View/ViewPropTypes.js @@ -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; @@ -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 @@ -321,13 +325,6 @@ type AndroidViewProps = $ReadOnly<{| |}>; type IOSViewProps = $ReadOnly<{| - /** - * Provides an array of custom actions available for accessibility. - * - * @platform ios - */ - accessibilityActions?: ?$ReadOnlyArray, - /** * Prevents view from being inverted if set to true and color inversion is turned on. * @@ -417,6 +414,12 @@ export type ViewProps = $ReadOnly<{| */ accessibilityStates?: ?AccessibilityStates, + /** + * Provides an array of custom actions available for accessibility. + * + */ + accessibilityActions?: ?$ReadOnlyArray, + /** * Used to locate this view in end-to-end tests. * diff --git a/RNTester/js/AccessibilityExample.js b/RNTester/js/AccessibilityExample.js index 01e442183d6c5b..c5ae027942e4d8 100644 --- a/RNTester/js/AccessibilityExample.js +++ b/RNTester/js/AccessibilityExample.js @@ -410,6 +410,72 @@ class AccessibilityRoleAndStateExample extends React.Component<{}> { } } +class AccessibilityActionsExample extends React.Component { + render() { + return ( + + + { + switch (event.nativeEvent.actionName) { + case 'activate': + Alert.alert('Alert', 'View is clicked'); + break; + } + }}> + Click me + + + + + { + 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; + } + }}> + This view supports many actions. + + + + + { + switch (event.nativeEvent.actionName) { + case 'increment': + Alert.alert('Alert', 'increment action success'); + break; + case 'decrement': + Alert.alert('Alert', 'decrement action success'); + break; + } + }}> + Slider + + + + ); + } +} class ScreenReaderStatusExample extends React.Component<{}> { state = { screenReaderEnabled: false, @@ -483,6 +549,12 @@ exports.examples = [ return ; }, }, + { + title: 'Accessibility action examples', + render(): React.Element { + return ; + }, + }, { title: 'Check if the screen reader is enabled', render(): React.Element { diff --git a/RNTester/js/AccessibilityIOSExample.js b/RNTester/js/AccessibilityIOSExample.js index 87a54070a95a93..0bcabc91c1c2a7 100644 --- a/RNTester/js/AccessibilityIOSExample.js +++ b/RNTester/js/AccessibilityIOSExample.js @@ -21,22 +21,33 @@ class AccessibilityIOSExample extends React.Component { return ( - Alert.alert('Alert', 'onAccessibilityTap success') - } - accessible={true}> + onAccessibilityAction={event => { + if (event.nativeEvent.actionName === 'activate') { + Alert.alert('Alert', 'onAccessibilityTap success'); + } + }} + accessible={true} + accessibilityActions={[{name: 'activate'}]}> Accessibility normal tap example Alert.alert('Alert', 'onMagicTap success')} - accessible={true}> + onAccessibilityAction={event => { + if (event.nativeEvent.actionName === 'magicTap') { + Alert.alert('Alert', 'onMagicTap success'); + } + }} + accessible={true} + accessibilityActions={[{name: 'magicTap'}]}> Accessibility magic tap example - Alert.alert('onAccessibilityEscape success') - } - accessible={true}> + onAccessibilityAction={event => { + if (event.nativeEvent.actionName === 'escape') { + Alert.alert('onAccessibilityEscape success'); + } + }} + accessible={true} + accessibilityActions={[{name: 'escape'}]}> Accessibility escape example diff --git a/React/Views/RCTView.h b/React/Views/RCTView.h index a63ef765bee82d..b6d6f776682362 100644 --- a/React/Views/RCTView.h +++ b/React/Views/RCTView.h @@ -23,6 +23,7 @@ extern const UIAccessibilityTraits SwitchAccessibilityTrait; /** * Accessibility event handlers */ +@property (nonatomic, copy) NSArray *accessibilityActions; @property (nonatomic, copy) RCTDirectEventBlock onAccessibilityAction; @property (nonatomic, copy) RCTDirectEventBlock onAccessibilityTap; @property (nonatomic, copy) RCTDirectEventBlock onMagicTap; diff --git a/React/Views/RCTView.m b/React/Views/RCTView.m index 04795ef96ad394..6463980533f41f 100644 --- a/React/Views/RCTView.m +++ b/React/Views/RCTView.m @@ -101,6 +101,8 @@ - (UIView *)react_findClipView @implementation RCTView { UIColor *_backgroundColor; + NSMutableDictionary *accessibilityActionsNameMap; + NSMutableDictionary *accessibilityActionsLabelMap; } - (instancetype)initWithFrame:(CGRect)frame @@ -156,6 +158,24 @@ - (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 *)accessibilityCustomActions { if (!self.accessibilityActions.count) { @@ -163,10 +183,12 @@ - (NSString *)accessibilityLabel } 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]; @@ -174,15 +196,19 @@ - (NSString *)accessibilityLabel - (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; } @@ -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 { @@ -339,7 +380,9 @@ - (BOOL)accessibilityActivate - (BOOL)accessibilityPerformMagicTap { - if (_onMagicTap) { + if ([self performAccessibilityAction:@"magicTap"]) { + return YES; + } else if (_onMagicTap) { _onMagicTap(nil); return YES; } else { @@ -349,7 +392,9 @@ - (BOOL)accessibilityPerformMagicTap - (BOOL)accessibilityPerformEscape { - if (_onAccessibilityEscape) { + if ([self performAccessibilityAction:@"escape"]) { + return YES; + } else if (_onAccessibilityEscape) { _onAccessibilityEscape(nil); return YES; } else { @@ -357,6 +402,16 @@ - (BOOL)accessibilityPerformEscape } } +- (void)accessibilityIncrement +{ + [self performAccessibilityAction:@"increment"]; +} + +- (void)accessibilityDecrement +{ + [self performAccessibilityAction:@"decrement"]; +} + - (NSString *)description { NSString *superDescription = super.description; diff --git a/React/Views/RCTViewManager.m b/React/Views/RCTViewManager.m index 737686f799627d..7c538ed2e200bc 100644 --- a/React/Views/RCTViewManager.m +++ b/React/Views/RCTViewManager.m @@ -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) +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) diff --git a/React/Views/UIView+React.h b/React/Views/UIView+React.h index 2f510654244fb5..c4cab30b60a72f 100644 --- a/React/Views/UIView+React.h +++ b/React/Views/UIView+React.h @@ -116,7 +116,6 @@ /** * Accessibility properties */ -@property (nonatomic, copy) NSArray *accessibilityActions; @property (nonatomic, copy) NSString *accessibilityRole; @property (nonatomic, copy) NSArray *accessibilityStates; diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/AccessibilityDelegateUtil.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/AccessibilityDelegateUtil.java deleted file mode 100644 index e5def5725ef020..00000000000000 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/AccessibilityDelegateUtil.java +++ /dev/null @@ -1,244 +0,0 @@ -// Copyright (c) Facebook, Inc. and its affiliates. - -// This source code is licensed under the MIT license found in the -// LICENSE file in the root directory of this source tree. - -package com.facebook.react.uimanager; - -import android.content.Context; -import androidx.core.view.AccessibilityDelegateCompat; -import androidx.core.view.ViewCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat; -import android.text.SpannableString; -import android.text.style.URLSpan; -import androidx.core.view.AccessibilityDelegateCompat; -import androidx.core.view.ViewCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat; -import android.view.View; -import com.facebook.react.bridge.ReadableArray; -import com.facebook.react.R; -import java.util.Locale; -import javax.annotation.Nullable; - -/** - * Utility class that handles the addition of a "role" for accessibility to - * either a View or AccessibilityNodeInfo. - */ - -public class AccessibilityDelegateUtil { - - /** - * These roles are defined by Google's TalkBack screen reader, and this list - * should be kept up to date with their implementation. Details can be seen in - * their source code here: - * - *

- * https://github.com/google/talkback/blob/master/utils/src/main/java/Role.java - */ - - public enum AccessibilityRole { - NONE, BUTTON, LINK, SEARCH, IMAGE, IMAGEBUTTON, KEYBOARDKEY, TEXT, ADJUSTABLE, SUMMARY, HEADER, ALERT, CHECKBOX, - COMBOBOX, MENU, MENUBAR, MENUITEM, PROGRESSBAR, RADIO, RADIOGROUP, SCROLLBAR, SPINBUTTON, - SWITCH, TAB, TABLIST, TIMER, TOOLBAR; - - public static String getValue(AccessibilityRole role) { - switch (role) { - case BUTTON: - return "android.widget.Button"; - case SEARCH: - return "android.widget.EditText"; - case IMAGE: - return "android.widget.ImageView"; - case IMAGEBUTTON: - return "android.widget.ImageButon"; - case KEYBOARDKEY: - return "android.inputmethodservice.Keyboard$Key"; - case TEXT: - return "android.widget.TextView"; - case ADJUSTABLE: - return "android.widget.SeekBar"; - case CHECKBOX: - return "android.widget.CheckBox"; - case RADIO: - return "android.widget.RadioButton"; - case SPINBUTTON: - return "android.widget.SpinButton"; - case SWITCH: - return "android.widget.Switch"; - case NONE: - case LINK: - case SUMMARY: - case HEADER: - case ALERT: - case COMBOBOX: - case MENU: - case MENUBAR: - case MENUITEM: - case PROGRESSBAR: - case RADIOGROUP: - case SCROLLBAR: - case TAB: - case TABLIST: - case TIMER: - case TOOLBAR: - return "android.view.View"; - default: - throw new IllegalArgumentException("Invalid accessibility role value: " + role); - } - } - - public static AccessibilityRole fromValue(@Nullable String value) { - for (AccessibilityRole role : AccessibilityRole.values()) { - if (role.name().equalsIgnoreCase(value)) { - return role; - } - } - throw new IllegalArgumentException("Invalid accessibility role value: " + value); - } - } - - private AccessibilityDelegateUtil() { - // No instances - } - - public static void setDelegate(final View view) { - final AccessibilityRole accessibilityRole = (AccessibilityRole) view.getTag(R.id.accessibility_role); - // if a view already has an accessibility delegate, replacing it could cause - // problems, - // so leave it alone. - if (!ViewCompat.hasAccessibilityDelegate(view) - && (accessibilityRole != null || view.getTag(R.id.accessibility_states) != null)) { - ViewCompat.setAccessibilityDelegate(view, new AccessibilityDelegateCompat() { - @Override - public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) { - super.onInitializeAccessibilityNodeInfo(host, info); - setRole(info, accessibilityRole, view.getContext()); - // states are changable. - ReadableArray accessibilityStates = (ReadableArray) view.getTag(R.id.accessibility_states); - if (accessibilityStates != null) { - setState(info, accessibilityStates, view.getContext()); - } - } - }); - } - } - - public static void setState(AccessibilityNodeInfoCompat info, ReadableArray accessibilityStates, Context context) { - for (int i = 0; i < accessibilityStates.size(); i++) { - String state = accessibilityStates.getString(i); - switch (state) { - case "selected": - info.setSelected(true); - break; - case "disabled": - info.setEnabled(false); - break; - case "checked": - info.setCheckable(true); - info.setChecked(true); - if (info.getClassName().equals("android.widget.Switch")) { - info.setText(context.getString(R.string.state_on_description)); - } - break; - case "unchecked": - info.setCheckable(true); - info.setChecked(false); - if (info.getClassName().equals("android.widget.Switch")) { - info.setText(context.getString(R.string.state_off_description)); - } - break; - case "hasPopup": - info.setCanOpenPopup(true); - break; - } - } - } - - /** - * Strings for setting the Role Description in english - */ - - // TODO: Eventually support for other languages on talkback - - public static void setRole(AccessibilityNodeInfoCompat nodeInfo, AccessibilityRole role, final Context context) { - if (role == null) { - role = AccessibilityRole.NONE; - } - nodeInfo.setClassName(AccessibilityRole.getValue(role)); - if (role.equals(AccessibilityRole.LINK)) { - nodeInfo.setRoleDescription(context.getString(R.string.link_description)); - - if (nodeInfo.getContentDescription() != null) { - SpannableString spannable = new SpannableString(nodeInfo.getContentDescription()); - spannable.setSpan(new URLSpan(""), 0, spannable.length(), 0); - nodeInfo.setContentDescription(spannable); - } - - if (nodeInfo.getText() != null) { - SpannableString spannable = new SpannableString(nodeInfo.getText()); - spannable.setSpan(new URLSpan(""), 0, spannable.length(), 0); - nodeInfo.setText(spannable); - } - } - if (role.equals(AccessibilityRole.SEARCH)) { - nodeInfo.setRoleDescription(context.getString(R.string.search_description)); - } - if (role.equals(AccessibilityRole.IMAGE)) { - nodeInfo.setRoleDescription(context.getString(R.string.image_description)); - } - if (role.equals(AccessibilityRole.IMAGEBUTTON)) { - nodeInfo.setRoleDescription(context.getString(R.string.imagebutton_description)); - nodeInfo.setClickable(true); - } - if (role.equals(AccessibilityRole.SUMMARY)) { - nodeInfo.setRoleDescription(context.getString(R.string.summary_description)); - } - if (role.equals(AccessibilityRole.HEADER)) { - nodeInfo.setRoleDescription(context.getString(R.string.header_description)); - final AccessibilityNodeInfoCompat.CollectionItemInfoCompat itemInfo = - AccessibilityNodeInfoCompat.CollectionItemInfoCompat.obtain(0, 1, 0, 1, true); - nodeInfo.setCollectionItemInfo(itemInfo); - } - if (role.equals(AccessibilityRole.ALERT)) { - nodeInfo.setRoleDescription(context.getString(R.string.alert_description)); - } - if (role.equals(AccessibilityRole.COMBOBOX)) { - nodeInfo.setRoleDescription(context.getString(R.string.combobox_description)); - } - if (role.equals(AccessibilityRole.MENU)) { - nodeInfo.setRoleDescription(context.getString(R.string.menu_description)); - } - if (role.equals(AccessibilityRole.MENUBAR)) { - nodeInfo.setRoleDescription(context.getString(R.string.menubar_description)); - } - if (role.equals(AccessibilityRole.MENUITEM)) { - nodeInfo.setRoleDescription(context.getString(R.string.menuitem_description)); - } - if (role.equals(AccessibilityRole.PROGRESSBAR)) { - nodeInfo.setRoleDescription(context.getString(R.string.progressbar_description)); - } - if (role.equals(AccessibilityRole.RADIOGROUP)) { - nodeInfo.setRoleDescription(context.getString(R.string.radiogroup_description)); - } - if (role.equals(AccessibilityRole.SCROLLBAR)) { - nodeInfo.setRoleDescription(context.getString(R.string.scrollbar_description)); - } - if (role.equals(AccessibilityRole.SPINBUTTON)) { - nodeInfo.setRoleDescription(context.getString(R.string.spinbutton_description)); - } - if (role.equals(AccessibilityRole.TAB)) { - nodeInfo.setRoleDescription(context.getString(R.string.rn_tab_description)); - } - if (role.equals(AccessibilityRole.TABLIST)) { - nodeInfo.setRoleDescription(context.getString(R.string.tablist_description)); - } - if (role.equals(AccessibilityRole.TIMER)) { - nodeInfo.setRoleDescription(context.getString(R.string.timer_description)); - } - if (role.equals(AccessibilityRole.TOOLBAR)) { - nodeInfo.setRoleDescription(context.getString(R.string.toolbar_description)); - } - } -} diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java index bb559a64b61bc3..0d63d86cb1f15b 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java @@ -14,11 +14,14 @@ import com.facebook.react.R; import com.facebook.react.bridge.ReadableArray; -import com.facebook.react.uimanager.AccessibilityDelegateUtil.AccessibilityRole; +import com.facebook.react.common.MapBuilder; +import com.facebook.react.uimanager.ReactAccessibilityDelegate; +import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole; import com.facebook.react.uimanager.annotations.ReactProp; import com.facebook.react.uimanager.util.ReactFindViewUtil; import javax.annotation.Nonnull; +import java.util.Map; import javax.annotation.Nullable; /** @@ -38,6 +41,7 @@ public abstract class BaseViewManager getExportedCustomDirectEventTypeConstants() { + return MapBuilder.builder() + .put("performAction", MapBuilder.of("registrationName", "onAccessibilityAction")) + .build(); + } } diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java new file mode 100644 index 00000000000000..0924dacfc8e8cd --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java @@ -0,0 +1,288 @@ +// Copyright (c) Facebook, Inc. and its affiliates. + +// This source code is licensed under the MIT license found in the +// LICENSE file in the root directory of this source tree. + +package com.facebook.react.uimanager; + +import android.os.Bundle; +import android.content.Context; +import androidx.core.view.AccessibilityDelegateCompat; +import androidx.core.view.ViewCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat; +import android.text.SpannableString; +import android.text.style.URLSpan; +import androidx.core.view.AccessibilityDelegateCompat; +import androidx.core.view.ViewCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat; +import android.view.View; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReactContext; +import com.facebook.react.bridge.ReadableArray; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.events.RCTEventEmitter; +import com.facebook.react.R; + +import java.util.HashMap; +import java.util.Locale; +import javax.annotation.Nullable; + +/** + * Utility class that handles the addition of a "role" for accessibility to + * either a View or AccessibilityNodeInfo. + */ + +public class ReactAccessibilityDelegate extends AccessibilityDelegateCompat { + + private static int sCounter = 0x3f000000; + + public static final HashMap sActionIdMap= new HashMap<>(); + static { + sActionIdMap.put("activate", AccessibilityActionCompat.ACTION_CLICK.getId()); + sActionIdMap.put("longpress", AccessibilityActionCompat.ACTION_LONG_CLICK.getId()); + sActionIdMap.put("increment", AccessibilityActionCompat.ACTION_SCROLL_FORWARD.getId()); + sActionIdMap.put("decrement", AccessibilityActionCompat.ACTION_SCROLL_BACKWARD.getId()); + } + + /** + * These roles are defined by Google's TalkBack screen reader, and this list + * should be kept up to date with their implementation. Details can be seen in + * their source code here: + * + *

+ * https://github.com/google/talkback/blob/master/utils/src/main/java/Role.java + */ + + public enum AccessibilityRole { + NONE, BUTTON, LINK, SEARCH, IMAGE, IMAGEBUTTON, KEYBOARDKEY, TEXT, ADJUSTABLE, SUMMARY, HEADER, ALERT, CHECKBOX, + COMBOBOX, MENU, MENUBAR, MENUITEM, PROGRESSBAR, RADIO, RADIOGROUP, SCROLLBAR, SPINBUTTON, + SWITCH, TAB, TABLIST, TIMER, TOOLBAR; + + public static String getValue(AccessibilityRole role) { + switch (role) { + case BUTTON: + return "android.widget.Button"; + case SEARCH: + return "android.widget.EditText"; + case IMAGE: + return "android.widget.ImageView"; + case IMAGEBUTTON: + return "android.widget.ImageButon"; + case KEYBOARDKEY: + return "android.inputmethodservice.Keyboard$Key"; + case TEXT: + return "android.widget.TextView"; + case ADJUSTABLE: + return "android.widget.SeekBar"; + case CHECKBOX: + return "android.widget.CheckBox"; + case RADIO: + return "android.widget.RadioButton"; + case SPINBUTTON: + return "android.widget.SpinButton"; + case SWITCH: + return "android.widget.Switch"; + case NONE: + case LINK: + case SUMMARY: + case HEADER: + case ALERT: + case COMBOBOX: + case MENU: + case MENUBAR: + case MENUITEM: + case PROGRESSBAR: + case RADIOGROUP: + case SCROLLBAR: + case TAB: + case TABLIST: + case TIMER: + case TOOLBAR: + return "android.view.View"; + default: + throw new IllegalArgumentException("Invalid accessibility role value: " + role); + } + } + + public static AccessibilityRole fromValue(@Nullable String value) { + for (AccessibilityRole role : AccessibilityRole.values()) { + if (role.name().equalsIgnoreCase(value)) { + return role; + } + } + throw new IllegalArgumentException("Invalid accessibility role value: " + value); + } + } + + private final HashMap mAccessibilityActionsMap; + + public ReactAccessibilityDelegate() { + super(); + mAccessibilityActionsMap = new HashMap(); + } + + @Override + public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) { + super.onInitializeAccessibilityNodeInfo(host, info); + final AccessibilityRole accessibilityRole = (AccessibilityRole) host.getTag(R.id.accessibility_role); + if (accessibilityRole != null) { + setRole(info, accessibilityRole, host.getContext()); + } + + // states are changable. + final ReadableArray accessibilityStates = (ReadableArray) host.getTag(R.id.accessibility_states); + if (accessibilityStates != null) { + setState(info, accessibilityStates, host.getContext()); + } + final ReadableArray accessibilityActions = (ReadableArray) host.getTag(R.id.accessibility_actions); + if (accessibilityActions != null) { + for (int i = 0; i < accessibilityActions.size(); i++) { + final ReadableMap action = accessibilityActions.getMap(i); + if (!action.hasKey("name")) { + throw new IllegalArgumentException("Unknown accessibility action."); + } + int actionId = sCounter; + String actionLabel = action.hasKey("label") ? action.getString("label") : null; + if (sActionIdMap.containsKey(action.getString("name"))) { + actionId = sActionIdMap.get(action.getString("name")); + } else { + sCounter++; + } + mAccessibilityActionsMap.put(actionId, action.getString("name")); + final AccessibilityActionCompat accessibilityAction = new AccessibilityActionCompat(actionId, actionLabel); + info.addAction(accessibilityAction); + } + } + } + + @Override + public boolean performAccessibilityAction(View host, int action, Bundle args) { + if (mAccessibilityActionsMap.containsKey(action)) { + final WritableMap event = Arguments.createMap(); + event.putString("actionName", mAccessibilityActionsMap.get(action)); + ReactContext reactContext = (ReactContext)host.getContext(); + reactContext.getJSModule(RCTEventEmitter.class).receiveEvent( + host.getId(), + "performAction", + event); + return true; + } + return super.performAccessibilityAction(host, action, args); + } + + public static void setState(AccessibilityNodeInfoCompat info, ReadableArray accessibilityStates, Context context) { + for (int i = 0; i < accessibilityStates.size(); i++) { + String state = accessibilityStates.getString(i); + switch (state) { + case "selected": + info.setSelected(true); + break; + case "disabled": + info.setEnabled(false); + break; + case "checked": + info.setCheckable(true); + info.setChecked(true); + if (info.getClassName().equals("android.widget.Switch")) { + info.setText(context.getString(R.string.state_on_description)); + } + break; + case "unchecked": + info.setCheckable(true); + info.setChecked(false); + if (info.getClassName().equals("android.widget.Switch")) { + info.setText(context.getString(R.string.state_off_description)); + } + break; + case "hasPopup": + info.setCanOpenPopup(true); + break; + } + } + } + + /** + * Strings for setting the Role Description in english + */ + + // TODO: Eventually support for other languages on talkback + + public static void setRole(AccessibilityNodeInfoCompat nodeInfo, AccessibilityRole role, final Context context) { + if (role == null) { + role = AccessibilityRole.NONE; + } + nodeInfo.setClassName(AccessibilityRole.getValue(role)); + if (role.equals(AccessibilityRole.LINK)) { + nodeInfo.setRoleDescription(context.getString(R.string.link_description)); + + if (nodeInfo.getContentDescription() != null) { + SpannableString spannable = new SpannableString(nodeInfo.getContentDescription()); + spannable.setSpan(new URLSpan(""), 0, spannable.length(), 0); + nodeInfo.setContentDescription(spannable); + } + + if (nodeInfo.getText() != null) { + SpannableString spannable = new SpannableString(nodeInfo.getText()); + spannable.setSpan(new URLSpan(""), 0, spannable.length(), 0); + nodeInfo.setText(spannable); + } + } else if (role.equals(AccessibilityRole.SEARCH)) { + nodeInfo.setRoleDescription(context.getString(R.string.search_description)); + } else if (role.equals(AccessibilityRole.IMAGE)) { + nodeInfo.setRoleDescription(context.getString(R.string.image_description)); + } else if (role.equals(AccessibilityRole.IMAGEBUTTON)) { + nodeInfo.setRoleDescription(context.getString(R.string.imagebutton_description)); + nodeInfo.setClickable(true); + } else if (role.equals(AccessibilityRole.SUMMARY)) { + nodeInfo.setRoleDescription(context.getString(R.string.summary_description)); + } else if (role.equals(AccessibilityRole.HEADER)) { + nodeInfo.setRoleDescription(context.getString(R.string.header_description)); + final AccessibilityNodeInfoCompat.CollectionItemInfoCompat itemInfo = + AccessibilityNodeInfoCompat.CollectionItemInfoCompat.obtain(0, 1, 0, 1, true); + nodeInfo.setCollectionItemInfo(itemInfo); + } else if (role.equals(AccessibilityRole.ALERT)) { + nodeInfo.setRoleDescription(context.getString(R.string.alert_description)); + } else if (role.equals(AccessibilityRole.COMBOBOX)) { + nodeInfo.setRoleDescription(context.getString(R.string.combobox_description)); + } else if (role.equals(AccessibilityRole.MENU)) { + nodeInfo.setRoleDescription(context.getString(R.string.menu_description)); + } else if (role.equals(AccessibilityRole.MENUBAR)) { + nodeInfo.setRoleDescription(context.getString(R.string.menubar_description)); + } else if (role.equals(AccessibilityRole.MENUITEM)) { + nodeInfo.setRoleDescription(context.getString(R.string.menuitem_description)); + } else if (role.equals(AccessibilityRole.PROGRESSBAR)) { + nodeInfo.setRoleDescription(context.getString(R.string.progressbar_description)); + } else if (role.equals(AccessibilityRole.RADIOGROUP)) { + nodeInfo.setRoleDescription(context.getString(R.string.radiogroup_description)); + } else if (role.equals(AccessibilityRole.SCROLLBAR)) { + nodeInfo.setRoleDescription(context.getString(R.string.scrollbar_description)); + } else if (role.equals(AccessibilityRole.SPINBUTTON)) { + nodeInfo.setRoleDescription(context.getString(R.string.spinbutton_description)); + } else if (role.equals(AccessibilityRole.TAB)) { + nodeInfo.setRoleDescription(context.getString(R.string.rn_tab_description)); + } else if (role.equals(AccessibilityRole.TABLIST)) { + nodeInfo.setRoleDescription(context.getString(R.string.tablist_description)); + } else if (role.equals(AccessibilityRole.TIMER)) { + nodeInfo.setRoleDescription(context.getString(R.string.timer_description)); + } else if (role.equals(AccessibilityRole.TOOLBAR)) { + nodeInfo.setRoleDescription(context.getString(R.string.toolbar_description)); + } + } + + public static void setDelegate(final View view) { + // if a view already has an accessibility delegate, replacing it could cause + // problems, + // so leave it alone. + if (!ViewCompat.hasAccessibilityDelegate(view) + && (view.getTag(R.id.accessibility_role) != null || + view.getTag(R.id.accessibility_states) != null || + view.getTag(R.id.accessibility_actions) != null)) { + ViewCompat.setAccessibilityDelegate(view, new ReactAccessibilityDelegate()); + } + } +} diff --git a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index 90989b2b99cdac..0ae5d684021781 100644 --- a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -21,4 +21,7 @@ + + + diff --git a/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.java b/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.java index f0a19d071c8148..db9449bf11c452 100644 --- a/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.java +++ b/ReactAndroid/src/test/java/com/facebook/react/uimanager/BaseViewManagerTest.java @@ -11,7 +11,7 @@ import android.content.Context; import androidx.core.view.ViewCompat; -import com.facebook.react.uimanager.AccessibilityDelegateUtil.AccessibilityRole; +import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole; import com.facebook.react.views.view.ReactViewGroup; import com.facebook.react.views.view.ReactViewManager; import com.facebook.react.R;