259 lines
7.5 KiB
JavaScript
259 lines
7.5 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
|
|
* @format
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
import type {FrameMetricProps} from './VirtualizedListProps';
|
|
|
|
/**
|
|
* Used to find the indices of the frames that overlap the given offsets. Useful for finding the
|
|
* items that bound different windows of content, such as the visible area or the buffered overscan
|
|
* area.
|
|
*/
|
|
export function elementsThatOverlapOffsets(
|
|
offsets: Array<number>,
|
|
props: FrameMetricProps,
|
|
getFrameMetrics: (
|
|
index: number,
|
|
props: FrameMetricProps,
|
|
) => {
|
|
length: number,
|
|
offset: number,
|
|
...
|
|
},
|
|
zoomScale: number = 1,
|
|
): Array<number> {
|
|
const itemCount = props.getItemCount(props.data);
|
|
const result = [];
|
|
for (let offsetIndex = 0; offsetIndex < offsets.length; offsetIndex++) {
|
|
const currentOffset = offsets[offsetIndex];
|
|
let left = 0;
|
|
let right = itemCount - 1;
|
|
|
|
while (left <= right) {
|
|
// eslint-disable-next-line no-bitwise
|
|
const mid = left + ((right - left) >>> 1);
|
|
const frame = getFrameMetrics(mid, props);
|
|
const scaledOffsetStart = frame.offset * zoomScale;
|
|
const scaledOffsetEnd = (frame.offset + frame.length) * zoomScale;
|
|
|
|
// We want the first frame that contains the offset, with inclusive bounds. Thus, for the
|
|
// first frame the scaledOffsetStart is inclusive, while for other frames it is exclusive.
|
|
if (
|
|
(mid === 0 && currentOffset < scaledOffsetStart) ||
|
|
(mid !== 0 && currentOffset <= scaledOffsetStart)
|
|
) {
|
|
right = mid - 1;
|
|
} else if (currentOffset > scaledOffsetEnd) {
|
|
left = mid + 1;
|
|
} else {
|
|
result[offsetIndex] = mid;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Computes the number of elements in the `next` range that are new compared to the `prev` range.
|
|
* Handy for calculating how many new items will be rendered when the render window changes so we
|
|
* can restrict the number of new items render at once so that content can appear on the screen
|
|
* faster.
|
|
*/
|
|
export function newRangeCount(
|
|
prev: {
|
|
first: number,
|
|
last: number,
|
|
...
|
|
},
|
|
next: {
|
|
first: number,
|
|
last: number,
|
|
...
|
|
},
|
|
): number {
|
|
return (
|
|
next.last -
|
|
next.first +
|
|
1 -
|
|
Math.max(
|
|
0,
|
|
1 + Math.min(next.last, prev.last) - Math.max(next.first, prev.first),
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Custom logic for determining which items should be rendered given the current frame and scroll
|
|
* metrics, as well as the previous render state. The algorithm may evolve over time, but generally
|
|
* prioritizes the visible area first, then expands that with overscan regions ahead and behind,
|
|
* biased in the direction of scroll.
|
|
*/
|
|
export function computeWindowedRenderLimits(
|
|
props: FrameMetricProps,
|
|
maxToRenderPerBatch: number,
|
|
windowSize: number,
|
|
prev: {
|
|
first: number,
|
|
last: number,
|
|
},
|
|
getFrameMetricsApprox: (
|
|
index: number,
|
|
props: FrameMetricProps,
|
|
) => {
|
|
length: number,
|
|
offset: number,
|
|
...
|
|
},
|
|
scrollMetrics: {
|
|
dt: number,
|
|
offset: number,
|
|
velocity: number,
|
|
visibleLength: number,
|
|
zoomScale: number,
|
|
...
|
|
},
|
|
): {
|
|
first: number,
|
|
last: number,
|
|
} {
|
|
const itemCount = props.getItemCount(props.data);
|
|
if (itemCount === 0) {
|
|
return {first: 0, last: -1};
|
|
}
|
|
const {offset, velocity, visibleLength, zoomScale = 1} = scrollMetrics;
|
|
|
|
// Start with visible area, then compute maximum overscan region by expanding from there, biased
|
|
// in the direction of scroll. Total overscan area is capped, which should cap memory consumption
|
|
// too.
|
|
const visibleBegin = Math.max(0, offset);
|
|
const visibleEnd = visibleBegin + visibleLength;
|
|
const overscanLength = (windowSize - 1) * visibleLength;
|
|
|
|
// Considering velocity seems to introduce more churn than it's worth.
|
|
const leadFactor = 0.5; // Math.max(0, Math.min(1, velocity / 25 + 0.5));
|
|
|
|
const fillPreference =
|
|
velocity > 1 ? 'after' : velocity < -1 ? 'before' : 'none';
|
|
|
|
const overscanBegin = Math.max(
|
|
0,
|
|
visibleBegin - (1 - leadFactor) * overscanLength,
|
|
);
|
|
const overscanEnd = Math.max(0, visibleEnd + leadFactor * overscanLength);
|
|
|
|
const lastItemOffset =
|
|
getFrameMetricsApprox(itemCount - 1, props).offset * zoomScale;
|
|
if (lastItemOffset < overscanBegin) {
|
|
// Entire list is before our overscan window
|
|
return {
|
|
first: Math.max(0, itemCount - 1 - maxToRenderPerBatch),
|
|
last: itemCount - 1,
|
|
};
|
|
}
|
|
|
|
// Find the indices that correspond to the items at the render boundaries we're targeting.
|
|
let [overscanFirst, first, last, overscanLast] = elementsThatOverlapOffsets(
|
|
[overscanBegin, visibleBegin, visibleEnd, overscanEnd],
|
|
props,
|
|
getFrameMetricsApprox,
|
|
zoomScale,
|
|
);
|
|
overscanFirst = overscanFirst == null ? 0 : overscanFirst;
|
|
first = first == null ? Math.max(0, overscanFirst) : first;
|
|
overscanLast = overscanLast == null ? itemCount - 1 : overscanLast;
|
|
last =
|
|
last == null
|
|
? Math.min(overscanLast, first + maxToRenderPerBatch - 1)
|
|
: last;
|
|
const visible = {first, last};
|
|
|
|
// We want to limit the number of new cells we're rendering per batch so that we can fill the
|
|
// content on the screen quickly. If we rendered the entire overscan window at once, the user
|
|
// could be staring at white space for a long time waiting for a bunch of offscreen content to
|
|
// render.
|
|
let newCellCount = newRangeCount(prev, visible);
|
|
|
|
while (true) {
|
|
if (first <= overscanFirst && last >= overscanLast) {
|
|
// If we fill the entire overscan range, we're done.
|
|
break;
|
|
}
|
|
const maxNewCells = newCellCount >= maxToRenderPerBatch;
|
|
const firstWillAddMore = first <= prev.first || first > prev.last;
|
|
const firstShouldIncrement =
|
|
first > overscanFirst && (!maxNewCells || !firstWillAddMore);
|
|
const lastWillAddMore = last >= prev.last || last < prev.first;
|
|
const lastShouldIncrement =
|
|
last < overscanLast && (!maxNewCells || !lastWillAddMore);
|
|
if (maxNewCells && !firstShouldIncrement && !lastShouldIncrement) {
|
|
// We only want to stop if we've hit maxNewCells AND we cannot increment first or last
|
|
// without rendering new items. This let's us preserve as many already rendered items as
|
|
// possible, reducing render churn and keeping the rendered overscan range as large as
|
|
// possible.
|
|
break;
|
|
}
|
|
if (
|
|
firstShouldIncrement &&
|
|
!(fillPreference === 'after' && lastShouldIncrement && lastWillAddMore)
|
|
) {
|
|
if (firstWillAddMore) {
|
|
newCellCount++;
|
|
}
|
|
first--;
|
|
}
|
|
if (
|
|
lastShouldIncrement &&
|
|
!(fillPreference === 'before' && firstShouldIncrement && firstWillAddMore)
|
|
) {
|
|
if (lastWillAddMore) {
|
|
newCellCount++;
|
|
}
|
|
last++;
|
|
}
|
|
}
|
|
if (
|
|
!(
|
|
last >= first &&
|
|
first >= 0 &&
|
|
last < itemCount &&
|
|
first >= overscanFirst &&
|
|
last <= overscanLast &&
|
|
first <= visible.first &&
|
|
last >= visible.last
|
|
)
|
|
) {
|
|
throw new Error(
|
|
'Bad window calculation ' +
|
|
JSON.stringify({
|
|
first,
|
|
last,
|
|
itemCount,
|
|
overscanFirst,
|
|
overscanLast,
|
|
visible,
|
|
}),
|
|
);
|
|
}
|
|
return {first, last};
|
|
}
|
|
|
|
export function keyExtractor(item: any, index: number): string {
|
|
if (typeof item === 'object' && item?.key != null) {
|
|
return item.key;
|
|
}
|
|
if (typeof item === 'object' && item?.id != null) {
|
|
return item.id;
|
|
}
|
|
return String(index);
|
|
}
|