Skip to content

Commit bd37d26

Browse files
ramya-2196facebook-github-bot
authored andcommitted
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 (#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
1 parent f99303c commit bd37d26

9 files changed

Lines changed: 522 additions & 22 deletions

‎packages/virtualized-lists/Lists/ListMetricsAggregator.js‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export type CellMetricProps = {
5050
getItem: VirtualizedListProps['getItem'],
5151
getItemLayout?: VirtualizedListProps['getItemLayout'],
5252
keyExtractor?: VirtualizedListProps['keyExtractor'],
53+
experimental_numColumns?: VirtualizedListProps['experimental_numColumns'],
5354
...
5455
};
5556

@@ -168,15 +169,19 @@ export default class ListMetricsAggregator {
168169
/**
169170
* Returns the exact metrics of a cell if it has already been laid out,
170171
* otherwise an estimate based on the average length of previously measured
171-
* cells
172+
* cells. In a grid, cells in the same row share their row's offset and
173+
* length, so estimates count rows rather than cells.
172174
*/
173175
getCellMetricsApprox(index: number, props: CellMetricProps): CellMetrics {
174176
const frame = this.getCellMetrics(index, props);
175177
if (frame && frame.index === index) {
176178
// check for invalid frames due to row re-ordering
177179
return frame;
178180
} else {
181+
const numColumns = props.experimental_numColumns ?? 1;
182+
const rowIndex = Math.floor(index / numColumns);
179183
let offset;
184+
let length = this._averageCellLength;
180185

181186
const highestMeasuredCellIndex = this.getHighestMeasuredCellIndex();
182187
if (highestMeasuredCellIndex < index) {
@@ -189,15 +194,24 @@ export default class ListMetricsAggregator {
189194
props,
190195
);
191196
if (highestMeasuredCellFrame) {
192-
offset =
193-
highestMeasuredCellFrame.offset +
194-
highestMeasuredCellFrame.length +
195-
this._averageCellLength * (index - highestMeasuredCellIndex - 1);
197+
const highestMeasuredRowIndex = Math.floor(
198+
highestMeasuredCellIndex / numColumns,
199+
);
200+
if (rowIndex === highestMeasuredRowIndex) {
201+
offset = highestMeasuredCellFrame.offset;
202+
length = highestMeasuredCellFrame.length;
203+
} else {
204+
offset =
205+
highestMeasuredCellFrame.offset +
206+
highestMeasuredCellFrame.length +
207+
this._averageCellLength *
208+
(rowIndex - highestMeasuredRowIndex - 1);
209+
}
196210
}
197211
}
198212

199213
if (offset == null) {
200-
offset = this._averageCellLength * index;
214+
offset = this._averageCellLength * rowIndex;
201215
}
202216

203217
const {data, getItemCount} = props;
@@ -206,7 +220,7 @@ export default class ListMetricsAggregator {
206220
'Tried to get frame for out of range index ' + index,
207221
);
208222
return {
209-
length: this._averageCellLength,
223+
length,
210224
offset,
211225
index,
212226
isMounted: false,

‎packages/virtualized-lists/Lists/VirtualizeUtils.js‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,28 @@ export function computeWindowedRenderLimits(
243243
return {first, last};
244244
}
245245

246+
/**
247+
* Widens a range of cells to whole rows of a grid with `numColumns` columns,
248+
* so that rendered cells and the spacers between them always cover complete
249+
* rows.
250+
*/
251+
export function expandCellsToWholeRows(
252+
cells: {first: number, last: number},
253+
numColumns: number,
254+
itemCount: number,
255+
): {first: number, last: number} {
256+
if (numColumns <= 1 || cells.last < cells.first) {
257+
return cells;
258+
}
259+
return {
260+
first: cells.first - (cells.first % numColumns),
261+
last: Math.min(
262+
itemCount - 1,
263+
cells.last + numColumns - 1 - (cells.last % numColumns),
264+
),
265+
};
266+
}
267+
246268
export function keyExtractor(item: any, index: number): string {
247269
if (typeof item === 'object' && item?.key != null) {
248270
return item.key;

‎packages/virtualized-lists/Lists/VirtualizedList.d.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,15 @@ export interface VirtualizedListWithoutRenderItemProps<
267267

268268
keyExtractor?: ((item: ItemT, index: number) => string) | undefined;
269269

270+
/**
271+
* Lays items out in a grid with this many columns. Every item is its own
272+
* cell keyed by `keyExtractor`, so an item keeps its identity when it moves
273+
* to another row. `getItemLayout` receives item indices and should return
274+
* the offset and length of the item's row. Only vertical lists are
275+
* supported, without `ItemSeparatorComponent` or `stickyHeaderIndices`.
276+
*/
277+
experimental_numColumns?: number | null | undefined;
278+
270279
/**
271280
* The maximum number of items to render in each incremental render batch. The more rendered at
272281
* once, the better the fill rate, but responsiveness may suffer because rendering content may

‎packages/virtualized-lists/Lists/VirtualizedList.js‎

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,14 @@ import {
4343
horizontalOrDefault,
4444
initialNumToRenderOrDefault,
4545
maxToRenderPerBatchOrDefault,
46+
numColumnsOrDefault,
4647
onEndReachedThresholdOrDefault,
4748
onStartReachedThresholdOrDefault,
4849
windowSizeOrDefault,
4950
} from './VirtualizedListProps';
5051
import {
5152
computeWindowedRenderLimits,
53+
expandCellsToWholeRows,
5254
keyExtractor as defaultKeyExtractor,
5355
} from './VirtualizeUtils';
5456
import invariant from 'invariant';
@@ -596,6 +598,27 @@ class VirtualizedList extends StateSafePureComponent<
596598
'VirtualizedList: The windowSize prop must be present and set to a value greater than 0.',
597599
);
598600

601+
const numColumns = numColumnsOrDefault(props.experimental_numColumns);
602+
invariant(
603+
Number.isInteger(numColumns) && numColumns >= 1,
604+
'VirtualizedList: experimental_numColumns must be a positive integer.',
605+
);
606+
if (numColumns > 1) {
607+
invariant(
608+
!horizontalOrDefault(props.horizontal),
609+
'VirtualizedList: experimental_numColumns does not support horizontal lists.',
610+
);
611+
invariant(
612+
props.ItemSeparatorComponent == null,
613+
'VirtualizedList: experimental_numColumns does not support ItemSeparatorComponent.',
614+
);
615+
invariant(
616+
props.stickyHeaderIndices == null ||
617+
props.stickyHeaderIndices.length === 0,
618+
'VirtualizedList: experimental_numColumns does not support stickyHeaderIndices.',
619+
);
620+
}
621+
599622
const itemCount = getItemCount(data);
600623

601624
if (
@@ -673,18 +696,22 @@ class VirtualizedList extends StateSafePureComponent<
673696
);
674697

675698
const renderMask = new CellRenderMask(itemCount);
699+
const numColumns = numColumnsOrDefault(props.experimental_numColumns);
700+
const addCellsInWholeRows = (cells: {first: number, last: number}) => {
701+
renderMask.addCells(expandCellsToWholeRows(cells, numColumns, itemCount));
702+
};
676703

677704
if (itemCount > 0) {
678705
const allRegions = [cellsAroundViewport, ...(additionalRegions ?? [])];
679706
for (const region of allRegions) {
680-
renderMask.addCells(region);
707+
addCellsInWholeRows(region);
681708
}
682709

683710
// The initially rendered cells are retained as part of the
684711
// "scroll-to-top" optimization
685712
if (props.initialScrollIndex == null || props.initialScrollIndex <= 0) {
686713
const initialRegion = VirtualizedList._initialRenderRegion(props);
687-
renderMask.addCells(initialRegion);
714+
addCellsInWholeRows(initialRegion);
688715
}
689716

690717
// The layout coordinates of sticky headers may be off-screen while the
@@ -950,6 +977,7 @@ class VirtualizedList extends StateSafePureComponent<
950977
renderItem,
951978
} = this.props;
952979
const stickyOffset = ListHeaderComponent ? 1 : 0;
980+
const numColumns = numColumnsOrDefault(this.props.experimental_numColumns);
953981
const end = getItemCount(data) - 1;
954982
let prevCellKey;
955983
last = Math.min(end, last);
@@ -977,6 +1005,7 @@ class VirtualizedList extends StateSafePureComponent<
9771005
inversionStyle={inversionStyle}
9781006
item={item}
9791007
key={key}
1008+
numColumns={numColumns}
9801009
prevCellKey={prevCellKey}
9811010
onUpdateSeparators={this._onUpdateSeparators}
9821011
onCellFocusCapture={this._onCellFocusCapture}
@@ -1138,6 +1167,9 @@ class VirtualizedList extends StateSafePureComponent<
11381167
? styles.horizontallyInverted
11391168
: styles.verticallyInverted
11401169
: null;
1170+
const isGridLayout =
1171+
numColumnsOrDefault(this.props.experimental_numColumns) > 1;
1172+
const fullWidthStyle = isGridLayout ? styles.gridFullWidth : null;
11411173
const cells: Array<any | React.Node> = [];
11421174
// Avoid allocating a Set on every render when no sticky headers are
11431175
// configured (the common case).
@@ -1169,8 +1201,11 @@ class VirtualizedList extends StateSafePureComponent<
11691201
collapsable={false}
11701202
onLayout={this._onLayoutHeader}
11711203
style={StyleSheet.compose(
1172-
inversionStyle,
1173-
this.props.ListHeaderComponentStyle,
1204+
fullWidthStyle,
1205+
StyleSheet.compose(
1206+
inversionStyle,
1207+
this.props.ListHeaderComponentStyle,
1208+
),
11741209
)}>
11751210
{
11761211
// $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors
@@ -1197,7 +1232,10 @@ class VirtualizedList extends StateSafePureComponent<
11971232
<VirtualizedListCellContextProvider
11981233
cellKey={this._getCellKey() + '-empty'}
11991234
key="$empty">
1200-
{this._renderEmptyComponent(element, inversionStyle)}
1235+
{this._renderEmptyComponent(
1236+
element,
1237+
StyleSheet.compose(fullWidthStyle, inversionStyle),
1238+
)}
12011239
</VirtualizedListCellContextProvider>,
12021240
);
12031241
}
@@ -1247,7 +1285,9 @@ class VirtualizedList extends StateSafePureComponent<
12471285
<View
12481286
key={`$spacer-${section.first}`}
12491287
// $FlowFixMe[incompatible-type]
1250-
style={{[spacerKey]: spacerSize}}
1288+
style={StyleSheet.compose(fullWidthStyle, {
1289+
[spacerKey]: spacerSize,
1290+
})}
12511291
/>,
12521292
);
12531293
} else {
@@ -1288,8 +1328,11 @@ class VirtualizedList extends StateSafePureComponent<
12881328
<View
12891329
onLayout={this._onLayoutFooter}
12901330
style={StyleSheet.compose(
1291-
inversionStyle,
1292-
this.props.ListFooterComponentStyle,
1331+
fullWidthStyle,
1332+
StyleSheet.compose(
1333+
inversionStyle,
1334+
this.props.ListFooterComponentStyle,
1335+
),
12931336
)}>
12941337
{
12951338
// $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors
@@ -1303,6 +1346,18 @@ class VirtualizedList extends StateSafePureComponent<
13031346
// 4. Render the ScrollView
13041347
const scrollProps = {
13051348
...this.props,
1349+
...(isGridLayout
1350+
? {
1351+
contentContainerStyle: StyleSheet.compose(
1352+
this.props.contentContainerStyle,
1353+
// Android inverts lists on both axes, so reverse each row to
1354+
// keep items in reading order once the list is flipped back.
1355+
this.props.inverted && Platform.OS === 'android'
1356+
? styles.gridContentContainerAndroidInverted
1357+
: styles.gridContentContainer,
1358+
),
1359+
}
1360+
: null),
13061361
onContentSizeChange: this._onContentSizeChange,
13071362
onLayout: this._onLayout,
13081363
onScroll: this._onScroll,
@@ -2301,6 +2356,17 @@ class VirtualizedList extends StateSafePureComponent<
23012356
}
23022357

23032358
const styles = StyleSheet.create({
2359+
gridContentContainer: {
2360+
flexDirection: 'row',
2361+
flexWrap: 'wrap',
2362+
},
2363+
gridContentContainerAndroidInverted: {
2364+
flexDirection: 'row-reverse',
2365+
flexWrap: 'wrap',
2366+
},
2367+
gridFullWidth: {
2368+
width: '100%',
2369+
},
23042370
verticallyInverted:
23052371
Platform.OS === 'android'
23062372
? {transform: [{scale: -1}]}

‎packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export type Props<ItemT> = {
3434
index: number,
3535
inversionStyle: StyleProp<ViewStyle>,
3636
item: ItemT,
37+
numColumns?: ?number,
3738
onCellLayout?: (
3839
event: LayoutChangeEvent,
3940
cellKey: string,
@@ -183,6 +184,7 @@ export default class CellRenderer<ItemT> extends React.PureComponent<
183184
item,
184185
index,
185186
inversionStyle,
187+
numColumns,
186188
onCellLayout,
187189
renderItem,
188190
} = this.props;
@@ -204,13 +206,16 @@ export default class CellRenderer<ItemT> extends React.PureComponent<
204206
// $FlowFixMe[not-a-component]
205207
<ItemSeparatorComponent {...this.state.separatorProps} />
206208
);
207-
const cellStyle = inversionStyle
208-
? horizontal
209-
? [styles.rowReverse, inversionStyle]
210-
: [styles.columnReverse, inversionStyle]
211-
: horizontal
212-
? [styles.row, inversionStyle]
213-
: inversionStyle;
209+
const cellStyle =
210+
numColumns != null && numColumns > 1
211+
? [getGridCellStyle(numColumns), inversionStyle]
212+
: inversionStyle
213+
? horizontal
214+
? [styles.rowReverse, inversionStyle]
215+
: [styles.columnReverse, inversionStyle]
216+
: horizontal
217+
? [styles.row, inversionStyle]
218+
: inversionStyle;
214219
const result = !CellRendererComponent ? (
215220
<View
216221
style={cellStyle}
@@ -240,6 +245,22 @@ export default class CellRenderer<ItemT> extends React.PureComponent<
240245
}
241246
}
242247

248+
const gridCellStyles: Map<number, ViewStyle> = new Map();
249+
250+
// Cells use a row direction so that an item with `flex: 1` fills its column,
251+
// as it does inside a row of a FlatList with `numColumns`.
252+
function getGridCellStyle(numColumns: number): ViewStyle {
253+
let gridCellStyle = gridCellStyles.get(numColumns);
254+
if (gridCellStyle == null) {
255+
// Rounded down so that a full row of cells never adds up to more than
256+
// 100% and wraps early because of floating point error.
257+
const widthPercent = Math.floor((100 / numColumns) * 10000) / 10000;
258+
gridCellStyle = {flexDirection: 'row', width: `${widthPercent}%`};
259+
gridCellStyles.set(numColumns, gridCellStyle);
260+
}
261+
return gridCellStyle;
262+
}
263+
243264
const styles = StyleSheet.create({
244265
row: {
245266
flexDirection: 'row',

‎packages/virtualized-lists/Lists/VirtualizedListProps.js‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,14 @@ type OptionalVirtualizedListProps = {
8787
* `data` prop, stick it here and treat it immutably.
8888
*/
8989
extraData?: any,
90+
/**
91+
* Lays items out in a grid with this many columns. Every item is its own
92+
* cell keyed by `keyExtractor`, so an item keeps its identity when it moves
93+
* to another row. `getItemLayout` receives item indices and should return
94+
* the offset and length of the item's row. Only vertical lists are
95+
* supported, without `ItemSeparatorComponent` or `stickyHeaderIndices`.
96+
*/
97+
experimental_numColumns?: ?number,
9098
// e.g. height, y
9199
getItemLayout?: (
92100
data: any,
@@ -328,6 +336,11 @@ export function onEndReachedThresholdOrDefault(
328336
return onEndReachedThreshold ?? 2;
329337
}
330338

339+
// numColumnsOrDefault(this.props.experimental_numColumns)
340+
export function numColumnsOrDefault(numColumns: ?number): number {
341+
return numColumns ?? 1;
342+
}
343+
331344
// windowSizeOrDefault(this.props.windowSize)
332345
export function windowSizeOrDefault(windowSize: ?number): number {
333346
return windowSize ?? 21;

0 commit comments

Comments
 (0)