From 09dff0c990bbe67029bec01b75d2e1fc35e63737 Mon Sep 17 00:00:00 2001 From: Sharon Gong Date: Thu, 7 Mar 2019 18:10:34 +0000 Subject: [PATCH 1/6] Modify accessibilityActions to be a list of objects rather than a list of strings. --- .../Components/View/ViewAccessibility.js | 15 ++++++++++++ Libraries/Components/View/ViewPropTypes.js | 23 +++++++++++-------- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/Libraries/Components/View/ViewAccessibility.js b/Libraries/Components/View/ViewAccessibility.js index 316ca494b71c..ab23281eecea 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 d0a35c7dac58..861f105b4559 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. * From 3db20269545fea430055877f501e50ff9dd600ab Mon Sep 17 00:00:00 2001 From: Marc Mulcahy Date: Mon, 13 May 2019 15:28:56 -0500 Subject: [PATCH 2/6] iOS support for object-based accessibility actions --- React/Views/RCTView.h | 1 + React/Views/RCTView.m | 82 ++++++++++++++++++++++++++++++------ React/Views/RCTViewManager.m | 2 +- React/Views/UIView+React.h | 1 - 4 files changed, 71 insertions(+), 15 deletions(-) diff --git a/React/Views/RCTView.h b/React/Views/RCTView.h index a63ef765bee8..b6d6f7766823 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 04795ef96ad3..2532e9d3991e 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,19 +353,37 @@ - (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 { return NO; - } + } } - (BOOL)accessibilityPerformMagicTap { - if (_onMagicTap) { + if ([self performAccessibilityAction:@"magicTap"]) { + return YES; + } else if (_onMagicTap) { _onMagicTap(nil); return YES; } else { @@ -349,7 +393,9 @@ - (BOOL)accessibilityPerformMagicTap - (BOOL)accessibilityPerformEscape { - if (_onAccessibilityEscape) { + if ([self performAccessibilityAction:@"escape"]) { + return YES; + } else if (_onAccessibilityEscape) { _onAccessibilityEscape(nil); return YES; } else { @@ -357,6 +403,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 737686f79962..7c538ed2e200 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 2f510654244f..c4cab30b60a7 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; From f04c41b93f3ddfb810bcb0f78a228d0d83e3ce9e Mon Sep 17 00:00:00 2001 From: Sharon Gong Date: Thu, 7 Mar 2019 18:17:19 +0000 Subject: [PATCH 3/6] Add Android support for accessibility actions. --- .../uimanager/AccessibilityDelegateUtil.java | 244 --------------- .../react/uimanager/BaseViewManager.java | 24 +- .../uimanager/ReactAccessibilityDelegate.java | 288 ++++++++++++++++++ .../main/res/views/uimanager/values/ids.xml | 3 + 4 files changed, 313 insertions(+), 246 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/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 e5def5725ef0..000000000000 --- 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 bb559a64b61b..0d63d86cb1f1 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 000000000000..0924dacfc8e8 --- /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 90989b2b99cd..0ae5d6840217 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 @@ + + + From 29473966e599b326f0ae988539f0aff679f52243 Mon Sep 17 00:00:00 2001 From: Sharon Gong Date: Thu, 7 Mar 2019 18:18:01 +0000 Subject: [PATCH 4/6] Add examples of accessibility actions. --- RNTester/js/AccessibilityExample.js | 72 ++++++++++++++++++++++++++ RNTester/js/AccessibilityIOSExample.js | 31 +++++++---- 2 files changed, 93 insertions(+), 10 deletions(-) diff --git a/RNTester/js/AccessibilityExample.js b/RNTester/js/AccessibilityExample.js index 01e442183d6c..c5ae027942e4 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 87a54070a95a..0bcabc91c1c2 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 From aaaa0ba098ea3edeeebae750dcfa80f08488dfb2 Mon Sep 17 00:00:00 2001 From: Christoph Nakazawa Date: Fri, 17 May 2019 09:53:05 +0100 Subject: [PATCH 5/6] Update RCTView.m --- React/Views/RCTView.m | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/React/Views/RCTView.m b/React/Views/RCTView.m index 2532e9d3991e..6463980533f4 100644 --- a/React/Views/RCTView.m +++ b/React/Views/RCTView.m @@ -204,10 +204,10 @@ - (BOOL)didActivateAccessibilityCustomAction:(UIAccessibilityCustomAction *)acti NSDictionary *actionObject = accessibilityActionsLabelMap[action.name]; if (actionObject) { - _onAccessibilityAction(@{ - @"actionName": actionObject[@"name"], - @"actionTarget": self.reactTag - }); + _onAccessibilityAction(@{ + @"actionName": actionObject[@"name"], + @"actionTarget": self.reactTag + }); } return YES; } @@ -369,14 +369,13 @@ - (BOOL)accessibilityActivate { if ([self performAccessibilityAction:@"activate"]) { return YES; - } else if (_onAccessibilityTap) { _onAccessibilityTap(nil); return YES; } else { return NO; - } + } } - (BOOL)accessibilityPerformMagicTap From c86465af6542a31323bd036ef5cb173c99125e08 Mon Sep 17 00:00:00 2001 From: Marc Mulcahy Date: Fri, 17 May 2019 16:51:49 -0500 Subject: [PATCH 6/6] Fix unit test build. --- .../java/com/facebook/react/uimanager/BaseViewManagerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 f0a19d071c81..db9449bf11c4 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;