Additional Accessibility Roles and States (#24095)

Summary:
Assistive technologies use the accessibility role of a component to tell the disabled user what the component is, and provide hints about how to use it. Many important roles do not have analog AccessibilityTraits on iOS. This PR adds many critical roles, such as editabletext, checkbox, menu, and switch to name a few.

Accessibility states are used to convey the current state of a component. This PR adds several critical states such as checked, unchecked, on and off.

[general] [change] - Adds critical accessibility roles and states.
Pull Request resolved: https://github.com/facebook/react-native/pull/24095

Differential Revision: D15079245

Pulled By: cpojer

fbshipit-source-id: 941b30eb8f5d565597e5ea3a04687d9809cbe372
This commit is contained in:
Marc Mulcahy
2019-04-25 06:13:07 -07:00
committed by Facebook Github Bot
parent 421ffb05ae
commit 1aeac1c625
10 changed files with 743 additions and 120 deletions
@@ -17,63 +17,75 @@ 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.
* 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:
* 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:
*
* <p>https://github.com/google/talkback/blob/master/utils/src/main/java/Role.java
* <p>
* 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;
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 NONE:
return null;
case BUTTON:
return "android.widget.Button";
case LINK:
return "android.widget.ViewGroup";
case SEARCH:
return "android.widget.EditText";
case IMAGE:
return "android.widget.ImageView";
case IMAGEBUTTON:
return "android.widget.ImageView";
case KEYBOARDKEY:
return "android.inputmethodservice.Keyboard$Key";
case TEXT:
return "android.widget.ViewGroup";
case ADJUSTABLE:
return "android.widget.SeekBar";
case SUMMARY:
return "android.widget.ViewGroup";
case HEADER:
return "android.widget.ViewGroup";
default:
throw new IllegalArgumentException("Invalid accessibility role value: " + 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);
}
}
@@ -92,32 +104,55 @@ public class AccessibilityDelegateUtil {
}
public static void setDelegate(final View view) {
final String accessibilityHint = (String) view.getTag(R.id.accessibility_hint);
final AccessibilityRole accessibilityRole = (AccessibilityRole) view.getTag(R.id.accessibility_role);
// if a view already has an accessibility delegate, replacing it could cause problems,
// if a view already has an accessibility delegate, replacing it could cause
// problems,
// so leave it alone.
if (!ViewCompat.hasAccessibilityDelegate(view) &&
(accessibilityHint != null || accessibilityRole != null)) {
ViewCompat.setAccessibilityDelegate(
view,
new AccessibilityDelegateCompat() {
@Override
public void onInitializeAccessibilityNodeInfo(
View host, AccessibilityNodeInfoCompat info) {
super.onInitializeAccessibilityNodeInfo(host, info);
if (!(accessibilityHint == null)) {
String contentDescription=(String)info.getContentDescription();
if (contentDescription != null) {
contentDescription = contentDescription + ", " + accessibilityHint;
info.setContentDescription(contentDescription);
} else {
info.setContentDescription(accessibilityHint);
}
}
setRole(info, accessibilityRole, view.getContext());
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;
}
}
}
@@ -125,50 +160,85 @@ public class AccessibilityDelegateUtil {
* Strings for setting the Role Description in english
*/
//TODO: Eventually support for other languages on talkback
// 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 (Locale.getDefault().getLanguage().equals(new Locale("en").getLanguage())) {
if (role.equals(AccessibilityRole.LINK)) {
nodeInfo.setRoleDescription(context.getString(R.string.link_description));
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.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.image_button_description));
}
if (role.equals(AccessibilityRole.ADJUSTABLE)) {
nodeInfo.setRoleDescription(context.getString(R.string.adjustable_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 (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));
}
}
}
@@ -9,6 +9,9 @@ import android.graphics.Color;
import android.view.View;
import android.view.ViewParent;
import androidx.core.view.ViewCompat;
import java.util.HashMap;
import com.facebook.react.R;
import com.facebook.react.bridge.ReadableArray;
import com.facebook.react.uimanager.AccessibilityDelegateUtil.AccessibilityRole;
@@ -57,6 +60,13 @@ public abstract class BaseViewManager<T extends View, C extends LayoutShadowNode
new MatrixMathHelper.MatrixDecompositionContext();
private static double[] sTransformDecompositionArray = new double[16];
public static final HashMap<String, Integer> sStateDescription= new HashMap<String, Integer>();
static {
sStateDescription.put("busy", R.string.state_busy_description);
sStateDescription.put("expanded", R.string.state_expanded_description);
sStateDescription.put("collapsed", R.string.state_collapsed_description);
}
@ReactProp(name = PROP_BACKGROUND_COLOR, defaultInt = Color.TRANSPARENT, customType = "Color")
public void setBackgroundColor(@Nonnull T view, int backgroundColor) {
view.setBackgroundColor(backgroundColor);
@@ -112,12 +122,14 @@ public abstract class BaseViewManager<T extends View, C extends LayoutShadowNode
@ReactProp(name = PROP_ACCESSIBILITY_LABEL)
public void setAccessibilityLabel(@Nonnull T view, String accessibilityLabel) {
view.setContentDescription(accessibilityLabel);
view.setTag(R.id.accessibility_label, accessibilityLabel);
updateViewContentDescription(view);
}
@ReactProp(name = PROP_ACCESSIBILITY_HINT)
public void setAccessibilityHint(@Nonnull T view, String accessibilityHint) {
view.setTag(R.id.accessibility_hint, accessibilityHint);
updateViewContentDescription(view);
}
@ReactProp(name = PROP_ACCESSIBILITY_ROLE)
@@ -125,27 +137,47 @@ public abstract class BaseViewManager<T extends View, C extends LayoutShadowNode
if (accessibilityRole == null) {
return;
}
view.setTag(R.id.accessibility_role, AccessibilityRole.fromValue(accessibilityRole));
}
@ReactProp(name = PROP_ACCESSIBILITY_STATES)
public void setViewStates(@Nonnull T view, @Nullable ReadableArray accessibilityStates) {
view.setSelected(false);
view.setEnabled(true);
if (accessibilityStates == null) {
return;
}
view.setTag(R.id.accessibility_states, accessibilityStates);
for (int i = 0; i < accessibilityStates.size(); i++) {
String state = accessibilityStates.getString(i);
if (state.equals("selected")) {
view.setSelected(true);
} else if (state.equals("disabled")) {
view.setEnabled(false);
if (sStateDescription.containsKey(state)) {
updateViewContentDescription(view);
}
}
}
private void updateViewContentDescription(@Nonnull T view) {
final String accessibilityLabel = (String) view.getTag(R.id.accessibility_label);
final ReadableArray accessibilityStates = (ReadableArray) view.getTag(R.id.accessibility_states);
final String accessibilityHint = (String) view.getTag(R.id.accessibility_hint);
StringBuilder contentDescription = new StringBuilder();
if (accessibilityLabel != null) {
contentDescription.append(accessibilityLabel + ", ");
}
if (accessibilityStates != null) {
for (int i = 0; i < accessibilityStates.size(); i++) {
String state = accessibilityStates.getString(i);
if (sStateDescription.containsKey(state)) {
contentDescription.append(view.getContext().getString(sStateDescription.get(state)) + ", ");
}
}
}
if (accessibilityHint != null) {
contentDescription.append(accessibilityHint + ", ");
}
if (contentDescription.length() > 0) {
view.setContentDescription(contentDescription.toString());
}
}
@ReactProp(name = PROP_IMPORTANT_FOR_ACCESSIBILITY)
public void setImportantForAccessibility(@Nonnull T view, @Nullable String importantForAccessibility) {
if (importantForAccessibility == null || importantForAccessibility.equals("auto")) {