Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 21 additions & 7 deletions packages/virtualized-lists/Lists/ListMetricsAggregator.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export type CellMetricProps = {
getItem: VirtualizedListProps['getItem'],
getItemLayout?: VirtualizedListProps['getItemLayout'],
keyExtractor?: VirtualizedListProps['keyExtractor'],
experimental_numColumns?: VirtualizedListProps['experimental_numColumns'],
...
};

Expand Down Expand Up @@ -168,15 +169,19 @@ 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);
if (frame && frame.index === index) {
// 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) {
Expand All @@ -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;
Expand All @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions packages/virtualized-lists/Lists/VirtualizeUtils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions packages/virtualized-lists/Lists/VirtualizedList.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
82 changes: 74 additions & 8 deletions packages/virtualized-lists/Lists/VirtualizedList.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -977,6 +1005,7 @@ class VirtualizedList extends StateSafePureComponent<
inversionStyle={inversionStyle}
item={item}
key={key}
numColumns={numColumns}
prevCellKey={prevCellKey}
onUpdateSeparators={this._onUpdateSeparators}
onCellFocusCapture={this._onCellFocusCapture}
Expand Down Expand Up @@ -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<any | React.Node> = [];
// Avoid allocating a Set on every render when no sticky headers are
// configured (the common case).
Expand Down Expand Up @@ -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
Expand All @@ -1197,7 +1232,10 @@ class VirtualizedList extends StateSafePureComponent<
<VirtualizedListCellContextProvider
cellKey={this._getCellKey() + '-empty'}
key="$empty">
{this._renderEmptyComponent(element, inversionStyle)}
{this._renderEmptyComponent(
element,
StyleSheet.compose(fullWidthStyle, inversionStyle),
)}
</VirtualizedListCellContextProvider>,
);
}
Expand Down Expand Up @@ -1247,7 +1285,9 @@ class VirtualizedList extends StateSafePureComponent<
<View
key={`$spacer-${section.first}`}
// $FlowFixMe[incompatible-type]
style={{[spacerKey]: spacerSize}}
style={StyleSheet.compose(fullWidthStyle, {
[spacerKey]: spacerSize,
})}
/>,
);
} else {
Expand Down Expand Up @@ -1288,8 +1328,11 @@ class VirtualizedList extends StateSafePureComponent<
<View
onLayout={this._onLayoutFooter}
style={StyleSheet.compose(
inversionStyle,
this.props.ListFooterComponentStyle,
fullWidthStyle,
StyleSheet.compose(
inversionStyle,
this.props.ListFooterComponentStyle,
),
)}>
{
// $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors
Expand All @@ -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,
Expand Down Expand Up @@ -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}]}
Expand Down
35 changes: 28 additions & 7 deletions packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export type Props<ItemT> = {
index: number,
inversionStyle: StyleProp<ViewStyle>,
item: ItemT,
numColumns?: ?number,
onCellLayout?: (
event: LayoutChangeEvent,
cellKey: string,
Expand Down Expand Up @@ -183,6 +184,7 @@ export default class CellRenderer<ItemT> extends React.PureComponent<
item,
index,
inversionStyle,
numColumns,
onCellLayout,
renderItem,
} = this.props;
Expand All @@ -204,13 +206,16 @@ export default class CellRenderer<ItemT> extends React.PureComponent<
// $FlowFixMe[not-a-component]
<ItemSeparatorComponent {...this.state.separatorProps} />
);
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 ? (
<View
style={cellStyle}
Expand Down Expand Up @@ -240,6 +245,22 @@ export default class CellRenderer<ItemT> extends React.PureComponent<
}
}

const gridCellStyles: Map<number, ViewStyle> = 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',
Expand Down
13 changes: 13 additions & 0 deletions packages/virtualized-lists/Lists/VirtualizedListProps.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading