From 76d7d14f909293595a5cdf4d28a86a077fb6bf77 Mon Sep 17 00:00:00 2001 From: fabriziobertoglio1987 Date: Tue, 19 Apr 2022 19:45:10 -0700 Subject: [PATCH] TalkBack support for ScrollView accessibility announcements (list and grid) (#33180) Summary: 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] [Android] [Added] - Accessibility announcement for list and grid in FlatList Pull Request resolved: https://github.com/facebook/react-native/pull/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]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/6#issuecomment-1050452894 [2]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/6#issuecomment-1050462465 [3]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/6#issuecomment-1032340879 [4]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/6#issuecomment-1050618308 [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]:https://github.com/fabriziobertoglio1987/react-native-notes/issues/6#issuecomment-1042518901 "test case on Android GridView" [12]:https://github.com/fabriziobertoglio1987/react-native-notes/issues/6#issuecomment-1050452894 "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]: https://github.com/facebook/react-native/issues/30977 [18]: https://github.com/fabriziobertoglio1987/react-native-notes/issues/6 [19]: https://github.com/facebook/react-native/pull/31666 [20]: https://reactnative.dev/docs/next/flatlist#required-renderitem "FlatList renderItem documentation" [21]: https://github.com/fabriziobertoglio1987/react-native/commit/75147359c5d070406ebbe488c57c3cd94c08c19d "commit that introduces fourth param accessibilityCollectionItem in callback renderItem" [22]: https://github.com/facebook/react-native/pull/33180#discussion_r826748664 "discussion on the additional container View around each FlatList cell" [23]: https://github.com/fabriziobertoglio1987/react-native/commit/d50fd1a68112f40f4be3ac3aa4d67f96df33e387 "commit adding prop getCellsInItemCount to VirtualizedList" Reviewed By: kacieb Differential Revision: D34518929 Pulled By: blavalla fbshipit-source-id: 410a05263a56162bf505a4cad957b24005ed65ed --- .../Components/View/ViewAccessibility.js | 1 + Libraries/Components/View/ViewPropTypes.js | 17 + Libraries/Lists/FlatList.js | 10 +- Libraries/Lists/VirtualizedList.js | 67 +++- Libraries/Lists/VirtualizedSectionList.js | 16 +- .../__snapshots__/FlatList-test.js.snap | 59 +++ .../__snapshots__/SectionList-test.js.snap | 52 ++- .../VirtualizedList-test.js.snap | 360 ++++++++++++++++++ .../VirtualizedSectionList-test.js.snap | 88 +++++ .../react/uimanager/BaseViewManager.java | 14 + .../uimanager/BaseViewManagerAdapter.java | 8 + .../uimanager/BaseViewManagerDelegate.java | 6 + .../uimanager/BaseViewManagerInterface.java | 4 + .../uimanager/ReactAccessibilityDelegate.java | 20 + .../facebook/react/uimanager/ViewProps.java | 2 + .../scroll/ReactHorizontalScrollView.java | 26 +- .../react/views/scroll/ReactScrollView.java | 20 + .../ReactScrollViewAccessibilityDelegate.java | 154 ++++++++ .../main/res/views/uimanager/values/ids.xml | 8 +- .../js/examples/FlatList/FlatList-basic.js | 27 +- .../examples/FlatList/FlatList-multiColumn.js | 7 +- .../js/examples/FlatList/FlatList-nested.js | 114 ++++++ .../SectionList/SectionList-scrollable.js | 18 +- .../js/utils/RNTesterList.android.js | 5 + 24 files changed, 1051 insertions(+), 52 deletions(-) create mode 100644 ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegate.java create mode 100644 packages/rn-tester/js/examples/FlatList/FlatList-nested.js diff --git a/Libraries/Components/View/ViewAccessibility.js b/Libraries/Components/View/ViewAccessibility.js index 89e07889bd14a0..de4f9e44ad505d 100644 --- a/Libraries/Components/View/ViewAccessibility.js +++ b/Libraries/Components/View/ViewAccessibility.js @@ -43,6 +43,7 @@ export type AccessibilityRole = | 'tablist' | 'timer' | 'list' + | 'grid' | 'toolbar' | 'menubutton'; // [macOS] diff --git a/Libraries/Components/View/ViewPropTypes.js b/Libraries/Components/View/ViewPropTypes.js index 273c316947e918..ea556fc46fa34e 100644 --- a/Libraries/Components/View/ViewPropTypes.js +++ b/Libraries/Components/View/ViewPropTypes.js @@ -577,6 +577,23 @@ export type ViewProps = $ReadOnly<{| */ accessibilityActions?: ?$ReadOnlyArray, + /** + * + * Node Information of a FlatList, VirtualizedList or SectionList collection item. + * A collection item starts at a given row and column in the collection, and spans one or more rows and columns. + * + * @platform android + * + */ + accessibilityCollectionItem?: ?{ + rowIndex: number, + rowSpan: number, + columnIndex: number, + columnSpan: number, + heading: boolean, + itemIndex: number, + }, + /** * Specifies the nativeID of the associated label text. When the assistive technology focuses on the component with this props, the text is read aloud. * diff --git a/Libraries/Lists/FlatList.js b/Libraries/Lists/FlatList.js index eca9de8ef4baee..e7f1a37622ab8d 100644 --- a/Libraries/Lists/FlatList.js +++ b/Libraries/Lists/FlatList.js @@ -660,11 +660,18 @@ class FlatList extends React.PureComponent, void> { return ( {item.map((it, kk) => { + const itemIndex = index * cols + kk; + const accessibilityCollectionItem = { + ...info.accessibilityCollectionItem, + columnIndex: itemIndex % cols, + itemIndex: itemIndex, + }; const element = renderer({ item: it, - index: index * cols + kk, + index: itemIndex, isSelected: info.isSelected, // [macOS] separators: info.separators, + accessibilityCollectionItem, }); return element != null ? ( {element} @@ -695,6 +702,7 @@ class FlatList extends React.PureComponent, void> { return ( = { item: ItemT, index: number, isSelected: ?boolean, // [macOS] separators: Separators, + accessibilityCollectionItem: AccessibilityCollectionItem, ... }; @@ -93,9 +104,19 @@ type RequiredProps = {| */ getItem: (data: any, index: number) => ?Item, /** - * Determines how many items are in the data blob. + * Determines how many items (rows) are in the data blob. */ getItemCount: (data: any) => number, + /** + * Determines how many cells are in the data blob + * see https://bit.ly/35RKX7H + */ + getCellsInItemCount?: (data: any) => number, + /** + * The number of columns used in FlatList. + * The default of 1 is used in other components to calculate the accessibilityCollection prop. + */ + numColumns?: ?number, |}; type OptionalProps = {| renderItem?: ?RenderItemType, @@ -360,6 +381,10 @@ type Props = {| ...OptionalProps, |}; +function numColumnsOrDefault(numColumns: ?number) { + return numColumns ?? 1; +} + let _usedIndexForKey = false; let _keylessItemComponentName: string = ''; @@ -1338,6 +1363,17 @@ class VirtualizedList extends React.PureComponent { ); } + _getCellsInItemCount = props => { + const {getCellsInItemCount, data} = props; + if (getCellsInItemCount) { + return getCellsInItemCount(data); + } + if (Array.isArray(data)) { + return data.length; + } + return 0; + }; + _defaultRenderScrollComponent = props => { // [macOS const preferredScrollerStyleDidChangeHandler = @@ -1362,7 +1398,21 @@ class VirtualizedList extends React.PureComponent { onKeyDown: this._handleKeyDown, }; // macOS] + const {getItemCount, data} = props; const onRefresh = props.onRefresh; + const numColumns = numColumnsOrDefault(props.numColumns); + const accessibilityRole = Platform.select({ + android: numColumns > 1 ? 'grid' : 'list', + }); + const rowCount = getItemCount(data); + const accessibilityCollection = { + // over-ride _getCellsInItemCount to handle Objects or other data formats + // see https://bit.ly/35RKX7H + itemCount: this._getCellsInItemCount(props), + rowCount, + columnCount: numColumns, + hierarchical: false, + }; if (this._isNestedWithSameOrientation()) { // $FlowFixMe[prop-missing] - Typing ReactNativeComponent revealed errors return ; @@ -1383,6 +1433,8 @@ class VirtualizedList extends React.PureComponent { preferredScrollerStyleDidChangeHandler } // macOS] {...props} + accessibilityRole={accessibilityRole} + accessibilityCollection={accessibilityCollection} refreshControl={ props.refreshControl == null ? ( { ); } else { return ( - // $FlowFixMe Invalid prop usage + // $FlowFixMe[prop-missing] Invalid prop usage { preferredScrollerStyleDidChangeHandler } // macOS] {...props} + accessibilityRole={accessibilityRole} + accessibilityCollection={accessibilityCollection} /> ); } @@ -2231,11 +2285,20 @@ class CellRenderer extends React.Component< } if (renderItem) { + const accessibilityCollectionItem = { + itemIndex: index, + rowIndex: index, + rowSpan: 1, + columnIndex: 0, + columnSpan: 1, + heading: false, + }; return renderItem({ item, index, isSelected, // [macOS] separators: this._separators, + accessibilityCollectionItem, }); } diff --git a/Libraries/Lists/VirtualizedSectionList.js b/Libraries/Lists/VirtualizedSectionList.js index e8b81a519caaee..0010e2cd3191b4 100644 --- a/Libraries/Lists/VirtualizedSectionList.js +++ b/Libraries/Lists/VirtualizedSectionList.js @@ -13,6 +13,7 @@ import invariant from 'invariant'; import type {ViewToken} from './ViewabilityHelper'; import type {SelectedRowIndexPathType} from './VirtualizedList'; // [macOS] import type {KeyEvent} from '../Types/CoreEventTypes'; // [macOS] +import type {AccessibilityCollectionItem} from './VirtualizedList'; import {keyExtractor as defaultKeyExtractor} from './VirtualizeUtils'; import {View, VirtualizedList} from 'react-native'; import * as React from 'react'; @@ -508,7 +509,16 @@ class VirtualizedSectionList< _renderItem = (listItemCount: number) => - ({item, index}: {item: Item, index: number, ...}) => { + ({ + item, + index, + accessibilityCollectionItem, + }: { + item: Item, + index: number, + accessibilityCollectionItem: AccessibilityCollectionItem, + ... + }) => { const info = this._subExtractor(index); if (!info) { return null; @@ -537,6 +547,7 @@ class VirtualizedSectionList< LeadingSeparatorComponent={ infoIndex === 0 ? this.props.SectionSeparatorComponent : undefined } + accessibilityCollectionItem={accessibilityCollectionItem} cellKey={info.key} index={infoIndex} isSelected={this._isItemSelected(item)} // [macOS] @@ -651,6 +662,7 @@ type ItemWithSeparatorProps = $ReadOnly<{| updatePropsFor: (prevCellKey: string, value: Object) => void, renderItem: Function, inverted: boolean, + accessibilityCollectionItem: AccessibilityCollectionItem, |}>; function ItemWithSeparator(props: ItemWithSeparatorProps): React.Node { @@ -669,6 +681,7 @@ function ItemWithSeparator(props: ItemWithSeparatorProps): React.Node { index, section, inverted, + accessibilityCollectionItem, } = props; const [leadingSeparatorHiglighted, setLeadingSeparatorHighlighted] = @@ -743,6 +756,7 @@ function ItemWithSeparator(props: ItemWithSeparatorProps): React.Node { isSelected, // [macOS] section, separators, + accessibilityCollectionItem, }); const leadingSeparator = LeadingSeparatorComponent != null && ( Object { @@ -1566,6 +1718,14 @@ exports[`VirtualizedList test getItem functionality where data is not an Array 1 exports[`VirtualizedList warns if both renderItem or ListItemComponent are specified. Uses ListItemComponent 1`] = ` { void setAccessibilityRole(T view, @Nullable String accessibilityRole); + void setAccessibilityCollection(T view, @Nullable ReadableMap accessibilityCollection); + + void setAccessibilityCollectionItem(T view, @Nullable ReadableMap accessibilityCollectionItem); + void setViewState(T view, @Nullable ReadableMap accessibilityState); void setBackgroundColor(T view, int backgroundColor); 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 7d38333ccf3074..3e6d1b36b08467 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ReactAccessibilityDelegate.java @@ -122,6 +122,7 @@ public enum AccessibilityRole { TABLIST, TIMER, LIST, + GRID, TOOLBAR; public static String getValue(AccessibilityRole role) { @@ -152,6 +153,8 @@ public static String getValue(AccessibilityRole role) { return "android.widget.Switch"; case LIST: return "android.widget.AbsListView"; + case GRID: + return "android.widget.GridView"; case NONE: case LINK: case SUMMARY: @@ -242,6 +245,22 @@ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCo } final ReadableArray accessibilityActions = (ReadableArray) host.getTag(R.id.accessibility_actions); + + final ReadableMap accessibilityCollectionItem = + (ReadableMap) host.getTag(R.id.accessibility_collection_item); + if (accessibilityCollectionItem != null) { + int rowIndex = accessibilityCollectionItem.getInt("rowIndex"); + int columnIndex = accessibilityCollectionItem.getInt("columnIndex"); + int rowSpan = accessibilityCollectionItem.getInt("rowSpan"); + int columnSpan = accessibilityCollectionItem.getInt("columnSpan"); + boolean heading = accessibilityCollectionItem.getBoolean("heading"); + + AccessibilityNodeInfoCompat.CollectionItemInfoCompat collectionItemCompat = + AccessibilityNodeInfoCompat.CollectionItemInfoCompat.obtain( + rowIndex, rowSpan, columnIndex, columnSpan, heading); + info.setCollectionItemInfo(collectionItemCompat); + } + if (accessibilityActions != null) { for (int i = 0; i < accessibilityActions.size(); i++) { final ReadableMap action = accessibilityActions.getMap(i); @@ -466,6 +485,7 @@ public static void setDelegate( || view.getTag(R.id.accessibility_state) != null || view.getTag(R.id.accessibility_actions) != null || view.getTag(R.id.react_test_id) != null + || view.getTag(R.id.accessibility_collection_item) != null || view.getTag(R.id.accessibility_links) != null)) { ViewCompat.setAccessibilityDelegate( view, diff --git a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java index 661c3466dde96f..d78d1f7e5a8e8b 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java +++ b/ReactAndroid/src/main/java/com/facebook/react/uimanager/ViewProps.java @@ -146,6 +146,8 @@ public class ViewProps { public static final String Z_INDEX = "zIndex"; public static final String RENDER_TO_HARDWARE_TEXTURE = "renderToHardwareTextureAndroid"; public static final String ACCESSIBILITY_LABEL = "accessibilityLabel"; + public static final String ACCESSIBILITY_COLLECTION = "accessibilityCollection"; + public static final String ACCESSIBILITY_COLLECTION_ITEM = "accessibilityCollectionItem"; public static final String ACCESSIBILITY_HINT = "accessibilityHint"; public static final String ACCESSIBILITY_LIVE_REGION = "accessibilityLiveRegion"; public static final String ACCESSIBILITY_ROLE = "accessibilityRole"; diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java index eb0de9d470e043..83f14c50c4c104 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactHorizontalScrollView.java @@ -25,13 +25,10 @@ import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; -import android.view.accessibility.AccessibilityEvent; import android.widget.HorizontalScrollView; import android.widget.OverScroller; import androidx.annotation.Nullable; -import androidx.core.view.AccessibilityDelegateCompat; import androidx.core.view.ViewCompat; -import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; import com.facebook.common.logging.FLog; import com.facebook.infer.annotation.Assertions; import com.facebook.react.common.ReactConstants; @@ -118,22 +115,7 @@ public ReactHorizontalScrollView(Context context, @Nullable FpsListener fpsListe mReactBackgroundManager = new ReactViewBackgroundManager(this); mFpsListener = fpsListener; - ViewCompat.setAccessibilityDelegate( - this, - new AccessibilityDelegateCompat() { - @Override - public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) { - super.onInitializeAccessibilityEvent(host, event); - event.setScrollable(mScrollEnabled); - } - - @Override - public void onInitializeAccessibilityNodeInfo( - View host, AccessibilityNodeInfoCompat info) { - super.onInitializeAccessibilityNodeInfo(host, info); - info.setScrollable(mScrollEnabled); - } - }); + ViewCompat.setAccessibilityDelegate(this, new ReactScrollViewAccessibilityDelegate()); mScroller = getOverScrollerFromParent(); mReactScrollViewScrollState = @@ -143,6 +125,10 @@ public void onInitializeAccessibilityNodeInfo( : ViewCompat.LAYOUT_DIRECTION_LTR); } + public boolean getScrollEnabled() { + return mScrollEnabled; + } + @Nullable private OverScroller getOverScrollerFromParent() { OverScroller scroller; @@ -404,7 +390,7 @@ private boolean isScrolledInView(View descendent) { } /** Returns whether the given descendent is partially scrolled in view */ - private boolean isPartiallyScrolledInView(View descendent) { + public boolean isPartiallyScrolledInView(View descendent) { int scrollDelta = getScrollDelta(descendent); descendent.getDrawingRect(mTempRect); return scrollDelta != 0 && Math.abs(scrollDelta) < mTempRect.width(); diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java index 9c77f01ddc39b2..d33c08f1fdbc72 100644 --- a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java @@ -73,6 +73,7 @@ public class ReactScrollView extends ScrollView private final @Nullable OverScroller mScroller; private final VelocityHelper mVelocityHelper = new VelocityHelper(); private final Rect mRect = new Rect(); // for reuse to avoid allocation + private final Rect mTempRect = new Rect(); private final Rect mOverflowInset = new Rect(); private boolean mActivelyScrolling; @@ -116,6 +117,8 @@ public ReactScrollView(Context context, @Nullable FpsListener fpsListener) { mScroller = getOverScrollerFromParent(); setOnHierarchyChangeListener(this); setScrollBarStyle(SCROLLBARS_OUTSIDE_OVERLAY); + + ViewCompat.setAccessibilityDelegate(this, new ReactScrollViewAccessibilityDelegate()); } @Override @@ -187,6 +190,10 @@ public void setScrollEnabled(boolean scrollEnabled) { mScrollEnabled = scrollEnabled; } + public boolean getScrollEnabled() { + return mScrollEnabled; + } + public void setPagingEnabled(boolean pagingEnabled) { mPagingEnabled = pagingEnabled; } @@ -295,6 +302,19 @@ public void requestChildFocus(View child, View focused) { super.requestChildFocus(child, focused); } + private int getScrollDelta(View descendent) { + descendent.getDrawingRect(mTempRect); + offsetDescendantRectToMyCoords(descendent, mTempRect); + return computeScrollDeltaToGetChildRectOnScreen(mTempRect); + } + + /** Returns whether the given descendent is partially scrolled in view */ + public boolean isPartiallyScrolledInView(View descendent) { + int scrollDelta = getScrollDelta(descendent); + descendent.getDrawingRect(mTempRect); + return scrollDelta != 0 && Math.abs(scrollDelta) < mTempRect.width(); + } + private void scrollToChild(View child) { Rect tempRect = new Rect(); child.getDrawingRect(tempRect); diff --git a/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegate.java b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegate.java new file mode 100644 index 00000000000000..6cf22db90c04e1 --- /dev/null +++ b/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegate.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +package com.facebook.react.views.scroll; + +import android.view.View; +import android.view.ViewGroup; +import android.view.accessibility.AccessibilityEvent; +import androidx.core.view.AccessibilityDelegateCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.facebook.react.R; +import com.facebook.react.bridge.AssertionException; +import com.facebook.react.bridge.ReactSoftExceptionLogger; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.uimanager.ReactAccessibilityDelegate; + +public class ReactScrollViewAccessibilityDelegate extends AccessibilityDelegateCompat { + private final String TAG = ReactScrollViewAccessibilityDelegate.class.getSimpleName(); + + @Override + public void onInitializeAccessibilityEvent(View host, AccessibilityEvent event) { + super.onInitializeAccessibilityEvent(host, event); + if (host instanceof ReactScrollView || host instanceof ReactHorizontalScrollView) { + onInitializeAccessibilityEventInternal(host, event); + } else { + ReactSoftExceptionLogger.logSoftException( + TAG, + new AssertionException( + "ReactScrollViewAccessibilityDelegate should only be used with ReactScrollView or ReactHorizontalScrollView, not with class: " + + host.getClass().getSimpleName())); + } + } + + @Override + public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) { + super.onInitializeAccessibilityNodeInfo(host, info); + if (host instanceof ReactScrollView || host instanceof ReactHorizontalScrollView) { + onInitializeAccessibilityNodeInfoInternal(host, info); + } else { + ReactSoftExceptionLogger.logSoftException( + TAG, + new AssertionException( + "ReactScrollViewAccessibilityDelegate should only be used with ReactScrollView or ReactHorizontalScrollView, not with class: " + + host.getClass().getSimpleName())); + } + }; + + private void onInitializeAccessibilityEventInternal(View view, AccessibilityEvent event) { + final ReadableMap accessibilityCollection = + (ReadableMap) view.getTag(R.id.accessibility_collection); + + if (accessibilityCollection != null) { + event.setItemCount(accessibilityCollection.getInt("itemCount")); + View contentView; + if (view instanceof ViewGroup) { + ViewGroup viewGroup = (ViewGroup) view; + contentView = viewGroup.getChildAt(0); + } else { + return; + } + Integer firstVisibleIndex = null; + Integer lastVisibleIndex = null; + + if (!(contentView instanceof ViewGroup)) { + return; + } + + for (int index = 0; index < ((ViewGroup) contentView).getChildCount(); index++) { + View nextChild = ((ViewGroup) contentView).getChildAt(index); + boolean isVisible; + if (view instanceof ReactScrollView) { + ReactScrollView scrollView = (ReactScrollView) view; + isVisible = scrollView.isPartiallyScrolledInView(nextChild); + } else if (view instanceof ReactHorizontalScrollView) { + ReactHorizontalScrollView scrollView = (ReactHorizontalScrollView) view; + isVisible = scrollView.isPartiallyScrolledInView(nextChild); + } else { + return; + } + ReadableMap accessibilityCollectionItem = + (ReadableMap) nextChild.getTag(R.id.accessibility_collection_item); + + if (!(nextChild instanceof ViewGroup)) { + return; + } + + int childCount = ((ViewGroup) nextChild).getChildCount(); + + // If this child's accessibilityCollectionItem is null, we'll check one more + // nested child. + // Happens when getItemLayout is not passed in FlatList which adds an additional + // View in the hierarchy. + if (childCount > 0 && accessibilityCollectionItem == null) { + View nestedNextChild = ((ViewGroup) nextChild).getChildAt(0); + if (nestedNextChild != null) { + ReadableMap nestedChildAccessibility = + (ReadableMap) nestedNextChild.getTag(R.id.accessibility_collection_item); + if (nestedChildAccessibility != null) { + accessibilityCollectionItem = nestedChildAccessibility; + } + } + } + + if (isVisible == true && accessibilityCollectionItem != null) { + if (firstVisibleIndex == null) { + firstVisibleIndex = accessibilityCollectionItem.getInt("itemIndex"); + } + lastVisibleIndex = accessibilityCollectionItem.getInt("itemIndex"); + } + + if (firstVisibleIndex != null && lastVisibleIndex != null) { + event.setFromIndex(firstVisibleIndex); + event.setToIndex(lastVisibleIndex); + } + } + } + } + + private void onInitializeAccessibilityNodeInfoInternal( + View view, AccessibilityNodeInfoCompat info) { + final ReactAccessibilityDelegate.AccessibilityRole accessibilityRole = + (ReactAccessibilityDelegate.AccessibilityRole) view.getTag(R.id.accessibility_role); + + if (accessibilityRole != null) { + ReactAccessibilityDelegate.setRole(info, accessibilityRole, view.getContext()); + } + + final ReadableMap accessibilityCollection = + (ReadableMap) view.getTag(R.id.accessibility_collection); + + if (accessibilityCollection != null) { + int rowCount = accessibilityCollection.getInt("rowCount"); + int columnCount = accessibilityCollection.getInt("columnCount"); + boolean hierarchical = accessibilityCollection.getBoolean("hierarchical"); + + AccessibilityNodeInfoCompat.CollectionInfoCompat collectionInfoCompat = + AccessibilityNodeInfoCompat.CollectionInfoCompat.obtain( + rowCount, columnCount, hierarchical); + info.setCollectionInfo(collectionInfoCompat); + } + + if (view instanceof ReactScrollView) { + ReactScrollView scrollView = (ReactScrollView) view; + info.setScrollable(scrollView.getScrollEnabled()); + } else if (view instanceof ReactHorizontalScrollView) { + ReactHorizontalScrollView scrollView = (ReactHorizontalScrollView) view; + info.setScrollable(scrollView.getScrollEnabled()); + } + } +}; diff --git a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml index b9e9b732bb3595..bd525ea13c91d8 100644 --- a/ReactAndroid/src/main/res/views/uimanager/values/ids.xml +++ b/ReactAndroid/src/main/res/views/uimanager/values/ids.xml @@ -15,7 +15,13 @@ - + + + + + + + diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js index 64431eed54663e..25756e56173c6d 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js @@ -312,19 +312,22 @@ class FlatListExample extends React.PureComponent { /* $FlowFixMe[invalid-computed-prop] (>=0.111.0 site=react_native_fb) * This comment suppresses an error found when Flow v0.111 was deployed. * To see the error, delete this comment and run Flow. */ - [flatListPropKey]: props => { - const {item, separators, isSelected} = props; // [macOS] + [flatListPropKey]: ({item, separators, accessibilityCollectionItem, isSelected}) => { // [macOS] return ( - + + + ); }, }; diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js b/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js index a4ec63a8c4d397..eb673afc1686e0 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js @@ -140,9 +140,12 @@ class MultiColumnExample extends React.PureComponent< getItemLayout(data, index).length + 2 * (CARD_MARGIN + BORDER_WIDTH); return {length, offset: length * index, index}; } - _renderItemComponent = ({item}: RenderItemProps) => { + _renderItemComponent = ({item, accessibilityCollectionItem}) => { return ( - + ( + + {item.title} + +); + +const renderItem = (props) => ; + +const renderFlatList = ({item}) => { + return ( + + Flatlist {item} + + + ); +}; + +const FlatListNested = () => { + return ( + + item.toString()} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + marginTop: StatusBar.currentHeight || 0, + }, + item: { + backgroundColor: '#f9c2ff', + padding: 20, + marginVertical: 8, + marginHorizontal: 16, + }, + title: { + fontSize: 16, + }, +}); + +exports.title = 'FlatList Nested'; +exports.testTitle = 'Test accessibility announcement in nested flatlist'; +exports.category = 'ListView'; +exports.documentationURL = 'https://reactnative.dev/docs/flatlist'; +exports.description = 'Nested flatlist example'; +exports.examples = [ + { + title: 'FlatList Nested example', + render: function (): React.Element { + return ; + }, + }, +]; diff --git a/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js b/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js index d478424f4c51fd..b0ae6d1879aa7d 100644 --- a/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js +++ b/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js @@ -109,7 +109,7 @@ const EmptySectionList = () => ( const renderItemComponent = setItemState => - ({item, separators}) => { + ({item, separators, accessibilityCollectionItem}) => { if (isNaN(item.key)) { return; } @@ -119,12 +119,16 @@ const renderItemComponent = }; return ( - + + + ); }; diff --git a/packages/rn-tester/js/utils/RNTesterList.android.js b/packages/rn-tester/js/utils/RNTesterList.android.js index f150ea666b1109..61e5318eab352c 100644 --- a/packages/rn-tester/js/utils/RNTesterList.android.js +++ b/packages/rn-tester/js/utils/RNTesterList.android.js @@ -31,6 +31,11 @@ const Components: Array = [ category: 'ListView', supportsTVOS: true, }, + { + key: 'FlatList-nested', + module: require('../examples/FlatList/FlatList-nested'), + category: 'ListView', + }, { key: 'ImageExample', category: 'Basic',