mirror of
https://github.com/facebook/react-native.git
synced 2025-11-01 09:14:26 +00:00
Override default Talkback automatic content grouping and generate a custom contentDescription (#33690)
Summary: The Implementation of the functionality consists of: 1) Checking that an element has no contentDescription and no text and has other content to announce (role, state, etc.) which causes this issue (for ex. the accessibilityRole is announced before the contentDescription for ex. "Button, My text children component") 2) If Talkback finds no content to announce on the current node, a custom contentDescription is generated from the child elements that respect the following conditions: >If an AccessibilityNodeInfo is considered "actionable" (which Talkback defines as having clickable=true, longClickable=true, or focusable=true, or having AccessibilityActions for any of those), AND it has some content to read like a contentDescription or text, it will be considered focusable. >If an AccessibilityNodeInfo is considered "actionable" AND it does not have content to read like a contentDescription or text Talkback will parse descendant elements looking for non-focusable descendants to use as content. 3) implementation of a method getTalkbackDescription to generate the above contentDescription from child elements 4) over-ride parent contentDescription (accessibilityLabel) with the value returned from getTalkbackDescription Related [notes on Android: Role description is announced before the components text, rather than after https://github.com/facebook/react-native/issues/31042][51]. This issue fixes [https://github.com/facebook/react-native/issues/31042][50]. ## Changelog [Android] [Added] - Override default Talkback automatic content grouping and generate a custom contentDescription Pull Request resolved: https://github.com/facebook/react-native/pull/33690 Test Plan: **PR Branch** [1]. Screenreader correctly announcing accessible/non-accessible items ([link][1]) [2]. Screenreader announces Pressability items ([link][2]) [3]. Button role is announced after child contentDescription with TouchableNativeFeedback ([link][3]) [4]. Testing for regressions in Accessibility Actions ([link][4]) [5]. Screenreader focuses on ScrollView Items ([link][5]) [6]. Recordings of complete test cases in rn-tester app main and pr branch ([link][6]) [9]. TouchableOpacity with TextInput child announces contentDescription before the Role ([link][9]) [10]. One of the child has accessibilityState (hasStateDescription triggers the announcement) ([link][10]) [11]. One of the child has accessibilityHint (hasText triggers the announcement) ([link][11]) **Main** [15]. The View does not announce the child component Text when accessibilityLabel is missing (automatic content grouping) ([link][15]) [8]. TouchableOpacity with child EditText annouces placeholder text before and after the role ([link][8]) [1]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/14#issuecomment-1106375974 "Screenreader correctly announcing accessible/non-accessible items" [2]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/14#issuecomment-1106213313 "Screenreader announces Pressability items" [3]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/14#issuecomment-1109935139 "Button role is announced after child contentDescription" [4]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/14#issuecomment-1110541392 "Testing for regressions in Accessibility Actions" [5]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/14#issuecomment-1106012823 "Screenreader focuses on ScrollView Items" [6]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/14#issuecomment-1111942775 "Recordings of complete test cases in rn-tester app main and pr branch" [7]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/14#issuecomment-1139511589 "TouchableOpacity with child EditText annouces Button role before the child contentDescription" [8]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/14#issuecomment-1140967225 "TouchableOpacity with child EditText annouces placholder text before and after the role" [9]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/14#issuecomment-1140999648 "TouchableOpacity with TextInput child announces contentDescription before the Role" [10]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/14#issuecomment-1149479950 "One of the child has accessibilityState (hasStateDescription triggers the announcement)" [11]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/14#issuecomment-1149535215 "One of the child has accessibilityHint (hasText triggers the announcement)" [15]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/14#issuecomment-1088680339 "The View does not announce the child component Text when accessibilityLabel is missing (automatic content grouping)" [50]: https://github.com/facebook/react-native/issues/31042 "Android: Role description is announced before the components text, rather than after https://github.com/facebook/react-native/issues/31042" [51]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/14 "notes on Android: Role description is announced before the components text, rather than after https://github.com/facebook/react-native/issues/31042" Reviewed By: cipolleschi Differential Revision: D39177512 Pulled By: blavalla fbshipit-source-id: 6bd0fba9c347bc14b3839e903184c86d2bcab3d2
This commit is contained in:
1 parent
fb84e092cf
commit
759056b499
2 files changed
+455
-6
No files matched your search
+308
@@ -16,10 +16,13 @@ import android.os.Message;
|
||||
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.ReadableMapKeySetIterator;
|
||||
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<String, Integer> sActionIdMap = new HashMap<>();
|
||||
|
||||
@@ -356,6 +362,17 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
|
||||
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
|
||||
@@ -767,4 +784,295 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
|
||||
|
||||
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.
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>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}.
|
||||
*
|
||||
* <p>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:
|
||||
*
|
||||
* <p>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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user