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',