diff --git a/packages/virtualized-lists/Lists/ListMetricsAggregator.js b/packages/virtualized-lists/Lists/ListMetricsAggregator.js index b0516e6f7af1..3c3782d5560e 100644 --- a/packages/virtualized-lists/Lists/ListMetricsAggregator.js +++ b/packages/virtualized-lists/Lists/ListMetricsAggregator.js @@ -50,6 +50,7 @@ export type CellMetricProps = { getItem: VirtualizedListProps['getItem'], getItemLayout?: VirtualizedListProps['getItemLayout'], keyExtractor?: VirtualizedListProps['keyExtractor'], + experimental_numColumns?: VirtualizedListProps['experimental_numColumns'], ... }; @@ -168,7 +169,8 @@ export default class ListMetricsAggregator { /** * Returns the exact metrics of a cell if it has already been laid out, * otherwise an estimate based on the average length of previously measured - * cells + * cells. In a grid, cells in the same row share their row's offset and + * length, so estimates count rows rather than cells. */ getCellMetricsApprox(index: number, props: CellMetricProps): CellMetrics { const frame = this.getCellMetrics(index, props); @@ -176,7 +178,10 @@ export default class ListMetricsAggregator { // check for invalid frames due to row re-ordering return frame; } else { + const numColumns = props.experimental_numColumns ?? 1; + const rowIndex = Math.floor(index / numColumns); let offset; + let length = this._averageCellLength; const highestMeasuredCellIndex = this.getHighestMeasuredCellIndex(); if (highestMeasuredCellIndex < index) { @@ -189,15 +194,24 @@ export default class ListMetricsAggregator { props, ); if (highestMeasuredCellFrame) { - offset = - highestMeasuredCellFrame.offset + - highestMeasuredCellFrame.length + - this._averageCellLength * (index - highestMeasuredCellIndex - 1); + const highestMeasuredRowIndex = Math.floor( + highestMeasuredCellIndex / numColumns, + ); + if (rowIndex === highestMeasuredRowIndex) { + offset = highestMeasuredCellFrame.offset; + length = highestMeasuredCellFrame.length; + } else { + offset = + highestMeasuredCellFrame.offset + + highestMeasuredCellFrame.length + + this._averageCellLength * + (rowIndex - highestMeasuredRowIndex - 1); + } } } if (offset == null) { - offset = this._averageCellLength * index; + offset = this._averageCellLength * rowIndex; } const {data, getItemCount} = props; @@ -206,7 +220,7 @@ export default class ListMetricsAggregator { 'Tried to get frame for out of range index ' + index, ); return { - length: this._averageCellLength, + length, offset, index, isMounted: false, diff --git a/packages/virtualized-lists/Lists/VirtualizeUtils.js b/packages/virtualized-lists/Lists/VirtualizeUtils.js index 4e5c6877fc77..87478cb6aba4 100644 --- a/packages/virtualized-lists/Lists/VirtualizeUtils.js +++ b/packages/virtualized-lists/Lists/VirtualizeUtils.js @@ -243,6 +243,28 @@ export function computeWindowedRenderLimits( return {first, last}; } +/** + * Widens a range of cells to whole rows of a grid with `numColumns` columns, + * so that rendered cells and the spacers between them always cover complete + * rows. + */ +export function expandCellsToWholeRows( + cells: {first: number, last: number}, + numColumns: number, + itemCount: number, +): {first: number, last: number} { + if (numColumns <= 1 || cells.last < cells.first) { + return cells; + } + return { + first: cells.first - (cells.first % numColumns), + last: Math.min( + itemCount - 1, + cells.last + numColumns - 1 - (cells.last % numColumns), + ), + }; +} + export function keyExtractor(item: any, index: number): string { if (typeof item === 'object' && item?.key != null) { return item.key; diff --git a/packages/virtualized-lists/Lists/VirtualizedList.d.ts b/packages/virtualized-lists/Lists/VirtualizedList.d.ts index 06d5e63b9973..8273bece5cf1 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.d.ts +++ b/packages/virtualized-lists/Lists/VirtualizedList.d.ts @@ -267,6 +267,15 @@ export interface VirtualizedListWithoutRenderItemProps< keyExtractor?: ((item: ItemT, index: number) => string) | undefined; + /** + * Lays items out in a grid with this many columns. Every item is its own + * cell keyed by `keyExtractor`, so an item keeps its identity when it moves + * to another row. `getItemLayout` receives item indices and should return + * the offset and length of the item's row. Only vertical lists are + * supported, without `ItemSeparatorComponent` or `stickyHeaderIndices`. + */ + experimental_numColumns?: number | null | undefined; + /** * The maximum number of items to render in each incremental render batch. The more rendered at * once, the better the fill rate, but responsiveness may suffer because rendering content may diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index 9d4616f55a96..43b1be8461a3 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -43,12 +43,14 @@ import { horizontalOrDefault, initialNumToRenderOrDefault, maxToRenderPerBatchOrDefault, + numColumnsOrDefault, onEndReachedThresholdOrDefault, onStartReachedThresholdOrDefault, windowSizeOrDefault, } from './VirtualizedListProps'; import { computeWindowedRenderLimits, + expandCellsToWholeRows, keyExtractor as defaultKeyExtractor, } from './VirtualizeUtils'; import invariant from 'invariant'; @@ -596,6 +598,27 @@ class VirtualizedList extends StateSafePureComponent< 'VirtualizedList: The windowSize prop must be present and set to a value greater than 0.', ); + const numColumns = numColumnsOrDefault(props.experimental_numColumns); + invariant( + Number.isInteger(numColumns) && numColumns >= 1, + 'VirtualizedList: experimental_numColumns must be a positive integer.', + ); + if (numColumns > 1) { + invariant( + !horizontalOrDefault(props.horizontal), + 'VirtualizedList: experimental_numColumns does not support horizontal lists.', + ); + invariant( + props.ItemSeparatorComponent == null, + 'VirtualizedList: experimental_numColumns does not support ItemSeparatorComponent.', + ); + invariant( + props.stickyHeaderIndices == null || + props.stickyHeaderIndices.length === 0, + 'VirtualizedList: experimental_numColumns does not support stickyHeaderIndices.', + ); + } + const itemCount = getItemCount(data); if ( @@ -673,18 +696,22 @@ class VirtualizedList extends StateSafePureComponent< ); const renderMask = new CellRenderMask(itemCount); + const numColumns = numColumnsOrDefault(props.experimental_numColumns); + const addCellsInWholeRows = (cells: {first: number, last: number}) => { + renderMask.addCells(expandCellsToWholeRows(cells, numColumns, itemCount)); + }; if (itemCount > 0) { const allRegions = [cellsAroundViewport, ...(additionalRegions ?? [])]; for (const region of allRegions) { - renderMask.addCells(region); + addCellsInWholeRows(region); } // The initially rendered cells are retained as part of the // "scroll-to-top" optimization if (props.initialScrollIndex == null || props.initialScrollIndex <= 0) { const initialRegion = VirtualizedList._initialRenderRegion(props); - renderMask.addCells(initialRegion); + addCellsInWholeRows(initialRegion); } // The layout coordinates of sticky headers may be off-screen while the @@ -950,6 +977,7 @@ class VirtualizedList extends StateSafePureComponent< renderItem, } = this.props; const stickyOffset = ListHeaderComponent ? 1 : 0; + const numColumns = numColumnsOrDefault(this.props.experimental_numColumns); const end = getItemCount(data) - 1; let prevCellKey; last = Math.min(end, last); @@ -977,6 +1005,7 @@ class VirtualizedList extends StateSafePureComponent< inversionStyle={inversionStyle} item={item} key={key} + numColumns={numColumns} prevCellKey={prevCellKey} onUpdateSeparators={this._onUpdateSeparators} onCellFocusCapture={this._onCellFocusCapture} @@ -1138,6 +1167,9 @@ class VirtualizedList extends StateSafePureComponent< ? styles.horizontallyInverted : styles.verticallyInverted : null; + const isGridLayout = + numColumnsOrDefault(this.props.experimental_numColumns) > 1; + const fullWidthStyle = isGridLayout ? styles.gridFullWidth : null; const cells: Array = []; // Avoid allocating a Set on every render when no sticky headers are // configured (the common case). @@ -1169,8 +1201,11 @@ class VirtualizedList extends StateSafePureComponent< collapsable={false} onLayout={this._onLayoutHeader} style={StyleSheet.compose( - inversionStyle, - this.props.ListHeaderComponentStyle, + fullWidthStyle, + StyleSheet.compose( + inversionStyle, + this.props.ListHeaderComponentStyle, + ), )}> { // $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors @@ -1197,7 +1232,10 @@ class VirtualizedList extends StateSafePureComponent< - {this._renderEmptyComponent(element, inversionStyle)} + {this._renderEmptyComponent( + element, + StyleSheet.compose(fullWidthStyle, inversionStyle), + )} , ); } @@ -1247,7 +1285,9 @@ class VirtualizedList extends StateSafePureComponent< , ); } else { @@ -1288,8 +1328,11 @@ class VirtualizedList extends StateSafePureComponent< { // $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors @@ -1303,6 +1346,18 @@ class VirtualizedList extends StateSafePureComponent< // 4. Render the ScrollView const scrollProps = { ...this.props, + ...(isGridLayout + ? { + contentContainerStyle: StyleSheet.compose( + this.props.contentContainerStyle, + // Android inverts lists on both axes, so reverse each row to + // keep items in reading order once the list is flipped back. + this.props.inverted && Platform.OS === 'android' + ? styles.gridContentContainerAndroidInverted + : styles.gridContentContainer, + ), + } + : null), onContentSizeChange: this._onContentSizeChange, onLayout: this._onLayout, onScroll: this._onScroll, @@ -2301,6 +2356,17 @@ class VirtualizedList extends StateSafePureComponent< } const styles = StyleSheet.create({ + gridContentContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + }, + gridContentContainerAndroidInverted: { + flexDirection: 'row-reverse', + flexWrap: 'wrap', + }, + gridFullWidth: { + width: '100%', + }, verticallyInverted: Platform.OS === 'android' ? {transform: [{scale: -1}]} diff --git a/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js b/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js index 35573a9d703c..8ec09e8ae308 100644 --- a/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js +++ b/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js @@ -34,6 +34,7 @@ export type Props = { index: number, inversionStyle: StyleProp, item: ItemT, + numColumns?: ?number, onCellLayout?: ( event: LayoutChangeEvent, cellKey: string, @@ -183,6 +184,7 @@ export default class CellRenderer extends React.PureComponent< item, index, inversionStyle, + numColumns, onCellLayout, renderItem, } = this.props; @@ -204,13 +206,16 @@ export default class CellRenderer extends React.PureComponent< // $FlowFixMe[not-a-component] ); - const cellStyle = inversionStyle - ? horizontal - ? [styles.rowReverse, inversionStyle] - : [styles.columnReverse, inversionStyle] - : horizontal - ? [styles.row, inversionStyle] - : inversionStyle; + const cellStyle = + numColumns != null && numColumns > 1 + ? [getGridCellStyle(numColumns), inversionStyle] + : inversionStyle + ? horizontal + ? [styles.rowReverse, inversionStyle] + : [styles.columnReverse, inversionStyle] + : horizontal + ? [styles.row, inversionStyle] + : inversionStyle; const result = !CellRendererComponent ? ( extends React.PureComponent< } } +const gridCellStyles: Map = new Map(); + +// Cells use a row direction so that an item with `flex: 1` fills its column, +// as it does inside a row of a FlatList with `numColumns`. +function getGridCellStyle(numColumns: number): ViewStyle { + let gridCellStyle = gridCellStyles.get(numColumns); + if (gridCellStyle == null) { + // Rounded down so that a full row of cells never adds up to more than + // 100% and wraps early because of floating point error. + const widthPercent = Math.floor((100 / numColumns) * 10000) / 10000; + gridCellStyle = {flexDirection: 'row', width: `${widthPercent}%`}; + gridCellStyles.set(numColumns, gridCellStyle); + } + return gridCellStyle; +} + const styles = StyleSheet.create({ row: { flexDirection: 'row', diff --git a/packages/virtualized-lists/Lists/VirtualizedListProps.js b/packages/virtualized-lists/Lists/VirtualizedListProps.js index f5675959f853..156b0ffb4e76 100644 --- a/packages/virtualized-lists/Lists/VirtualizedListProps.js +++ b/packages/virtualized-lists/Lists/VirtualizedListProps.js @@ -87,6 +87,14 @@ type OptionalVirtualizedListProps = { * `data` prop, stick it here and treat it immutably. */ extraData?: any, + /** + * Lays items out in a grid with this many columns. Every item is its own + * cell keyed by `keyExtractor`, so an item keeps its identity when it moves + * to another row. `getItemLayout` receives item indices and should return + * the offset and length of the item's row. Only vertical lists are + * supported, without `ItemSeparatorComponent` or `stickyHeaderIndices`. + */ + experimental_numColumns?: ?number, // e.g. height, y getItemLayout?: ( data: any, @@ -328,6 +336,11 @@ export function onEndReachedThresholdOrDefault( return onEndReachedThreshold ?? 2; } +// numColumnsOrDefault(this.props.experimental_numColumns) +export function numColumnsOrDefault(numColumns: ?number): number { + return numColumns ?? 1; +} + // windowSizeOrDefault(this.props.windowSize) export function windowSizeOrDefault(windowSize: ?number): number { return windowSize ?? 21; diff --git a/packages/virtualized-lists/Lists/__tests__/ListMetricsAggregator-test.js b/packages/virtualized-lists/Lists/__tests__/ListMetricsAggregator-test.js index 36b50e88e867..8d43f05343d8 100644 --- a/packages/virtualized-lists/Lists/__tests__/ListMetricsAggregator-test.js +++ b/packages/virtualized-lists/Lists/__tests__/ListMetricsAggregator-test.js @@ -1047,4 +1047,59 @@ describe('ListMetricsAggregator', () => { }), ).toThrow(); }); + + it('estimates metrics of unmeasured cells by row in a grid', () => { + const listMetrics = new ListMetricsAggregator(); + const orientation = {horizontal: false, rtl: false}; + const props: CellMetricProps = { + data: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10], + getItemCount: () => nullthrows(props.data).length, + getItem: (i: number) => nullthrows(props.data)[i], + experimental_numColumns: 3, + }; + + // Row 0 (cells 0-2) is 10 tall at y=100. Cell 3 starts row 1 and is 20 + // tall. + for (const cellIndex of [0, 1, 2]) { + listMetrics.notifyCellLayout({ + cellIndex, + cellKey: String(cellIndex), + orientation, + layout: {height: 10, width: 5, x: 5 * cellIndex, y: 100}, + }); + } + listMetrics.notifyCellLayout({ + cellIndex: 3, + cellKey: '3', + orientation, + layout: {height: 20, width: 5, x: 0, y: 110}, + }); + + // Same row as the last measured cell, so same offset and length. + expect(listMetrics.getCellMetricsApprox(5, props)).toEqual({ + index: 5, + length: 20, + offset: 110, + isMounted: false, + }); + // Rows after it use the average cell length, (3 * 10 + 20) / 4. + expect(listMetrics.getCellMetricsApprox(6, props)).toEqual({ + index: 6, + length: 12.5, + offset: 130, + isMounted: false, + }); + expect(listMetrics.getCellMetricsApprox(8, props)).toEqual({ + index: 8, + length: 12.5, + offset: 130, + isMounted: false, + }); + expect(listMetrics.getCellMetricsApprox(10, props)).toEqual({ + index: 10, + length: 12.5, + offset: 142.5, + isMounted: false, + }); + }); }); diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js index a0853f918b9a..e8f8e679a9fd 100644 --- a/packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js @@ -16,6 +16,7 @@ import ListMetricsAggregator from '../ListMetricsAggregator'; import { computeWindowedRenderLimits, elementsThatOverlapOffsets, + expandCellsToWholeRows, newRangeCount, } from '../VirtualizeUtils'; import {ReactNativeFeatureFlags} from 'react-native/react-private-interface'; @@ -292,3 +293,37 @@ describe('computeWindowedRenderLimits', function () { expect(result).toEqual({first: 0, last: 4}); }); }); + +describe('expandCellsToWholeRows', function () { + it('leaves ranges unchanged with a single column', function () { + expect(expandCellsToWholeRows({first: 4, last: 7}, 1, 20)).toEqual({ + first: 4, + last: 7, + }); + }); + + it('widens a range to whole rows', function () { + expect(expandCellsToWholeRows({first: 4, last: 7}, 3, 20)).toEqual({ + first: 3, + last: 8, + }); + expect(expandCellsToWholeRows({first: 3, last: 8}, 3, 20)).toEqual({ + first: 3, + last: 8, + }); + }); + + it('stops at the last item of a partial last row', function () { + expect(expandCellsToWholeRows({first: 7, last: 9}, 3, 10)).toEqual({ + first: 6, + last: 9, + }); + }); + + it('leaves empty ranges unchanged', function () { + expect(expandCellsToWholeRows({first: 0, last: -1}, 3, 0)).toEqual({ + first: 0, + last: -1, + }); + }); +}); diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js index e270af4649c9..26a3dda0ea5a 100644 --- a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js @@ -12,6 +12,7 @@ import VirtualizedList from '../VirtualizedList'; import {format} from 'node:util'; import * as React from 'react'; import {createElement, createRef} from 'react'; +import {StyleSheet} from 'react-native'; import {act, create} from 'react-test-renderer'; jest.useFakeTimers(); @@ -3048,6 +3049,270 @@ it('handles rapid prepends with coalesced scroll event (regression for #53542)', ).toBeGreaterThanOrEqual(0); }); +describe('experimental_numColumns', () => { + function gridItemLayoutProps(rowHeight, numColumns) { + return { + getItemLayout: (_, index) => ({ + length: rowHeight, + offset: rowHeight * Math.floor(index / numColumns), + index, + }), + }; + } + + function renderedItemKeys(component) { + return component.root + .findAllByType('MockCellItem') + .map(cellItem => cellItem.props.value); + } + + function expectWholeRows(itemKeys, numColumns, itemCount) { + const renderedKeys = new Set(itemKeys); + for (const key of itemKeys) { + const rowStart = key - (key % numColumns); + const rowEnd = Math.min(itemCount, rowStart + numColumns); + for (let rowKey = rowStart; rowKey < rowEnd; rowKey++) { + expect(renderedKeys.has(rowKey)).toBe(true); + } + } + } + + it('lays cells out in a wrapping grid with full-width header and footer', async () => { + const items = generateItems(5); + let component; + await act(() => { + component = create( +
} + ListFooterComponent={() =>