From bd37d267b0329a01feeeaaa65f994d8a17609283 Mon Sep 17 00:00:00 2001 From: Ramya Ramanathan Date: Tue, 13 Oct 2026 07:11:55 -0700 Subject: [PATCH 1/2] Add experimental grid layout to VirtualizedList (#59038) Summary: `FlatList` builds multi-column lists by grouping items into one row cell per row and keying each row by the keys of its items. Adding or removing an item shifts every later item into a different row, so every later row gets a new key and React remounts all of its items: they lose their state and replay their entry animations (https://github.com/facebook/react-native/issues/45895). This adds `experimental_numColumns` to `VirtualizedList` so the list can lay out the grid itself instead. Every item is rendered as its own cell, a direct child of the list keyed by `keyExtractor`, so an item keeps its identity when it moves to another row: - The content container wraps cells into rows (`flexDirection: 'row'`, `flexWrap: 'wrap'`). Each cell is `100% / numColumns` wide and uses a row direction, so an item with `flex: 1` fills its column the way it fills its share of a `FlatList` row today. The header, footer, empty component and spacers are full width. - Render windows are widened to whole rows (`expandCellsToWholeRows`) wherever the render mask is built, so rendered cells and spacers always cover complete rows. - `ListMetricsAggregator` estimates unmeasured cells by row, since cells in a row share their row's offset and length. - Viewability is reported per item without any change, because every item is its own cell. - `getItemLayout` receives item indices and returns the offset and length of the item's row. - On Android, inverted lists are flipped on both axes, so rows are reversed in the content container to keep items in reading order. Horizontal lists, `ItemSeparatorComponent` and `stickyHeaderIndices` are not supported with `experimental_numColumns` yet and throw. `FlatList` does not use the prop yet; that follows in the next diff behind a feature flag. Changelog: [General][Added] - Add experimental `experimental_numColumns` prop to `VirtualizedList` to lay out items in a grid with one cell per item Differential Revision: D124984957 --- .../Lists/ListMetricsAggregator.js | 28 +- .../Lists/VirtualizeUtils.js | 22 ++ .../Lists/VirtualizedList.d.ts | 9 + .../Lists/VirtualizedList.js | 82 +++++- .../Lists/VirtualizedListCellRenderer.js | 35 ++- .../Lists/VirtualizedListProps.js | 13 + .../__tests__/ListMetricsAggregator-test.js | 55 ++++ .../Lists/__tests__/VirtualizeUtils-test.js | 35 +++ .../Lists/__tests__/VirtualizedList-test.js | 265 ++++++++++++++++++ 9 files changed, 522 insertions(+), 22 deletions(-) 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={() =>