-
Notifications
You must be signed in to change notification settings - Fork 24.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Override default Talkback automatic content grouping and generate a custom contentDescription #33690
Override default Talkback automatic content grouping and generate a custom contentDescription #33690
Changes from all commits
05a2bc3
4ffd15d
5e1622f
28f86a3
963cd17
69473de
bdabc4c
ae66001
7c287a4
6d73cd4
2a3a836
982fb20
e0e1629
a22c435
07bac60
821e623
35190c0
986b10b
28ed277
e1521cf
90dd4a8
5e7aaf3
1f06997
a05b912
64bb48a
7cf8fce
0b620e0
45e3040
5488aba
751b83e
e3edefb
a0fb221
d2c8bfb
334b24d
9d72a70
9d1bf23
abed89e
b90fff4
f1852f1
6bd06c4
2dc702e
8622c13
e4b31c3
251df27
8aa5b25
b804bd0
f77a4a2
44122ff
cb0e1b6
0c79fc8
46826d8
ad6732a
d5f4c9c
c17f00e
087e642
51f23f0
8b84521
dc0cc5a
eac460e
ddfb345
11c4b00
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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<String, Integer> 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. | ||
* | ||
* <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; | ||
} | ||
|
||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should check for isScreenReaderFocusable here as well. See Talkback's implementation here for reference: There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I added the check isScreenReaderFocusable in isAccessibilityFocusable. The method is used by:
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @blavalla I added the check with commit 45e3040. https://developer.android.com/reference/android/view/View#setScreenReaderFocusable(boolean)
https://developer.android.com/reference/android/view/View#attr_android:focusable
https://github.com/facebook/react-native/pull/24359/files#r288296027 clickable was renamed to focusable
|
||
// 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(); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is missing some features that newer versions of Talkback take into account, such as stateDescription.
Check out Talkback's own implementation here for reference:
https://github.com/google/talkback/blob/6c0b475b7f52469e309e51bfcc13de58f18176ff/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java#L905
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A node with stateDescription is announced with TalkBack screenreader. The check on hasStateDescription is added to isSpeakingNode. isSpeakingNode will return true for nodes that have a stateDescription.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I noticed that getStateDescription returns null even when adding accessibilityState. Calling setStateDescription will update the state description, which is not set via the react accessibilityState or other props.
Seems that the method setViewState does not call setStateDescription.
I will further investigate tomorrow.
View#setStateDescription
This is the implementation of TalkBack hasStateDescription.
The following fields of the view are set with the accessibilityState:
setCheckable
https://github.com/aosp-mirror/platform_frameworks_base/blob/19e53cfdc8a5c6ef45c0adf2dd239576ddce5822/core/java/android/view/accessibility/AccessibilityNodeInfo.java#L2008
setEnabled
https://github.com/aosp-mirror/platform_frameworks_base/blob/19e53cfdc8a5c6ef45c0adf2dd239576ddce5822/core/java/android/view/accessibility/AccessibilityNodeInfo.java#L2227
The implementation of
hasStateDescription
is still validThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One of the child has accessibilityState (hasStateDescription triggers the announcement)
@blavalla The below test case was recorded with the example included in commit fabOnReact@c17f00e
#33690 (comment)
The test verifies that:
as explained in comment #33690 (comment)
View#onInitializeAccessibilityNodeInfoInternal
Seems that the method setViewState does not call setStateDescription.
sourcecode from test case
first video test
XRecorder_08062022_132543.mp4
verifying that nodes with accessibilityState.checkable are added to the talkback announcement (enable audio for explanation)
2022-08-11.21-34-01.mp4
verifying that nodes without accessibilityState.checkable are NOT added to the talkback announcement (enable audio for explanation)
2022-08-11.21-31-06.mp4