diff --git a/packages/react-native/Libraries/Lists/FlatList.js b/packages/react-native/Libraries/Lists/FlatList.js index 43256b3f79db..2f618cea1f8d 100644 --- a/packages/react-native/Libraries/Lists/FlatList.js +++ b/packages/react-native/Libraries/Lists/FlatList.js @@ -177,6 +177,78 @@ function isArrayLike(data: unknown): boolean { return typeof Object(data).length === 'number'; } +function getItemCountForAccessibility( + data: ?Readonly<$ArrayLike>, +): number { + return data != null && isArrayLike(data) ? data.length : 0; +} + +function createAccessibilityCollection( + data: ?Readonly<$ArrayLike>, + numColumns: number, +): { + itemCount: number, + rowCount: number, + columnCount: number, + hierarchical: boolean, +} { + const itemCount = getItemCountForAccessibility(data); + return { + itemCount, + rowCount: numColumns > 1 ? Math.ceil(itemCount / numColumns) : itemCount, + columnCount: numColumns, + hierarchical: false, + }; +} + +type AccessibilityCollectionItem = Readonly<{ + itemIndex: number, + rowIndex: number, + rowSpan: number, + columnIndex: number, + columnSpan: number, + heading: boolean, + ... +}>; + +function createAccessibilityCollectionItem( + itemIndex: number, + rowIndex: number, + columnIndex: number, + horizontal: boolean, +): AccessibilityCollectionItem { + return { + itemIndex, + rowIndex: horizontal ? 0 : rowIndex, + rowSpan: 1, + columnIndex: horizontal ? itemIndex : columnIndex, + columnSpan: 1, + heading: false, + }; +} + +function addAccessibilityCollectionItem( + element: React.Node, + accessibilityCollectionItem: ?AccessibilityCollectionItem, +): React.Node { + if ( + accessibilityCollectionItem == null || + !React.isValidElement(element) || + element.type === React.Fragment + ) { + return element; + } + + // $FlowFixMe[prop-missing] React.Element internal inspection. + if (element.props.accessibilityCollectionItem !== undefined) { + return element; + } + + return React.cloneElement(element, { + accessibilityCollectionItem, + }); +} + type FlatListBaseProps = { ...RequiredFlatListProps, ...OptionalFlatListProps, @@ -634,6 +706,8 @@ class FlatList extends React.PureComponent> { }; const renderProp = (info: ListRenderItemInfo) => { + const isAndroid = Platform.OS === 'android'; + const isHorizontal = this.props.horizontal === true; if (cols > 1) { const {item, index} = info; invariant( @@ -641,22 +715,49 @@ class FlatList extends React.PureComponent> { 'Expected array of items with numColumns > 1', ); return ( - + {item.map((it, kk) => { + const itemIndex = index * cols + kk; + const itemAccessibilityCollectionItem = isAndroid + ? createAccessibilityCollectionItem( + itemIndex, + index, + kk, + isHorizontal, + ) + : undefined; const element = render({ // $FlowFixMe[incompatible-type] item: it, - index: index * cols + kk, + index: itemIndex, separators: info.separators, }); return element != null ? ( - {element} + + {addAccessibilityCollectionItem( + element, + itemAccessibilityCollectionItem, + )} + ) : null; })} ); } else { - return render(info); + const itemAccessibilityCollectionItem = isAndroid + ? createAccessibilityCollectionItem( + info.index, + info.index, + 0, + isHorizontal, + ) + : undefined; + return addAccessibilityCollectionItem( + render(info), + itemAccessibilityCollectionItem, + ); } }; @@ -677,11 +778,28 @@ class FlatList extends React.PureComponent> { } = this.props; const renderer = strictMode ? this._memoizedRenderer : this._renderer; + const numColumnsValue = numColumnsOrDefault(numColumns); + const androidAccessibilityProps = + Platform.OS === 'android' + ? { + accessibilityCollection: + // $FlowFixMe[prop-missing] Internal native prop. + this.props.accessibilityCollection ?? + createAccessibilityCollection(this.props.data, numColumnsValue), + accessibilityRole: + this.props.role == null && this.props.accessibilityRole == null + ? numColumnsValue > 1 + ? 'grid' + : 'list' + : this.props.accessibilityRole, + } + : {}; return ( // $FlowFixMe[incompatible-exact] - `restProps` (`Props`) is inexact. { ); expect(component).toMatchSnapshot(); }); + it('adds Android accessibility collection metadata to list items', async () => { + const originalOS = Platform.OS; + // $FlowFixMe[incompatible-type] Platform.OS is read-only in production. + Platform.OS = 'android'; + + try { + const component = await create( + } + />, + ); + + const root = component.toJSON(); + expect(root?.props.accessibilityRole).toBe('list'); + expect(root?.props.accessibilityCollection).toEqual({ + itemCount: 3, + rowCount: 3, + columnCount: 1, + hierarchical: false, + }); + expect( + component.root + .findAllByType('item') + .map(item => item.props.accessibilityCollectionItem), + ).toEqual([ + { + itemIndex: 0, + rowIndex: 0, + rowSpan: 1, + columnIndex: 0, + columnSpan: 1, + heading: false, + }, + { + itemIndex: 1, + rowIndex: 1, + rowSpan: 1, + columnIndex: 0, + columnSpan: 1, + heading: false, + }, + { + itemIndex: 2, + rowIndex: 2, + rowSpan: 1, + columnIndex: 0, + columnSpan: 1, + heading: false, + }, + ]); + } finally { + // $FlowFixMe[incompatible-type] Platform.OS is read-only in production. + Platform.OS = originalOS; + } + }); + it('adds Android accessibility collection metadata to multi-column list items', async () => { + const originalOS = Platform.OS; + // $FlowFixMe[incompatible-type] Platform.OS is read-only in production. + Platform.OS = 'android'; + + try { + const component = await create( + } + numColumns={2} + />, + ); + + const root = component.toJSON(); + expect(root?.props.accessibilityRole).toBe('grid'); + expect(root?.props.accessibilityCollection).toEqual({ + itemCount: 5, + rowCount: 3, + columnCount: 2, + hierarchical: false, + }); + expect( + component.root + .findAllByType('item') + .map(item => item.props.accessibilityCollectionItem), + ).toEqual([ + { + itemIndex: 0, + rowIndex: 0, + rowSpan: 1, + columnIndex: 0, + columnSpan: 1, + heading: false, + }, + { + itemIndex: 1, + rowIndex: 0, + rowSpan: 1, + columnIndex: 1, + columnSpan: 1, + heading: false, + }, + { + itemIndex: 2, + rowIndex: 1, + rowSpan: 1, + columnIndex: 0, + columnSpan: 1, + heading: false, + }, + { + itemIndex: 3, + rowIndex: 1, + rowSpan: 1, + columnIndex: 1, + columnSpan: 1, + heading: false, + }, + { + itemIndex: 4, + rowIndex: 2, + rowSpan: 1, + columnIndex: 0, + columnSpan: 1, + heading: false, + }, + ]); + } finally { + // $FlowFixMe[incompatible-type] Platform.OS is read-only in production. + Platform.OS = originalOS; + } + }); it('renders simple list using ListItemComponent', async () => { function ListItemComponent({item}: Readonly<{item: {key: string}}>) { return ; diff --git a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegate.kt b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegate.kt index 56c6224230ab..e3c4e0e60a1c 100644 --- a/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegate.kt +++ b/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollViewAccessibilityDelegate.kt @@ -70,33 +70,13 @@ internal class ReactScrollViewAccessibilityDelegate : AccessibilityDelegateCompa } else { return } - var accessibilityCollectionItem: ReadableMap? = - nextChild.getTag(R.id.accessibility_collection_item) as ReadableMap + val accessibilityCollectionItemRange = findAccessibilityCollectionItemRange(nextChild) - if (nextChild !is ViewGroup) { - return - } - - // 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 (nextChild.childCount > 0 && accessibilityCollectionItem == null) { - val nestedNextChild = nextChild.getChildAt(0) - if (nestedNextChild != null) { - val nestedChildAccessibility = - nestedNextChild.getTag(R.id.accessibility_collection_item) as? ReadableMap - if (nestedChildAccessibility != null) { - accessibilityCollectionItem = nestedChildAccessibility - } - } - } - - if (isVisible && accessibilityCollectionItem != null) { + if (isVisible && accessibilityCollectionItemRange != null) { if (firstVisibleIndex == null) { - firstVisibleIndex = accessibilityCollectionItem.getInt("itemIndex") + firstVisibleIndex = accessibilityCollectionItemRange.first } - lastVisibleIndex = accessibilityCollectionItem.getInt("itemIndex") + lastVisibleIndex = accessibilityCollectionItemRange.second } if (firstVisibleIndex != null && lastVisibleIndex != null) { @@ -106,6 +86,36 @@ internal class ReactScrollViewAccessibilityDelegate : AccessibilityDelegateCompa } } + private fun findAccessibilityCollectionItemRange(view: View): Pair? { + val accessibilityCollectionItem = + view.getTag(R.id.accessibility_collection_item) as? ReadableMap + if (accessibilityCollectionItem != null) { + val itemIndex = accessibilityCollectionItem.getInt("itemIndex") + return Pair(itemIndex, itemIndex) + } + + if (view !is ViewGroup) { + return null + } + + var firstItemIndex: Int? = null + var lastItemIndex: Int? = null + for (index in 0..; + function getScrollingThreshold(threshold: number, visibleLength: number) { return (threshold * visibleLength) / 2; } +function createAccessibilityCollection( + itemCount: number, + horizontal: boolean, +): { + itemCount: number, + rowCount: number, + columnCount: number, + hierarchical: boolean, +} { + return { + itemCount, + rowCount: horizontal ? (itemCount > 0 ? 1 : 0) : itemCount, + columnCount: horizontal ? itemCount : 1, + hierarchical: false, + }; +} + +function createAccessibilityCollectionItem( + index: number, + horizontal: ?boolean, +): AccessibilityCollectionItem { + return { + itemIndex: index, + rowIndex: horizontal === true ? 0 : index, + rowSpan: 1, + columnIndex: horizontal === true ? index : 0, + columnSpan: 1, + heading: false, + }; +} + /** * Base implementation for the more convenient [``](https://reactnative.dev/docs/flatlist) * and [``](https://reactnative.dev/docs/sectionlist) components, which are also better @@ -811,6 +852,11 @@ class VirtualizedList extends StateSafePureComponent< CellRendererComponent={CellRendererComponent} ItemSeparatorComponent={ii < end ? ItemSeparatorComponent : undefined} ListItemComponent={ListItemComponent} + accessibilityCollectionItem={ + Platform.OS === 'android' + ? createAccessibilityCollectionItem(ii, horizontal) + : undefined + } cellKey={key} horizontal={horizontal} index={ii} @@ -1121,6 +1167,23 @@ class VirtualizedList extends StateSafePureComponent< : undefined, }; + if (Platform.OS === 'android' && !this._isNestedWithSameOrientation()) { + const scrollPropsForAndroid: any = scrollProps; + if (scrollPropsForAndroid.accessibilityCollection == null) { + scrollPropsForAndroid.accessibilityCollection = + createAccessibilityCollection( + itemCount, + horizontalOrDefault(this.props.horizontal), + ); + } + if ( + scrollPropsForAndroid.role == null && + scrollPropsForAndroid.accessibilityRole == null + ) { + scrollPropsForAndroid.accessibilityRole = 'list'; + } + } + this._hasMore = this.state.cellsAroundViewport.last < itemCount - 1; const innerRet = ( diff --git a/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js b/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js index 35573a9d703c..847eca459588 100644 --- a/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js +++ b/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js @@ -22,6 +22,16 @@ import * as React from 'react'; import {isValidElement} from 'react'; import {StyleSheet, View} from 'react-native'; +type AccessibilityCollectionItem = Readonly<{ + itemIndex: number, + rowIndex: number, + rowSpan: number, + columnIndex: number, + columnSpan: number, + heading: boolean, + ... +}>; + export type Props = { CellRendererComponent?: ?React.ComponentType>, ItemSeparatorComponent?: ?( @@ -29,6 +39,7 @@ export type Props = { | React.MixedElement ), ListItemComponent?: ?(React.ComponentType | React.MixedElement), + accessibilityCollectionItem?: ?AccessibilityCollectionItem, cellKey: string, horizontal: ?boolean, index: number, @@ -50,6 +61,28 @@ export type Props = { ... }; +function addAccessibilityCollectionItem( + element: React.Node, + accessibilityCollectionItem: ?AccessibilityCollectionItem, +): React.Node { + if ( + accessibilityCollectionItem == null || + !React.isValidElement(element) || + element.type === React.Fragment + ) { + return element; + } + + // $FlowFixMe[prop-missing] React.Element internal inspection. + if (element.props.accessibilityCollectionItem !== undefined) { + return element; + } + + return React.cloneElement(element, { + accessibilityCollectionItem, + }); +} + type SeparatorProps = Readonly<{ highlighted: boolean, leadingItem: ?ItemT, @@ -178,6 +211,7 @@ export default class CellRenderer extends React.PureComponent< CellRendererComponent, ItemSeparatorComponent, ListItemComponent, + accessibilityCollectionItem, cellKey, horizontal, item, @@ -186,11 +220,9 @@ export default class CellRenderer extends React.PureComponent< onCellLayout, renderItem, } = this.props; - const element = this._renderElement( - renderItem, - ListItemComponent, - item, - index, + const element = addAccessibilityCollectionItem( + this._renderElement(renderItem, ListItemComponent, item, index), + accessibilityCollectionItem, ); // NOTE: that when this is a sticky header, `onLayout` will get automatically extracted and