Skip to content

Commit

Permalink
Override default Talkback automatic content grouping and generate a c…
Browse files Browse the repository at this point in the history
…ustom 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 #31042]. This issue fixes [#31042].

## Changelog

[Android] [Added] - Override default Talkback automatic content grouping and generate a custom contentDescription

Pull Request resolved: #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]: fabOnReact/react-native-notes#14 (comment) "Screenreader correctly announcing accessible/non-accessible items"
[2]: fabOnReact/react-native-notes#14 (comment) "Screenreader announces Pressability items"
[3]: fabOnReact/react-native-notes#14 (comment) "Button role is announced after child contentDescription"
[4]: fabOnReact/react-native-notes#14 (comment) "Testing for regressions in Accessibility Actions"
[5]: fabOnReact/react-native-notes#14 (comment) "Screenreader focuses on ScrollView Items"
[6]: fabOnReact/react-native-notes#14 (comment) "Recordings of complete test cases in rn-tester app main and pr branch"
[7]: fabOnReact/react-native-notes#14 (comment) "TouchableOpacity with child EditText annouces Button role before the child contentDescription"
[8]: fabOnReact/react-native-notes#14 (comment) "TouchableOpacity with child EditText annouces placholder text before and after the role"
[9]: fabOnReact/react-native-notes#14 (comment) "TouchableOpacity with TextInput child announces contentDescription before the Role"
[10]: fabOnReact/react-native-notes#14 (comment) "One of the child has accessibilityState (hasStateDescription triggers the announcement)"
[11]: fabOnReact/react-native-notes#14 (comment) "One of the child has accessibilityHint (hasText triggers the announcement)"

[15]: fabOnReact/react-native-notes#14 (comment) "The View does not announce the child component Text when accessibilityLabel is missing (automatic content grouping)"

[50]: #31042 "Android: Role description is announced before the components text, rather than after #31042"
[51]: fabOnReact/react-native-notes#14 "notes on Android: Role description is announced before the components text, rather than after #31042"

Reviewed By: cipolleschi

Differential Revision: D39177512

Pulled By: blavalla

fbshipit-source-id: 6bd0fba9c347bc14b3839e903184c86d2bcab3d2
  • Loading branch information
fabOnReact authored and facebook-github-bot committed Dec 2, 2022
1 parent fb84e09 commit 759056b
Show file tree
Hide file tree
Showing 2 changed files with 455 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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<>();

Expand Down Expand Up @@ -356,6 +362,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
Expand Down Expand Up @@ -767,4 +784,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.
*
* <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();
}
}

0 comments on commit 759056b

Please sign in to comment.