diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java index 1c635887924eaf..e1daad5fea0c91 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java @@ -16,10 +16,13 @@ import android.text.Layout; import android.text.Spannable; import android.text.Spanned; +import android.text.TextUtils; import android.text.style.AbsoluteSizeSpan; import android.text.style.ClickableSpan; import android.view.View; +import android.view.ViewGroup; import android.view.accessibility.AccessibilityEvent; +import android.widget.EditText; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -41,6 +44,7 @@ import com.facebook.react.bridge.ReadableType; import com.facebook.react.bridge.UIManager; import com.facebook.react.bridge.WritableMap; +import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole; import com.facebook.react.uimanager.events.Event; import com.facebook.react.uimanager.events.EventDispatcher; import com.facebook.react.uimanager.util.ReactFindViewUtil; @@ -59,6 +63,8 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper { private static int sCounter = 0x3f000000; private static final int TIMEOUT_SEND_ACCESSIBILITY_EVENT = 200; private static final int SEND_EVENT = 1; + private static final String delimiter = ", "; + private static final int delimiterLength = delimiter.length(); public static final HashMap sActionIdMap = new HashMap<>(); @@ -319,6 +325,17 @@ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCo if (testId != null) { info.setViewIdResourceName(testId); } + boolean missingContentDescription = TextUtils.isEmpty(info.getContentDescription()); + boolean missingText = TextUtils.isEmpty(info.getText()); + boolean missingTextAndDescription = missingContentDescription && missingText; + boolean hasContentToAnnounce = + accessibilityActions != null + || accessibilityState != null + || accessibilityLabelledBy != null + || accessibilityRole != null; + if (missingTextAndDescription && hasContentToAnnounce) { + info.setContentDescription(getTalkbackDescription(host, info)); + } } @Override @@ -726,4 +743,295 @@ private static class AccessibleLink { return null; } + + /** + * Determines if the supplied {@link View} and {@link AccessibilityNodeInfoCompat} has any + * children which are not independently accessibility focusable and also have a spoken + * description. + * + *

NOTE: Accessibility services will include these children's descriptions in the closest + * focusable ancestor. + * + * @param view The {@link View} to evaluate + * @param node The {@link AccessibilityNodeInfoCompat} to evaluate + * @return {@code true} if it has any non-actionable speaking descendants within its subtree + */ + public static boolean hasNonActionableSpeakingDescendants( + @Nullable AccessibilityNodeInfoCompat node, @Nullable View view) { + + if (node == null || view == null || !(view instanceof ViewGroup)) { + return false; + } + + final ViewGroup viewGroup = (ViewGroup) view; + for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) { + final View childView = viewGroup.getChildAt(i); + + if (childView == null) { + continue; + } + + final AccessibilityNodeInfoCompat childNode = AccessibilityNodeInfoCompat.obtain(); + try { + ViewCompat.onInitializeAccessibilityNodeInfo(childView, childNode); + + if (!childNode.isVisibleToUser()) { + continue; + } + + if (isAccessibilityFocusable(childNode, childView)) { + continue; + } + + if (isSpeakingNode(childNode, childView)) { + return true; + } + } finally { + if (childNode != null) { + childNode.recycle(); + } + } + } + + return false; + } + + /** + * Returns whether the node has valid RangeInfo. + * + * @param node The node to check. + * @return Whether the node has valid RangeInfo. + */ + public static boolean hasValidRangeInfo(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + + @Nullable final RangeInfoCompat rangeInfo = node.getRangeInfo(); + if (rangeInfo == null) { + return false; + } + + final float maxProgress = rangeInfo.getMax(); + final float minProgress = rangeInfo.getMin(); + final float currentProgress = rangeInfo.getCurrent(); + final float diffProgress = maxProgress - minProgress; + return (diffProgress > 0.0f) + && (currentProgress >= minProgress) + && (currentProgress <= maxProgress); + } + + /** + * Returns whether the specified node has state description. + * + * @param node The node to check. + * @return {@code true} if the node has state description. + */ + private static boolean hasStateDescription(@Nullable AccessibilityNodeInfoCompat node) { + return node != null + && (!TextUtils.isEmpty(node.getStateDescription()) + || node.isCheckable() + || hasValidRangeInfo(node)); + } + + /** + * Returns whether the supplied {@link View} and {@link AccessibilityNodeInfoCompat} would produce + * spoken feedback if it were accessibility focused. NOTE: not all speaking nodes are focusable. + * + * @param view The {@link View} to evaluate + * @param node The {@link AccessibilityNodeInfoCompat} to evaluate + * @return {@code true} if it meets the criterion for producing spoken feedback + */ + public static boolean isSpeakingNode( + @Nullable AccessibilityNodeInfoCompat node, @Nullable View view) { + if (node == null || view == null) { + return false; + } + + final int important = ViewCompat.getImportantForAccessibility(view); + if (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + || (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO && node.getChildCount() <= 0)) { + return false; + } + + return hasText(node) + || hasStateDescription(node) + || node.isCheckable() + || hasNonActionableSpeakingDescendants(node, view); + } + + public static boolean hasText(@Nullable AccessibilityNodeInfoCompat node) { + return node != null + && node.getCollectionInfo() == null + && (!TextUtils.isEmpty(node.getText()) + || !TextUtils.isEmpty(node.getContentDescription()) + || !TextUtils.isEmpty(node.getHintText())); + } + + /** + * Determines if the provided {@link View} and {@link AccessibilityNodeInfoCompat} meet the + * criteria for gaining accessibility focus. + * + *

Note: this is evaluating general focusability by accessibility services, and does not mean + * this view will be guaranteed to be focused by specific services such as Talkback. For Talkback + * focusability, see {@link #isTalkbackFocusable(View)} + * + * @param view The {@link View} to evaluate + * @param node The {@link AccessibilityNodeInfoCompat} to evaluate + * @return {@code true} if it is possible to gain accessibility focus + */ + public static boolean isAccessibilityFocusable( + @Nullable AccessibilityNodeInfoCompat node, @Nullable View view) { + if (node == null || view == null) { + return false; + } + + // Never focus invisible nodes. + if (!node.isVisibleToUser()) { + return false; + } + + // Always focus "actionable" nodes. + return node.isScreenReaderFocusable() || isActionableForAccessibility(node); + } + + /** + * Returns whether a node is actionable. That is, the node supports one of {@link + * AccessibilityNodeInfoCompat#isClickable()}, {@link AccessibilityNodeInfoCompat#isFocusable()}, + * or {@link AccessibilityNodeInfoCompat#isLongClickable()}. + * + * @param node The {@link AccessibilityNodeInfoCompat} to evaluate + * @return {@code true} if node is actionable. + */ + public static boolean isActionableForAccessibility(@Nullable AccessibilityNodeInfoCompat node) { + if (node == null) { + return false; + } + + if (node.isClickable() || node.isLongClickable() || node.isFocusable()) { + return true; + } + + final List actionList = node.getActionList(); + return actionList.contains(AccessibilityNodeInfoCompat.ACTION_CLICK) + || actionList.contains(AccessibilityNodeInfoCompat.ACTION_LONG_CLICK) + || actionList.contains(AccessibilityNodeInfoCompat.ACTION_FOCUS); + } + + /** + * Returns a cached instance if such is available otherwise a new one. + * + * @param view The {@link View} to derive the AccessibilityNodeInfo properties from. + * @return {@link FlipperObject} containing the properties. + */ + @Nullable + public static AccessibilityNodeInfoCompat createNodeInfoFromView(View view) { + if (view == null) { + return null; + } + + final AccessibilityNodeInfoCompat nodeInfo = AccessibilityNodeInfoCompat.obtain(); + + // For some unknown reason, Android seems to occasionally throw a NPE from + // onInitializeAccessibilityNodeInfo. + try { + ViewCompat.onInitializeAccessibilityNodeInfo(view, nodeInfo); + } catch (NullPointerException e) { + if (nodeInfo != null) { + nodeInfo.recycle(); + } + return null; + } + + return nodeInfo; + } + + /** + * Creates the text that Google's TalkBack screen reader will read aloud for a given {@link View}. + * This may be any combination of the {@link View}'s {@code text}, {@code contentDescription}, and + * the {@code text} and {@code contentDescription} of any ancestor {@link View}. + * + *

This description is generally ported over from Google's TalkBack screen reader, and this + * should be kept up to date with their implementation (as much as necessary). Details can be seen + * in their source code here: + * + *

https://github.com/google/talkback/compositor/src/main/res/raw/compositor.json - search for + * "get_description_for_tree", "append_description_for_tree", "description_for_tree_nodes" + * + * @param view The {@link View} to evaluate. + * @param info The default {@link AccessibilityNodeInfoCompat}. + * @return {@code String} representing what talkback will say when a {@link View} is focused. + */ + @Nullable + public static CharSequence getTalkbackDescription( + View view, @Nullable AccessibilityNodeInfoCompat info) { + final AccessibilityNodeInfoCompat node = + info == null ? createNodeInfoFromView(view) : AccessibilityNodeInfoCompat.obtain(info); + + if (node == null) { + return null; + } + try { + final CharSequence contentDescription = node.getContentDescription(); + final CharSequence nodeText = node.getText(); + + final boolean hasNodeText = !TextUtils.isEmpty(nodeText); + final boolean isEditText = view instanceof EditText; + + StringBuilder talkbackSegments = new StringBuilder(); + + // EditText's prioritize their own text content over a contentDescription so skip this + if (!TextUtils.isEmpty(contentDescription) && (!isEditText || !hasNodeText)) { + // next add content description + talkbackSegments.append(contentDescription); + return talkbackSegments; + } + + // EditText + if (hasNodeText) { + // skipped status checks above for EditText + + // description + talkbackSegments.append(nodeText); + return talkbackSegments; + } + + // If there are child views and no contentDescription the text of all non-focusable children, + // comma separated, becomes the description. + if (view instanceof ViewGroup) { + final StringBuilder concatChildDescription = new StringBuilder(); + final ViewGroup viewGroup = (ViewGroup) view; + + for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) { + final View child = viewGroup.getChildAt(i); + + final AccessibilityNodeInfoCompat childNodeInfo = AccessibilityNodeInfoCompat.obtain(); + ViewCompat.onInitializeAccessibilityNodeInfo(child, childNodeInfo); + + if (isSpeakingNode(childNodeInfo, child) + && !isAccessibilityFocusable(childNodeInfo, child)) { + CharSequence childNodeDescription = getTalkbackDescription(child, null); + if (!TextUtils.isEmpty(childNodeDescription)) { + concatChildDescription.append(childNodeDescription + delimiter); + } + } + childNodeInfo.recycle(); + } + + return removeFinalDelimiter(concatChildDescription); + } + + return null; + } finally { + node.recycle(); + } + } + + private static String removeFinalDelimiter(StringBuilder builder) { + int end = builder.length(); + if (end > 0) { + builder.delete(end - delimiterLength, end); + } + return builder.toString(); + } } diff --git a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js index e3c6e333300ea5..bbe0143ac5a1b7 100644 --- a/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js +++ b/packages/rn-tester/js/examples/Accessibility/AccessibilityExample.js @@ -22,6 +22,7 @@ const { Text, View, TouchableOpacity, + TouchableNativeFeedback, TouchableWithoutFeedback, Alert, StyleSheet, @@ -69,6 +70,11 @@ const styles = StyleSheet.create({ flexDirection: 'column', justifyContent: 'space-between', }, + smallRedSquare: { + backgroundColor: 'red', + height: 40, + width: 40, + }, container: { flex: 1, }, @@ -244,6 +250,141 @@ class AccessibilityExample extends React.Component<{}> { } } +class AutomaticContentGrouping extends React.Component<{}> { + render(): React.Node { + return ( + + + + + + Text number 1 with a role + + Text number 2 + + + + + + { + 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; + } + }} + accessibilityRole="button"> + + Text number 1 + + Text number 2Text number 3 + + + + + + + + + Text number 1 + + + + + + + + + Text number 1 + console.warn('onPress child')} + accessible={false} + accessibilityLabel="this is my label" + accessibilityRole="image" + accessibilityState={{disabled: true}} + accessibilityValue={{text: 'this is the accessibility value'}}> + Text number 3 + + + + + + + + + + + + + + + Text number 2 + + Text number 3Text number 4 + + + + + + + console.warn('onPress child')} + accessible={true} + accessibilityRole="button"> + + + + + + + + + + + + + ); + } +} + class CheckboxExample extends React.Component< {}, { @@ -1266,6 +1407,12 @@ exports.examples = [ return ; }, }, + { + title: 'Automatic Content Grouping', + render(): React.Element { + return ; + }, + }, { title: 'New accessibility roles and states', render(): React.Element {