diff --git a/packages/react-native/Libraries/Lists/FlatList.js b/packages/react-native/Libraries/Lists/FlatList.js index c2770b947b43..c1383597da10 100644 --- a/packages/react-native/Libraries/Lists/FlatList.js +++ b/packages/react-native/Libraries/Lists/FlatList.js @@ -333,7 +333,14 @@ class FlatList extends React.PureComponent> { ... }) { if (this._listRef) { - this._listRef.scrollToIndex(params); + this._listRef.scrollToIndex( + this._usesGridLayout() + ? { + ...params, + index: params.index * numColumnsOrDefault(this.props.numColumns), + } + : params, + ); } } @@ -518,15 +525,78 @@ class FlatList extends React.PureComponent> { ); } + // With the grid layout, VirtualizedList lays out one cell per item. + // Features that depend on one cell per row keep the row layout. + _usesGridLayout(): boolean { + const { + CellRendererComponent, + ItemSeparatorComponent, + columnWrapperStyle, + stickyHeaderIndices, + } = this.props; + return ( + numColumnsOrDefault(this.props.numColumns) > 1 && + ReactNativeFeatureFlags.enableFlatListGridLayout() && + // The Windows fork of VirtualizedList does not support grids. + (Platform.OS as string) !== 'windows' && + columnWrapperStyle == null && + ItemSeparatorComponent == null && + CellRendererComponent == null && + (stickyHeaderIndices == null || stickyHeaderIndices.length === 0) + ); + } + + _itemsPerCell(): number { + return this._usesGridLayout() + ? 1 + : numColumnsOrDefault(this.props.numColumns); + } + + // Indices passed to `getItemLayout`, `initialScrollIndex` and + // `maintainVisibleContentPosition` refer to rows when `numColumns > 1`, so + // they are converted to and from the item indices used by the grid. + _getGridLayoutProps: ( + numColumns: number, + getItemLayout: FlatListProps['getItemLayout'], + initialScrollIndex: FlatListProps['initialScrollIndex'], + maintainVisibleContentPosition: FlatListProps['maintainVisibleContentPosition'], + ) => Partial = memoizeOne( + ( + numColumns, + getItemLayout, + initialScrollIndex, + maintainVisibleContentPosition, + ) => ({ + experimental_numColumns: numColumns, + getItemLayout: + getItemLayout == null + ? undefined + : (data, itemIndex) => + getItemLayout(data, Math.floor(itemIndex / numColumns)), + initialScrollIndex: + initialScrollIndex == null + ? initialScrollIndex + : initialScrollIndex * numColumns, + maintainVisibleContentPosition: + maintainVisibleContentPosition == null + ? maintainVisibleContentPosition + : { + ...maintainVisibleContentPosition, + minIndexForVisible: + maintainVisibleContentPosition.minIndexForVisible * numColumns, + }, + }), + ); + _getItem = ( data: $ArrayLike, index: number, ): ?(ItemT | ReadonlyArray) => { - const numColumns = numColumnsOrDefault(this.props.numColumns); - if (numColumns > 1) { + const itemsPerCell = this._itemsPerCell(); + if (itemsPerCell > 1) { const ret = []; - for (let kk = 0; kk < numColumns; kk++) { - const itemIndex = index * numColumns + kk; + for (let kk = 0; kk < itemsPerCell; kk++) { + const itemIndex = index * itemsPerCell + kk; if (itemIndex < data.length) { const item = data[itemIndex]; ret.push(item); @@ -546,27 +616,29 @@ class FlatList extends React.PureComponent> { // available to prevent it from trying to read from the invalid data // (without propagating invalidly typed data). if (data != null && isArrayLike(data)) { - const numColumns = numColumnsOrDefault(this.props.numColumns); - return numColumns > 1 ? Math.ceil(data.length / numColumns) : data.length; + const itemsPerCell = this._itemsPerCell(); + return itemsPerCell > 1 + ? Math.ceil(data.length / itemsPerCell) + : data.length; } else { return 0; } }; _keyExtractor = (items: ItemT | Array, index: number): string => { - const numColumns = numColumnsOrDefault(this.props.numColumns); + const itemsPerCell = this._itemsPerCell(); const keyExtractor = this.props.keyExtractor ?? defaultKeyExtractor; - if (numColumns > 1) { + if (itemsPerCell > 1) { invariant( Array.isArray(items), 'FlatList: Encountered internal consistency error, expected each item to consist of an ' + 'array with 1-%s columns; instead, received a single item.', - numColumns, + itemsPerCell, ); return items .map((item, kk) => - keyExtractor(item as $FlowFixMe as ItemT, index * numColumns + kk), + keyExtractor(item as $FlowFixMe as ItemT, index * itemsPerCell + kk), ) .join(':'); } @@ -576,11 +648,11 @@ class FlatList extends React.PureComponent> { }; _pushMultiColumnViewable(arr: Array, v: ListViewToken): void { - const numColumns = numColumnsOrDefault(this.props.numColumns); + const itemsPerCell = this._itemsPerCell(); const keyExtractor = this.props.keyExtractor ?? defaultKeyExtractor; v.item.forEach((item, ii) => { invariant(v.index != null, 'Missing index!'); - const index = v.index * numColumns + ii; + const index = v.index * itemsPerCell + ii; arr.push({...v, item, key: keyExtractor(item, index), index}); }); } @@ -598,9 +670,8 @@ class FlatList extends React.PureComponent> { changed: Array, ... }) => { - const numColumns = numColumnsOrDefault(this.props.numColumns); if (onViewableItemsChanged) { - if (numColumns > 1) { + if (this._itemsPerCell() > 1) { const changed: Array = []; const viewableItems: Array = []; info.viewableItems.forEach(v => @@ -684,11 +755,20 @@ class FlatList extends React.PureComponent> { } = this.props; const renderer = strictMode ? this._memoizedRenderer : this._renderer; + const gridLayoutProps = this._usesGridLayout() + ? this._getGridLayoutProps( + numColumnsOrDefault(numColumns), + restProps.getItemLayout, + restProps.initialScrollIndex, + restProps.maintainVisibleContentPosition, + ) + : null; return ( // $FlowFixMe[incompatible-exact] - `restProps` (`Props`) is inexact. extends React.PureComponent> { this.props.ListItemComponent, this.props.renderItem, columnWrapperStyle, - numColumns, + this._itemsPerCell(), this.props.extraData, )} /> diff --git a/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js b/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js index 5c9848028a46..0daf41340316 100644 --- a/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js +++ b/packages/react-native/Libraries/Lists/__tests__/FlatList-itest.js @@ -6,6 +6,7 @@ * * @flow strict-local * @format + * @fantom_flags enableFlatListGridLayout:* */ import '@react-native/fantom/src/setUpDefaultReactNativeEnvironment'; @@ -16,6 +17,11 @@ import nullthrows from 'nullthrows'; import * as React from 'react'; import {createRef} from 'react'; import {FlatList, Text, View} from 'react-native'; +import {ReactNativeFeatureFlags} from 'react-native/react-private-interface'; + +const LONE_LAST_ITEM_WIDTH = ReactNativeFeatureFlags.enableFlatListGridLayout() + ? 200 + : 400; function testPropPropagatedToMountingLayer({ propName, @@ -298,8 +304,54 @@ describe('', () => { ); }); - // Items 1 and 2 share the first row (each 200px wide), - // item 3 is alone on the second row (full 400px width). + // Items 1 and 2 share the first row (each 200px wide). Item 3 is alone on + // the second row: its column is 200px wide in the grid layout, while a + // row cell stretches it to the full 400px. + expect( + root + .getRenderedOutput({ + includeLayoutMetrics: true, + props: ['layoutMetrics-frame'], + }) + .toJSX(), + ).toEqual( + + + + + + + , + ); + }); + it('keeps row cells when columnWrapperStyle is set', () => { + const root = Fantom.createRoot({ + viewportWidth: 400, + viewportHeight: 600, + }); + Fantom.runTask(() => { + root.render( + ( + + )} + />, + ); + }); + + // Item 3 is stretched across its row cell in both layouts. expect( root .getRenderedOutput({ @@ -326,6 +378,196 @@ describe('', () => { , ); }); + + it('passes row indices to getItemLayout and scrollToIndex', () => { + const root = Fantom.createRoot(); + const flatListRef = createRef>(); + const getItemLayoutIndices: Array = []; + Fantom.runTask(() => { + root.render( + ({key: String(i)}))} + numColumns={2} + renderItem={() => } + getItemLayout={( + _data: ?Readonly<$ArrayLike<{key: string}>>, + index: number, + ) => { + getItemLayoutIndices.push(index); + return {length: 50, offset: 50 * index, index}; + }} + />, + ); + }); + + // 20 items in 2 columns make 10 rows. + expect(Math.max(...getItemLayoutIndices)).toBeLessThan(10); + + root.takeMountingManagerLogs(); + Fantom.runTask(() => { + nullthrows(flatListRef.current).scrollToIndex({ + index: 3, + animated: false, + }); + }); + expect(root.takeMountingManagerLogs()).toEqual([ + 'Command {type: "ScrollView", nativeID: "grid-list", name: "scrollTo, args: [0,150,false]"}', + ]); + }); + + it('reports items that move into view when an item is removed', () => { + const root = Fantom.createRoot({ + viewportWidth: 400, + viewportHeight: 100, + }); + const onViewableItemsChanged = jest.fn(); + const flatListRef = createRef>(); + const renderList = (keys: ReadonlyArray) => ( + ({key}))} + numColumns={2} + renderItem={() => ( + + )} + getItemLayout={( + _data: ?Readonly<$ArrayLike<{key: string}>>, + index: number, + ) => ({length: 50, offset: 50 * index, index})} + viewabilityConfig={{itemVisiblePercentThreshold: 50}} + onViewableItemsChanged={onViewableItemsChanged} + initialNumToRender={20} + /> + ); + const viewableKeys = () => + onViewableItemsChanged.mock.calls[ + onViewableItemsChanged.mock.calls.length - 1 + ][0].viewableItems.map((token: {key: string, ...}) => token.key); + const keys = Array.from({length: 20}, (_, i) => String(i)); + const timers = Fantom.installTimerMock(); + try { + Fantom.runTask(() => { + root.render(renderList(keys)); + }); + const scrollView = nullthrows( + nullthrows(flatListRef.current).getNativeScrollRef(), + ); + // Rows 1 and 2 fill the viewport. + Fantom.scrollTo(scrollView, {x: 0, y: 50}); + expect(viewableKeys()).toEqual(['2', '3', '4', '5']); + + // Removing an item above the viewport shifts every later item back + // by one, so the same rows now show different items. + Fantom.runTask(() => { + root.render(renderList(keys.filter(key => key !== '1'))); + }); + timers.advanceTimersByTime(100); + expect(viewableKeys()).toEqual(['3', '4', '5', '6']); + } finally { + timers.uninstall(); + } + }); + + if (ReactNativeFeatureFlags.enableFlatListGridLayout()) { + it('does not remount items when an item is removed, even if they change rows', () => { + const root = Fantom.createRoot({ + viewportWidth: 400, + viewportHeight: 600, + }); + const mountCounts: {[itemKey: string]: number} = {}; + class Item extends React.PureComponent<{itemKey: string}> { + componentDidMount() { + const {itemKey} = this.props; + mountCounts[itemKey] = (mountCounts[itemKey] ?? 0) + 1; + } + render(): React.Node { + return ( + + ); + } + } + const renderList = (keys: ReadonlyArray) => ( + ({key}))} + numColumns={2} + renderItem={({item}) => } + /> + ); + const keys = ['1', '2', '3', '4', '5', '6', '7', '8']; + + Fantom.runTask(() => { + root.render(renderList(keys)); + }); + Fantom.runTask(() => { + root.render(renderList(keys.filter(key => key !== '6'))); + }); + Fantom.runTask(() => { + root.render( + renderList(keys.filter(key => key !== '6' && key !== '1')), + ); + }); + + expect(mountCounts).toEqual({ + '1': 1, + '2': 1, + '3': 1, + '4': 1, + '5': 1, + '6': 1, + '7': 1, + '8': 1, + }); + expect( + root + .getRenderedOutput({ + includeLayoutMetrics: true, + props: ['nativeID', 'layoutMetrics-frame'], + }) + .toJSX(), + ).toEqual( + + + + + + + + + + , + ); + }); + } }); describe('ItemSeparatorComponent', () => { @@ -1234,8 +1476,9 @@ describe('', () => { ); }); - // Items 1 and 2 share the first row (each 200px wide), - // item 3 is alone on the second row (full 400px width). + // Items 1 and 2 share the first row (each 200px wide). Item 3 is alone on + // the second row: its column is 200px wide in the grid layout, while a + // row cell stretches it to the full 400px. expect( root .getRenderedOutput({ @@ -1256,7 +1499,7 @@ describe('', () => { /> , diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index d0041ff5c50e..14594e0e15fb 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -1093,6 +1093,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + enableFlatListGridLayout: { + defaultValue: false, + metadata: { + dateAdded: '2026-10-13', + description: + 'When enabled, FlatList with numColumns renders each item as its own VirtualizedList cell laid out in a grid, instead of grouping items into one cell per row, so items keep their state when they move to another row.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, enableImperativeEvents_DEPRECATED: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index 329f5e23d459..0c94e68cf1f7 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<<7478060214a584642797faf2822c3540>> + * @generated SignedSource<> * @flow strict * @noformat */ @@ -34,6 +34,7 @@ export type ReactNativeFeatureFlagsJsOnly = Readonly<{ animatedKeepListenersOnDetach: Getter, animatedShouldSyncValueBeforeStartCallback: Getter, deferFlatListFocusChangeRenderUpdate: Getter, + enableFlatListGridLayout: Getter, enableImperativeEvents_DEPRECATED: Getter, enableNativeEventTargetEventDispatching: Getter, externalElementInspectionEnabled: Getter, @@ -174,6 +175,11 @@ export const animatedShouldSyncValueBeforeStartCallback: Getter = creat */ export const deferFlatListFocusChangeRenderUpdate: Getter = createJavaScriptFlagGetter('deferFlatListFocusChangeRenderUpdate', false); +/** + * When enabled, FlatList with numColumns renders each item as its own VirtualizedList cell laid out in a grid, instead of grouping items into one cell per row, so items keep their state when they move to another row. + */ +export const enableFlatListGridLayout: Getter = createJavaScriptFlagGetter('enableFlatListGridLayout', false); + /** * Deprecated in favor of the common flag with the same name, which is the one that can be configured by OSS release stage. This is only kept so JS bundles delivered to older native builds, where the common flag is unavailable, can still enable the feature. */ 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={() =>