From e664018e8e08809753cb08b0695fb35b4b421d90 Mon Sep 17 00:00:00 2001 From: fabriziobertoglio1987 Date: Tue, 2 Aug 2022 19:49:05 -0700 Subject: [PATCH] TalkBack support for ScrollView accessibility announcements (list and grid) - Javascript Only Changes (#33180) Summary: This is the Javascript-only changes from D34518929 (https://github.com/facebook/react-native/commit/dd6325bafe1a539d348f3710e717a6344576b859), 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: 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: lunaleaps Differential Revision: D37668064 Pulled By: blavalla fbshipit-source-id: 7ba4068405fdcb9823d0daed2d8c36f0a56dbf0f --- Libraries/Components/View/ViewPropTypes.js | 17 + Libraries/Lists/FlatList.js | 10 +- Libraries/Lists/VirtualizedList.js | 52 +- Libraries/Lists/VirtualizedListProps.js | 22 +- .../Lists/VirtualizedList_EXPERIMENTAL.js | 52 +- Libraries/Lists/VirtualizedSectionList.js | 17 +- .../Lists/__tests__/VirtualizedList-test.js | 33 ++ .../__snapshots__/FlatList-test.js.snap | 59 +++ .../__snapshots__/SectionList-test.js.snap | 52 +- .../VirtualizedList-test.js.snap | 454 ++++++++++++++++ .../VirtualizedList_EXPERIMENTAL-test.js.snap | 488 ++++++++++++++++++ .../VirtualizedSectionList-test.js.snap | 88 ++++ .../js/examples/FlatList/FlatList-basic.js | 24 +- .../examples/FlatList/FlatList-multiColumn.js | 19 +- .../js/examples/FlatList/FlatList-nested.js | 112 ++++ .../SectionList/SectionList-scrollable.js | 19 +- .../js/utils/RNTesterList.android.js | 5 + 17 files changed, 1489 insertions(+), 34 deletions(-) create mode 100644 packages/rn-tester/js/examples/FlatList/FlatList-nested.js diff --git a/Libraries/Components/View/ViewPropTypes.js b/Libraries/Components/View/ViewPropTypes.js index c7ee1baa63a16b..5e558f9e17de39 100644 --- a/Libraries/Components/View/ViewPropTypes.js +++ b/Libraries/Components/View/ViewPropTypes.js @@ -461,6 +461,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 2ebd0f673ab004..46f99c7603efa4 100644 --- a/Libraries/Lists/FlatList.js +++ b/Libraries/Lists/FlatList.js @@ -625,11 +625,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({ // $FlowFixMe[incompatible-call] item: it, - index: index * cols + kk, + index: itemIndex, separators: info.separators, + accessibilityCollectionItem, }); return element != null ? ( {element} @@ -660,6 +667,7 @@ class FlatList extends React.PureComponent, void> { return ( { ); } + _getCellsInItemCount = (props: Props) => { + const {getCellsInItemCount, data} = props; + if (getCellsInItemCount) { + return getCellsInItemCount(data); + } + if (Array.isArray(data)) { + return data.length; + } + return 0; + }; + /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's * LTI update could not be added via codemod */ _defaultRenderScrollComponent = props => { + 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 ; @@ -1028,6 +1059,8 @@ class VirtualizedList extends React.PureComponent { // $FlowFixMe[prop-missing] Invalid prop usage { /> ); } else { - // $FlowFixMe[prop-missing] Invalid prop usage - return ; + return ( + // $FlowFixMe[prop-missing] Invalid prop usage + + ); } }; @@ -1821,10 +1860,19 @@ 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, separators: this._separators, + accessibilityCollectionItem, }); } diff --git a/Libraries/Lists/VirtualizedListProps.js b/Libraries/Lists/VirtualizedListProps.js index 7bf0cd0f539778..35db2c8bc61b1e 100644 --- a/Libraries/Lists/VirtualizedListProps.js +++ b/Libraries/Lists/VirtualizedListProps.js @@ -27,10 +27,20 @@ export type Separators = { ... }; +export type AccessibilityCollectionItem = { + itemIndex: number, + rowIndex: number, + rowSpan: number, + columnIndex: number, + columnSpan: number, + heading: boolean, +}; + export type RenderItemProps = { item: ItemT, index: number, separators: Separators, + accessibilityCollectionItem: AccessibilityCollectionItem, ... }; @@ -49,9 +59,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, diff --git a/Libraries/Lists/VirtualizedList_EXPERIMENTAL.js b/Libraries/Lists/VirtualizedList_EXPERIMENTAL.js index 3e2682f15a2a79..cb79424dfbecdb 100644 --- a/Libraries/Lists/VirtualizedList_EXPERIMENTAL.js +++ b/Libraries/Lists/VirtualizedList_EXPERIMENTAL.js @@ -49,6 +49,7 @@ const ScrollView = require('../Components/ScrollView/ScrollView'); const View = require('../Components/View/View'); const Batchinator = require('../Interaction/Batchinator'); const ReactNative = require('../Renderer/shims/ReactNative'); +const Platform = require('../Utilities/Platform'); const flattenStyle = require('../StyleSheet/flattenStyle'); const StyleSheet = require('../StyleSheet/StyleSheet'); const infoLog = require('../Utilities/infoLog'); @@ -81,6 +82,11 @@ type State = { * Use the following helper functions for default values */ +// numColumnsOrDefault(this.props.numColumns) +function numColumnsOrDefault(numColumns: ?number) { + return numColumns ?? 1; +} + // horizontalOrDefault(this.props.horizontal) function horizontalOrDefault(horizontal: ?boolean) { return horizontal ?? false; @@ -1205,10 +1211,35 @@ class VirtualizedList extends React.PureComponent { ); } + _getCellsInItemCount = (props: Props) => { + const {getCellsInItemCount, data} = props; + if (getCellsInItemCount) { + return getCellsInItemCount(data); + } + if (Array.isArray(data)) { + return data.length; + } + return 0; + }; + /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's * LTI update could not be added via codemod */ _defaultRenderScrollComponent = props => { + 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 ; @@ -1223,6 +1254,8 @@ class VirtualizedList extends React.PureComponent { // $FlowFixMe[prop-missing] Invalid prop usage { /> ); } else { - // $FlowFixMe[prop-missing] Invalid prop usage - return ; + return ( + // $FlowFixMe[prop-missing] Invalid prop usage + + ); } }; @@ -2037,10 +2076,19 @@ 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, separators: this._separators, + accessibilityCollectionItem, }); } diff --git a/Libraries/Lists/VirtualizedSectionList.js b/Libraries/Lists/VirtualizedSectionList.js index aa18f60f05c9e4..ffeb45450f4262 100644 --- a/Libraries/Lists/VirtualizedSectionList.js +++ b/Libraries/Lists/VirtualizedSectionList.js @@ -9,7 +9,7 @@ */ import type {ViewToken} from './ViewabilityHelper'; - +import type {AccessibilityCollectionItem} from './VirtualizedListProps'; import {keyExtractor as defaultKeyExtractor} from './VirtualizeUtils'; import invariant from 'invariant'; import * as React from 'react'; @@ -342,7 +342,16 @@ class VirtualizedSectionList< _renderItem = (listItemCount: number) => // eslint-disable-next-line react/no-unstable-nested-components - ({item, index}: {item: Item, index: number, ...}) => { + ({ + item, + index, + accessibilityCollectionItem, + }: { + item: Item, + index: number, + accessibilityCollectionItem: AccessibilityCollectionItem, + ... + }) => { const info = this._subExtractor(index); if (!info) { return null; @@ -371,6 +380,7 @@ class VirtualizedSectionList< LeadingSeparatorComponent={ infoIndex === 0 ? this.props.SectionSeparatorComponent : undefined } + accessibilityCollectionItem={accessibilityCollectionItem} cellKey={info.key} index={infoIndex} item={item} @@ -486,6 +496,7 @@ type ItemWithSeparatorProps = $ReadOnly<{| updatePropsFor: (prevCellKey: string, value: Object) => void, renderItem: Function, inverted: boolean, + accessibilityCollectionItem: AccessibilityCollectionItem, |}>; function ItemWithSeparator(props: ItemWithSeparatorProps): React.Node { @@ -503,6 +514,7 @@ function ItemWithSeparator(props: ItemWithSeparatorProps): React.Node { index, section, inverted, + accessibilityCollectionItem, } = props; const [leadingSeparatorHiglighted, setLeadingSeparatorHighlighted] = @@ -576,6 +588,7 @@ function ItemWithSeparator(props: ItemWithSeparatorProps): React.Node { index, section, separators, + accessibilityCollectionItem, }); const leadingSeparator = LeadingSeparatorComponent != null && ( { @@ -1542,6 +1543,38 @@ it('calls _onCellLayout properly', () => { expect(mock).not.toHaveBeenCalledWith(event, 'i3', 2); }); +it('renders list with custom column count', () => { + const numColumns = 3; + const component = ReactTestRenderer.create( + { + return ( + + {item.items.map((subitem, index) => ( + + ))} + + ); + }} + getItem={(data, index) => { + const ret = {key: index, items: []}; + for (let i = 0; i < numColumns; i++) { + const item = data[index * numColumns + i]; + if (item != null) { + ret.items.push(item); + } + } + return ret; + }} + numColumns={numColumns} + getItemCount={data => Math.ceil(data.length / numColumns)} + getCellsInItemCount={data => data.length} + />, + ); + expect(component).toMatchSnapshot(); +}); + if (useExperimentalList) { describe('VirtualizedList (Experimental functionality)', () => { it('keeps viewport below last focused rendered', () => { diff --git a/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap b/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap index c8b33bb98df2de..062bf63589c965 100644 --- a/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap +++ b/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap @@ -6,6 +6,14 @@ exports[`FlatList renders all the bells and whistles 1`] = ` ListEmptyComponent={[Function]} ListFooterComponent={[Function]} ListHeaderComponent={[Function]} + accessibilityCollection={ + Object { + "columnCount": 2, + "hierarchical": false, + "itemCount": 5, + "rowCount": 3, + } + } data={ Array [ Object { @@ -29,6 +37,7 @@ exports[`FlatList renders all the bells and whistles 1`] = ` getItemCount={[Function]} getItemLayout={[Function]} keyExtractor={[Function]} + numColumns={2} onContentSizeChange={[Function]} onLayout={[Function]} onMomentumScrollBegin={[Function]} @@ -121,6 +130,14 @@ exports[`FlatList renders all the bells and whistles 1`] = ` exports[`FlatList renders empty list 1`] = ` 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`] = ` `; +exports[`renders list with custom column count 1`] = ` + + + + + + + + + + + + + + + + + +`; + exports[`renders new items when data is updated with non-zero initialScrollIndex 1`] = ` Object { @@ -2358,6 +2542,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`] = ` `; +exports[`renders list with custom column count 1`] = ` + + + + + + + + + + + + + + + + + +`; + exports[`renders new items when data is updated with non-zero initialScrollIndex 1`] = ` { * To see the error, delete this comment and run Flow. */ /* $FlowFixMe[missing-local-annot] The type annotation(s) required by * Flow's LTI update could not be added via codemod */ - [flatListPropKey]: ({item, separators}) => { + [flatListPropKey]: ({item, separators, accessibilityCollectionItem}) => { return ( - + + + ); }, }; diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js b/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js index 66f0fc441532f5..e0bf2fb4e5eeed 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js @@ -10,7 +10,10 @@ 'use strict'; -import type {RenderItemProps} from 'react-native/Libraries/Lists/VirtualizedList'; +import type { + RenderItemProps, + AccessibilityCollectionItem, +} from 'react-native/Libraries/Lists/VirtualizedListProps'; import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; const RNTesterPage = require('../../components/RNTesterPage'); const React = require('react'); @@ -142,9 +145,19 @@ 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, + }: { + item: Item, + accessibilityCollectionItem: AccessibilityCollectionItem, + ... + }) => { return ( - + ( + + {item} + +); + +const renderItem = ({ + item, + separators, + accessibilityCollectionItem, +}: RenderItemProps) => ( + +); + +const renderFlatList = ({item}: RenderItemProps) => { + return ( + + Flatlist {item} + + + ); +}; + +const FlatListNested = (): React.Node => { + 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 aa8ea52ec9899e..0398496a5f7596 100644 --- a/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js +++ b/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js @@ -9,6 +9,7 @@ */ 'use strict'; +import type {AccessibilityCollectionItem} from 'react-native/Libraries/Lists/VirtualizedListProps'; import type {Item} from '../../components/ListExampleShared'; const RNTesterPage = require('../../components/RNTesterPage'); const React = require('react'); @@ -118,7 +119,7 @@ const renderItemComponent = (setItemState: (item: Item) => void) => /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's * LTI update could not be added via codemod */ - ({item, separators}) => { + ({item, separators, accessibilityCollectionItem}) => { if (isNaN(item.key)) { return; } @@ -128,12 +129,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 97ffe9759a1b7c..f781797fe4a73f 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',