Files
react-native/packages/virtualized-lists/Lists/ListMetricsAggregator.js
Rubén Norte 40aaeb7181 Improve FlatList estimates for items not yet laid out (#46487)
Summary:
Pull Request resolved: https://github.com/facebook/react-native/pull/46487

Changelog: [General][Fixed] Fixed accuracy of FlatList estimations to determine what elements are visible in the rendering window.

This fixes a bug in FlatList where it thinks that some elements are visible in the rendering window, when they're not. Specifically, if a cell hasn't been laid out yet, it ignores all the information it already has on the ones that had, and estimates its position and offset based on the estimated size of the cells. In this case, if the first element has a larger offset because the list has a header, that offset is ignored in this case.

One observed result of this is that in a list where there's a header and a single cell that occupy the whole rendering window, FlatList thinks it needs to pre-render an additional element because the header is ignored.

Reviewed By: NickGerleman

Differential Revision: D62649060

fbshipit-source-id: 437bae79916707ca1d08784190508a9f7e36688e
2024-09-13 16:18:33 -07:00

328 lines
9.1 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow strict-local
* @format
*/
import type {Props as VirtualizedListProps} from './VirtualizedListProps';
import type {Layout} from 'react-native/Libraries/Types/CoreEventTypes';
import {keyExtractor as defaultKeyExtractor} from './VirtualizeUtils';
import invariant from 'invariant';
export type CellMetrics = {
/**
* Index of the item in the list
*/
index: number,
/**
* Length of the cell along the scrolling axis
*/
length: number,
/**
* Distance between this cell and the start of the list along the scrolling
* axis
*/
offset: number,
/**
* Whether the cell is last known to be mounted
*/
isMounted: boolean,
};
// TODO: `inverted` can be incorporated here if it is moved to an order
// based implementation instead of transform.
export type ListOrientation = {
horizontal: boolean,
rtl: boolean,
};
/**
* Subset of VirtualizedList props needed to calculate cell metrics
*/
export type CellMetricProps = {
data: VirtualizedListProps['data'],
getItemCount: VirtualizedListProps['getItemCount'],
getItem: VirtualizedListProps['getItem'],
getItemLayout?: VirtualizedListProps['getItemLayout'],
keyExtractor?: VirtualizedListProps['keyExtractor'],
...
};
/**
* Provides an interface to query information about the metrics of a list and its cells.
*/
export default class ListMetricsAggregator {
_averageCellLength = 0;
_cellMetrics: Map<string, CellMetrics> = new Map();
_contentLength: ?number;
_highestMeasuredCellIndex = 0;
_measuredCellsLength = 0;
_measuredCellsCount = 0;
_orientation: ListOrientation = {
horizontal: false,
rtl: false,
};
/**
* Notify the ListMetricsAggregator that a cell has been laid out.
*
* @returns whether the cell layout has changed since last notification
*/
notifyCellLayout({
cellIndex,
cellKey,
orientation,
layout,
}: {
cellIndex: number,
cellKey: string,
orientation: ListOrientation,
layout: Layout,
}): boolean {
this._invalidateIfOrientationChanged(orientation);
const next: CellMetrics = {
index: cellIndex,
length: this._selectLength(layout),
isMounted: true,
offset: this.flowRelativeOffset(layout),
};
const curr = this._cellMetrics.get(cellKey);
if (!curr || next.offset !== curr.offset || next.length !== curr.length) {
if (curr) {
const dLength = next.length - curr.length;
this._measuredCellsLength += dLength;
} else {
this._measuredCellsLength += next.length;
this._measuredCellsCount += 1;
}
this._averageCellLength =
this._measuredCellsLength / this._measuredCellsCount;
this._cellMetrics.set(cellKey, next);
this._highestMeasuredCellIndex = Math.max(
this._highestMeasuredCellIndex,
cellIndex,
);
return true;
} else {
curr.isMounted = true;
return false;
}
}
/**
* Notify ListMetricsAggregator that a cell has been unmounted.
*/
notifyCellUnmounted(cellKey: string): void {
const curr = this._cellMetrics.get(cellKey);
if (curr) {
curr.isMounted = false;
}
}
/**
* Notify ListMetricsAggregator that the lists content container has been laid out.
*/
notifyListContentLayout({
orientation,
layout,
}: {
orientation: ListOrientation,
layout: $ReadOnly<{width: number, height: number}>,
}): void {
this._invalidateIfOrientationChanged(orientation);
this._contentLength = this._selectLength(layout);
}
/**
* Return the average length of the cells which have been measured
*/
getAverageCellLength(): number {
return this._averageCellLength;
}
/**
* Return the highest measured cell index (or 0 if nothing has been measured
* yet)
*/
getHighestMeasuredCellIndex(): number {
return this._highestMeasuredCellIndex;
}
/**
* 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
*/
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 {
let offset;
const highestMeasuredCellIndex = this.getHighestMeasuredCellIndex();
if (highestMeasuredCellIndex < index) {
// If any of the cells before this one have been laid out already, we
// should use that information in the estimations.
// This is important because if the list has a header, the initial cell
// will have a larger offset that we should take into account here.
const highestMeasuredCellFrame = this.getCellMetrics(
highestMeasuredCellIndex,
props,
);
if (highestMeasuredCellFrame) {
offset =
highestMeasuredCellFrame.offset +
highestMeasuredCellFrame.length +
this._averageCellLength * (index - highestMeasuredCellIndex - 1);
}
}
if (offset == null) {
offset = this._averageCellLength * index;
}
const {data, getItemCount} = props;
invariant(
index >= 0 && index < getItemCount(data),
'Tried to get frame for out of range index ' + index,
);
return {
length: this._averageCellLength,
offset,
index,
isMounted: false,
};
}
}
/**
* Returns the exact metrics of a cell if it has already been laid out
*/
getCellMetrics(index: number, props: CellMetricProps): ?CellMetrics {
const {data, getItem, getItemCount, getItemLayout} = props;
invariant(
index >= 0 && index < getItemCount(data),
'Tried to get metrics for out of range cell index ' + index,
);
const keyExtractor = props.keyExtractor ?? defaultKeyExtractor;
const frame = this._cellMetrics.get(
keyExtractor(getItem(data, index), index),
);
if (frame && frame.index === index) {
return frame;
}
if (getItemLayout) {
const {length, offset} = getItemLayout(data, index);
// TODO: `isMounted` is used for both "is exact layout" and "has been
// unmounted". Should be refactored.
return {index, length, offset, isMounted: true};
}
return null;
}
/**
* Gets an approximate offset to an item at a given index. Supports
* fractional indices.
*/
getCellOffsetApprox(index: number, props: CellMetricProps): number {
if (Number.isInteger(index)) {
return this.getCellMetricsApprox(index, props).offset;
} else {
const frameMetrics = this.getCellMetricsApprox(Math.floor(index), props);
const remainder = index - Math.floor(index);
return frameMetrics.offset + remainder * frameMetrics.length;
}
}
/**
* Returns the length of all ScrollView content along the scrolling axis.
*/
getContentLength(): number {
return this._contentLength ?? 0;
}
/**
* Whether a content length has been observed
*/
hasContentLength(): boolean {
return this._contentLength != null;
}
/**
* Finds the flow-relative offset (e.g. starting from the left in LTR, but
* right in RTL) from a layout box.
*/
flowRelativeOffset(layout: Layout, referenceContentLength?: ?number): number {
const {horizontal, rtl} = this._orientation;
if (horizontal && rtl) {
const contentLength = referenceContentLength ?? this._contentLength;
invariant(
contentLength != null,
'ListMetricsAggregator must be notified of list content layout before resolving offsets',
);
return (
contentLength -
(this._selectOffset(layout) + this._selectLength(layout))
);
} else {
return this._selectOffset(layout);
}
}
/**
* Converts a flow-relative offset to a cartesian offset
*/
cartesianOffset(flowRelativeOffset: number): number {
const {horizontal, rtl} = this._orientation;
if (horizontal && rtl) {
invariant(
this._contentLength != null,
'ListMetricsAggregator must be notified of list content layout before resolving offsets',
);
return this._contentLength - flowRelativeOffset;
} else {
return flowRelativeOffset;
}
}
_invalidateIfOrientationChanged(orientation: ListOrientation): void {
if (orientation.rtl !== this._orientation.rtl) {
this._cellMetrics.clear();
}
if (orientation.horizontal !== this._orientation.horizontal) {
this._averageCellLength = 0;
this._highestMeasuredCellIndex = 0;
this._measuredCellsLength = 0;
this._measuredCellsCount = 0;
}
this._orientation = orientation;
}
_selectLength({
width,
height,
}: $ReadOnly<{width: number, height: number, ...}>): number {
return this._orientation.horizontal ? width : height;
}
_selectOffset({x, y}: $ReadOnly<{x: number, y: number, ...}>): number {
return this._orientation.horizontal ? x : y;
}
}