Skip to content

Commit 105a239

Browse files
fabOnReactfacebook-github-bot
authored andcommitted
TalkBack support for ScrollView accessibility announcements (list and grid) - JAVA ONLY CHANGES (#33180)
Summary: This is the Java-only changes from D34518929 (dd6325b), split out for push safety. Original summary and test plan below: This issue fixes [30977][17] . The Pull Request was previously published by [intergalacticspacehighway][13] with [31666][19]. The solution consists of: 1. Adding Javascript logic in the [FlatList][14], SectionList, VirtualizedList components to provide accessibility information (row and column position) for each cell in the method [renderItem][20] as a fourth parameter [accessibilityCollectionItem][21]. The information is saved on the native side in the AccessibilityNodeInfo and announced by TalkBack when changing row, column, or page ([video example][12]). The prop accessibilityCollectionItem is available in the View component which wraps each FlatList cell. 2. Adding Java logic in [ReactScrollView.java][16] and HorizontalScrollView to announce pages with TalkBack when scrolling up/down. The missing AOSP logic in [ScrollView.java][10] (see also the [GridView][11] example) is responsible for announcing Page Scrolling with TalkBack. Relevant Links: x [Additional notes on this PR][18] x [discussion on the additional container View around each FlatList cell][22] x [commit adding prop getCellsInItemCount to VirtualizedList][23] ## Changelog [Android] [Added] - Accessibility announcement for list and grid in FlatList Pull Request resolved: #33180 Test Plan: [1]. TalkBack announces pages and cells with Horizontal Flatlist in the Paper Renderer ([link][1]) [2]. TalkBack announces pages and cells with Vertical Flatlist in the Paper Renderer ([link][2]) [3]. `FlatList numColumns={undefined}` Should not trigger Runtime Error NoSuchKey exception columnCount when enabling TalkBack. ([link][3]) [4]. TalkBack announces pages and cells with Nested Horizontal Flatlist in the rn-tester app ([link][4]) [1]: fabOnReact/react-native-notes#6 (comment) [2]: fabOnReact/react-native-notes#6 (comment) [3]: fabOnReact/react-native-notes#6 (comment) [4]: fabOnReact/react-native-notes#6 (comment) [10]:https://github.com/aosp-mirror/platform_frameworks_base/blob/1ac46f932ef88a8f96d652580d8105e361ffc842/core/java/android/widget/AdapterView.java#L1027-L1029 "GridView.java method responsible for calling setFromIndex and setToIndex" [11]:fabOnReact/react-native-notes#6 (comment) "test case on Android GridView" [12]:fabOnReact/react-native-notes#6 (comment) "TalkBack announces pages and cells with Horizontal Flatlist in the Paper Renderer" [13]:https://github.com/intergalacticspacehighway "github intergalacticspacehighway" [14]:https://github.com/fabriziobertoglio1987/react-native/blob/80acf523a4410adac8005d5c9472fb87f78e12ee/Libraries/Lists/FlatList.js#L617-L636 "FlatList accessibilityCollectionItem" [16]:https://github.com/fabriziobertoglio1987/react-native/blob/5706bd7d3ee35dca48f85322a2bdcaec0bce2c85/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java#L183-L184 "logic added to ReactScrollView.java" [17]: #30977 [18]: fabOnReact/react-native-notes#6 [19]: #31666 [20]: https://reactnative.dev/docs/next/flatlist#required-renderitem "FlatList renderItem documentation" [21]: fabOnReact@7514735 "commit that introduces fourth param accessibilityCollectionItem in callback renderItem" [22]: #33180 (comment) "discussion on the additional container View around each FlatList cell" [23]: fabOnReact@d50fd1a "commit adding prop getCellsInItemCount to VirtualizedList" Reviewed By: kacieb Differential Revision: D37186697 Pulled By: blavalla fbshipit-source-id: 7bb95274326ded417c6f1365cc8633391f589d1a
1 parent 8cf57a5 commit 105a239

10 files changed

Lines changed: 240 additions & 20 deletions

File tree

ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManager.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,20 @@ public void setAccessibilityRole(@NonNull T view, @Nullable String accessibility
249249
view.setTag(R.id.accessibility_role, AccessibilityRole.fromValue(accessibilityRole));
250250
}
251251

252+
@Override
253+
@ReactProp(name = ViewProps.ACCESSIBILITY_COLLECTION)
254+
public void setAccessibilityCollection(
255+
@NonNull T view, @Nullable ReadableMap accessibilityCollection) {
256+
view.setTag(R.id.accessibility_collection, accessibilityCollection);
257+
}
258+
259+
@Override
260+
@ReactProp(name = ViewProps.ACCESSIBILITY_COLLECTION_ITEM)
261+
public void setAccessibilityCollectionItem(
262+
@NonNull T view, @Nullable ReadableMap accessibilityCollectionItem) {
263+
view.setTag(R.id.accessibility_collection_item, accessibilityCollectionItem);
264+
}
265+
252266
@Override
253267
@ReactProp(name = ViewProps.ACCESSIBILITY_STATE)
254268
public void setViewState(@NonNull T view, @Nullable ReadableMap accessibilityState) {

ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerAdapter.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ public void setAccessibilityLiveRegion(@NonNull T view, @Nullable String liveReg
3131
@Override
3232
public void setAccessibilityRole(@NonNull T view, @Nullable String accessibilityRole) {}
3333

34+
@Override
35+
public void setAccessibilityCollection(
36+
@NonNull T view, @Nullable ReadableMap accessibilityCollection) {}
37+
38+
@Override
39+
public void setAccessibilityCollectionItem(
40+
@NonNull T view, @Nullable ReadableMap accessibilityCollectionItem) {}
41+
3442
@Override
3543
public void setViewState(@NonNull T view, @Nullable ReadableMap accessibilityState) {}
3644

ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerDelegate.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ public void setProperty(T view, String propName, @Nullable Object value) {
4848
case ViewProps.ACCESSIBILITY_STATE:
4949
mViewManager.setViewState(view, (ReadableMap) value);
5050
break;
51+
case ViewProps.ACCESSIBILITY_COLLECTION:
52+
mViewManager.setAccessibilityCollection(view, (ReadableMap) value);
53+
break;
54+
case ViewProps.ACCESSIBILITY_COLLECTION_ITEM:
55+
mViewManager.setAccessibilityCollectionItem(view, (ReadableMap) value);
56+
break;
5157
case ViewProps.BACKGROUND_COLOR:
5258
mViewManager.setBackgroundColor(
5359
view, value == null ? 0 : ColorPropConverter.getColor(value, view.getContext()));

ReactAndroid/src/main/java/com/facebook/react/uimanager/BaseViewManagerInterface.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ public interface BaseViewManagerInterface<T extends View> {
2828

2929
void setAccessibilityRole(T view, @Nullable String accessibilityRole);
3030

31+
void setAccessibilityCollection(T view, @Nullable ReadableMap accessibilityCollection);
32+
33+
void setAccessibilityCollectionItem(T view, @Nullable ReadableMap accessibilityCollectionItem);
34+
3135
void setViewState(T view, @Nullable ReadableMap accessibilityState);
3236

3337
void setBackgroundColor(T view, int backgroundColor);

ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ public enum AccessibilityRole {
122122
TABLIST,
123123
TIMER,
124124
LIST,
125+
GRID,
125126
TOOLBAR;
126127

127128
public static String getValue(AccessibilityRole role) {
@@ -152,6 +153,8 @@ public static String getValue(AccessibilityRole role) {
152153
return "android.widget.Switch";
153154
case LIST:
154155
return "android.widget.AbsListView";
156+
case GRID:
157+
return "android.widget.GridView";
155158
case NONE:
156159
case LINK:
157160
case SUMMARY:
@@ -242,6 +245,22 @@ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCo
242245
}
243246
final ReadableArray accessibilityActions =
244247
(ReadableArray) host.getTag(R.id.accessibility_actions);
248+
249+
final ReadableMap accessibilityCollectionItem =
250+
(ReadableMap) host.getTag(R.id.accessibility_collection_item);
251+
if (accessibilityCollectionItem != null) {
252+
int rowIndex = accessibilityCollectionItem.getInt("rowIndex");
253+
int columnIndex = accessibilityCollectionItem.getInt("columnIndex");
254+
int rowSpan = accessibilityCollectionItem.getInt("rowSpan");
255+
int columnSpan = accessibilityCollectionItem.getInt("columnSpan");
256+
boolean heading = accessibilityCollectionItem.getBoolean("heading");
257+
258+
AccessibilityNodeInfoCompat.CollectionItemInfoCompat collectionItemCompat =
259+
AccessibilityNodeInfoCompat.CollectionItemInfoCompat.obtain(
260+
rowIndex, rowSpan, columnIndex, columnSpan, heading);
261+
info.setCollectionItemInfo(collectionItemCompat);
262+
}
263+
245264
if (accessibilityActions != null) {
246265
for (int i = 0; i < accessibilityActions.size(); i++) {
247266
final ReadableMap action = accessibilityActions.getMap(i);
@@ -466,6 +485,7 @@ public static void setDelegate(
466485
|| view.getTag(R.id.accessibility_state) != null
467486
|| view.getTag(R.id.accessibility_actions) != null
468487
|| view.getTag(R.id.react_test_id) != null
488+
|| view.getTag(R.id.accessibility_collection_item) != null
469489
|| view.getTag(R.id.accessibility_links) != null)) {
470490
ViewCompat.setAccessibilityDelegate(
471491
view,

ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ public class ViewProps {
146146
public static final String Z_INDEX = "zIndex";
147147
public static final String RENDER_TO_HARDWARE_TEXTURE = "renderToHardwareTextureAndroid";
148148
public static final String ACCESSIBILITY_LABEL = "accessibilityLabel";
149+
public static final String ACCESSIBILITY_COLLECTION = "accessibilityCollection";
150+
public static final String ACCESSIBILITY_COLLECTION_ITEM = "accessibilityCollectionItem";
149151
public static final String ACCESSIBILITY_HINT = "accessibilityHint";
150152
public static final String ACCESSIBILITY_LIVE_REGION = "accessibilityLiveRegion";
151153
public static final String ACCESSIBILITY_ROLE = "accessibilityRole";

ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,10 @@
2525
import android.view.MotionEvent;
2626
import android.view.View;
2727
import android.view.ViewGroup;
28-
import android.view.accessibility.AccessibilityEvent;
2928
import android.widget.HorizontalScrollView;
3029
import android.widget.OverScroller;
3130
import androidx.annotation.Nullable;
32-
import androidx.core.view.AccessibilityDelegateCompat;
3331
import androidx.core.view.ViewCompat;
34-
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
3532
import com.facebook.common.logging.FLog;
3633
import com.facebook.infer.annotation.Assertions;
3734
import com.facebook.react.common.ReactConstants;
@@ -122,22 +119,7 @@ public ReactHorizontalScrollView(Context context, @Nullable FpsListener fpsListe
122119
mReactBackgroundManager = new ReactViewBackgroundManager(this);
123120
mFpsListener = fpsListener;
124121

125-
ViewCompat.setAccessibilityDelegate(
126-
this,
127-
new AccessibilityDelegateCompat() {
128-
@Override
129-
public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) {
130-
super.onInitializeAccessibilityEvent(host, event);
131-
event.setScrollable(mScrollEnabled);
132-
}
133-
134-
@Override
135-
public void onInitializeAccessibilityNodeInfo(
136-
View host, AccessibilityNodeInfoCompat info) {
137-
super.onInitializeAccessibilityNodeInfo(host, info);
138-
info.setScrollable(mScrollEnabled);
139-
}
140-
});
122+
ViewCompat.setAccessibilityDelegate(this, new ReactScrollViewAccessibilityDelegate());
141123

142124
mScroller = getOverScrollerFromParent();
143125
mReactScrollViewScrollState =
@@ -147,6 +129,10 @@ public void onInitializeAccessibilityNodeInfo(
147129
: ViewCompat.LAYOUT_DIRECTION_LTR);
148130
}
149131

132+
public boolean getScrollEnabled() {
133+
return mScrollEnabled;
134+
}
135+
150136
@Nullable
151137
private OverScroller getOverScrollerFromParent() {
152138
OverScroller scroller;
@@ -408,7 +394,7 @@ private boolean isScrolledInView(View descendent) {
408394
}
409395

410396
/** Returns whether the given descendent is partially scrolled in view */
411-
private boolean isPartiallyScrolledInView(View descendent) {
397+
public boolean isPartiallyScrolledInView(View descendent) {
412398
int scrollDelta = getScrollDelta(descendent);
413399
descendent.getDrawingRect(mTempRect);
414400
return scrollDelta != 0 && Math.abs(scrollDelta) < mTempRect.width();

ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ public class ReactScrollView extends ScrollView
7575
private final @Nullable OverScroller mScroller;
7676
private final VelocityHelper mVelocityHelper = new VelocityHelper();
7777
private final Rect mRect = new Rect(); // for reuse to avoid allocation
78+
private final Rect mTempRect = new Rect();
7879
private final Rect mOverflowInset = new Rect();
7980

8081
private boolean mActivelyScrolling;
@@ -120,6 +121,8 @@ public ReactScrollView(Context context, @Nullable FpsListener fpsListener) {
120121
mScroller = getOverScrollerFromParent();
121122
setOnHierarchyChangeListener(this);
122123
setScrollBarStyle(SCROLLBARS_OUTSIDE_OVERLAY);
124+
125+
ViewCompat.setAccessibilityDelegate(this, new ReactScrollViewAccessibilityDelegate());
123126
}
124127

125128
@Override
@@ -191,6 +194,10 @@ public void setScrollEnabled(boolean scrollEnabled) {
191194
mScrollEnabled = scrollEnabled;
192195
}
193196

197+
public boolean getScrollEnabled() {
198+
return mScrollEnabled;
199+
}
200+
194201
public void setPagingEnabled(boolean pagingEnabled) {
195202
mPagingEnabled = pagingEnabled;
196203
}
@@ -299,6 +306,19 @@ public void requestChildFocus(View child, View focused) {
299306
super.requestChildFocus(child, focused);
300307
}
301308

309+
private int getScrollDelta(View descendent) {
310+
descendent.getDrawingRect(mTempRect);
311+
offsetDescendantRectToMyCoords(descendent, mTempRect);
312+
return computeScrollDeltaToGetChildRectOnScreen(mTempRect);
313+
}
314+
315+
/** Returns whether the given descendent is partially scrolled in view */
316+
public boolean isPartiallyScrolledInView(View descendent) {
317+
int scrollDelta = getScrollDelta(descendent);
318+
descendent.getDrawingRect(mTempRect);
319+
return scrollDelta != 0 && Math.abs(scrollDelta) < mTempRect.width();
320+
}
321+
302322
private void scrollToChild(View child) {
303323
Rect tempRect = new Rect();
304324
child.getDrawingRect(tempRect);
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
/*
2+
* Copyright (c) Meta Platforms, Inc. and affiliates.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*/
7+
8+
package com.facebook.react.views.scroll;
9+
10+
import android.view.View;
11+
import android.view.ViewGroup;
12+
import android.view.accessibility.AccessibilityEvent;
13+
import androidx.core.view.AccessibilityDelegateCompat;
14+
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
15+
import com.facebook.react.R;
16+
import com.facebook.react.bridge.AssertionException;
17+
import com.facebook.react.bridge.ReactSoftExceptionLogger;
18+
import com.facebook.react.bridge.ReadableMap;
19+
import com.facebook.react.uimanager.ReactAccessibilityDelegate;
20+
21+
public class ReactScrollViewAccessibilityDelegate extends AccessibilityDelegateCompat {
22+
private final String TAG = ReactScrollViewAccessibilityDelegate.class.getSimpleName();
23+
24+
@Override
25+
public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) {
26+
super.onInitializeAccessibilityEvent(host, event);
27+
if (host instanceof ReactScrollView || host instanceof ReactHorizontalScrollView) {
28+
onInitializeAccessibilityEventInternal(host, event);
29+
} else {
30+
ReactSoftExceptionLogger.logSoftException(
31+
TAG,
32+
new AssertionException(
33+
"ReactScrollViewAccessibilityDelegate should only be used with ReactScrollView or ReactHorizontalScrollView, not with class: "
34+
+ host.getClass().getSimpleName()));
35+
}
36+
}
37+
38+
@Override
39+
public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) {
40+
super.onInitializeAccessibilityNodeInfo(host, info);
41+
if (host instanceof ReactScrollView || host instanceof ReactHorizontalScrollView) {
42+
onInitializeAccessibilityNodeInfoInternal(host, info);
43+
} else {
44+
ReactSoftExceptionLogger.logSoftException(
45+
TAG,
46+
new AssertionException(
47+
"ReactScrollViewAccessibilityDelegate should only be used with ReactScrollView or ReactHorizontalScrollView, not with class: "
48+
+ host.getClass().getSimpleName()));
49+
}
50+
};
51+
52+
private void onInitializeAccessibilityEventInternal(View view, AccessibilityEvent event) {
53+
final ReadableMap accessibilityCollection =
54+
(ReadableMap) view.getTag(R.id.accessibility_collection);
55+
56+
if (accessibilityCollection != null) {
57+
event.setItemCount(accessibilityCollection.getInt("itemCount"));
58+
View contentView;
59+
if (view instanceof ViewGroup) {
60+
ViewGroup viewGroup = (ViewGroup) view;
61+
contentView = viewGroup.getChildAt(0);
62+
} else {
63+
return;
64+
}
65+
Integer firstVisibleIndex = null;
66+
Integer lastVisibleIndex = null;
67+
68+
if (!(contentView instanceof ViewGroup)) {
69+
return;
70+
}
71+
72+
for (int index = 0; index < ((ViewGroup) contentView).getChildCount(); index++) {
73+
View nextChild = ((ViewGroup) contentView).getChildAt(index);
74+
boolean isVisible;
75+
if (view instanceof ReactScrollView) {
76+
ReactScrollView scrollView = (ReactScrollView) view;
77+
isVisible = scrollView.isPartiallyScrolledInView(nextChild);
78+
} else if (view instanceof ReactHorizontalScrollView) {
79+
ReactHorizontalScrollView scrollView = (ReactHorizontalScrollView) view;
80+
isVisible = scrollView.isPartiallyScrolledInView(nextChild);
81+
} else {
82+
return;
83+
}
84+
ReadableMap accessibilityCollectionItem =
85+
(ReadableMap) nextChild.getTag(R.id.accessibility_collection_item);
86+
87+
if (!(nextChild instanceof ViewGroup)) {
88+
return;
89+
}
90+
91+
int childCount = ((ViewGroup) nextChild).getChildCount();
92+
93+
// If this child's accessibilityCollectionItem is null, we'll check one more
94+
// nested child.
95+
// Happens when getItemLayout is not passed in FlatList which adds an additional
96+
// View in the hierarchy.
97+
if (childCount > 0 && accessibilityCollectionItem == null) {
98+
View nestedNextChild = ((ViewGroup) nextChild).getChildAt(0);
99+
if (nestedNextChild != null) {
100+
ReadableMap nestedChildAccessibility =
101+
(ReadableMap) nestedNextChild.getTag(R.id.accessibility_collection_item);
102+
if (nestedChildAccessibility != null) {
103+
accessibilityCollectionItem = nestedChildAccessibility;
104+
}
105+
}
106+
}
107+
108+
if (isVisible == true && accessibilityCollectionItem != null) {
109+
if (firstVisibleIndex == null) {
110+
firstVisibleIndex = accessibilityCollectionItem.getInt("itemIndex");
111+
}
112+
lastVisibleIndex = accessibilityCollectionItem.getInt("itemIndex");
113+
}
114+
115+
if (firstVisibleIndex != null && lastVisibleIndex != null) {
116+
event.setFromIndex(firstVisibleIndex);
117+
event.setToIndex(lastVisibleIndex);
118+
}
119+
}
120+
}
121+
}
122+
123+
private void onInitializeAccessibilityNodeInfoInternal(
124+
View view, AccessibilityNodeInfoCompat info) {
125+
final ReactAccessibilityDelegate.AccessibilityRole accessibilityRole =
126+
(ReactAccessibilityDelegate.AccessibilityRole) view.getTag(R.id.accessibility_role);
127+
128+
if (accessibilityRole != null) {
129+
ReactAccessibilityDelegate.setRole(info, accessibilityRole, view.getContext());
130+
}
131+
132+
final ReadableMap accessibilityCollection =
133+
(ReadableMap) view.getTag(R.id.accessibility_collection);
134+
135+
if (accessibilityCollection != null) {
136+
int rowCount = accessibilityCollection.getInt("rowCount");
137+
int columnCount = accessibilityCollection.getInt("columnCount");
138+
boolean hierarchical = accessibilityCollection.getBoolean("hierarchical");
139+
140+
AccessibilityNodeInfoCompat.CollectionInfoCompat collectionInfoCompat =
141+
AccessibilityNodeInfoCompat.CollectionInfoCompat.obtain(
142+
rowCount, columnCount, hierarchical);
143+
info.setCollectionInfo(collectionInfoCompat);
144+
}
145+
146+
if (view instanceof ReactScrollView) {
147+
ReactScrollView scrollView = (ReactScrollView) view;
148+
info.setScrollable(scrollView.getScrollEnabled());
149+
} else if (view instanceof ReactHorizontalScrollView) {
150+
ReactHorizontalScrollView scrollView = (ReactHorizontalScrollView) view;
151+
info.setScrollable(scrollView.getScrollEnabled());
152+
}
153+
}
154+
};

ReactAndroid/src/main/res/views/uimanager/values/ids.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@
1515
<!--tag is used to store accessibilityRole tag-->
1616
<item type="id" name="accessibility_role"/>
1717

18+
<!--tag is used to store accessibilityCollection -->
19+
<item type="id" name="accessibility_collection"/>
20+
21+
<!--tag is used to store accessibilityCollectionItem -->
22+
<item type="id" name="accessibility_collection_item"/>
23+
1824
<!--tag is used to store accessibilityState -->
1925
<item type="id" name="accessibility_state"/>
2026

0 commit comments

Comments
 (0)