Add proper support for fractional scrollIndex in VirtualizedList

Summary:
Non-integer `initialScrollIndex` or values to `scrollToIndex` would produce a reasonable result, with the caveat that it always falls back to layout estimation (will only be correct when all items are the same size), and breaks if getItemLayout() is supplied. It has usage though, so this diff adds proper support for non-integer scrollIndex, to offset a given amount into the length of the specific cell.

This overlaps a bit with the optional `viewOffset` and `viewPosition` arguments in `scrollToIndex`, but there isn't really the equivalent API for `initialScrollIndex`.

Changelog:
[General][Added]- Add proper support for fractional scrollIndex in VirtualizedList

Reviewed By: yungsters

Differential Revision: D39271100

fbshipit-source-id: 4d93887eed4497e9f6abcd1a6117ac7fdaebf2b1
This commit is contained in:
Nick Gerleman
2022-09-07 16:52:01 -07:00
committed by Facebook GitHub Bot
parent b6bf1fd373
commit f44dfef923
2 changed files with 227 additions and 3 deletions
@@ -235,11 +235,11 @@ class VirtualizedList extends StateSafePureComponent<Props, State> {
});
return;
}
const frame = this.__getFrameMetricsApprox(index, this.props);
const frame = this.__getFrameMetricsApprox(Math.floor(index), this.props);
const offset =
Math.max(
0,
frame.offset -
this._getOffsetApprox(index, this.props) -
(viewPosition || 0) *
(this._scrollMetrics.visibleLength - frame.length),
) - (viewOffset || 0);
@@ -564,7 +564,7 @@ class VirtualizedList extends StateSafePureComponent<Props, State> {
static _initialRenderRegion(props: Props): {first: number, last: number} {
const itemCount = props.getItemCount(props.data);
const scrollIndex = Math.max(0, props.initialScrollIndex ?? 0);
const scrollIndex = Math.floor(Math.max(0, props.initialScrollIndex ?? 0));
return {
first: scrollIndex,
@@ -1780,6 +1780,23 @@ class VirtualizedList extends StateSafePureComponent<Props, State> {
};
};
/**
* Gets an approximate offset to an item at a given index. Supports
* fractional indices.
*/
_getOffsetApprox = (index: number, props: FrameMetricProps): number => {
if (Number.isInteger(index)) {
return this.__getFrameMetricsApprox(index, props).offset;
} else {
const frameMetrics = this.__getFrameMetricsApprox(
Math.floor(index),
props,
);
const remainder = index - Math.floor(index);
return frameMetrics.offset + remainder * frameMetrics.length;
}
};
__getFrameMetricsApprox: (
index: number,
props: FrameMetricProps,
@@ -736,6 +736,213 @@ it('renders offset cells in initial render when initialScrollIndex set', () => {
expect(component).toMatchSnapshot();
});
it('scrolls after content sizing with integer initialScrollIndex', () => {
const items = generateItems(10);
const ITEM_HEIGHT = 10;
const listRef = React.createRef(null);
const component = ReactTestRenderer.create(
<VirtualizedList
initialScrollIndex={1}
initialNumToRender={4}
ref={listRef}
{...baseItemProps(items)}
{...fixedHeightItemLayoutProps(ITEM_HEIGHT)}
/>,
);
const {scrollTo} = listRef.current.getScrollRef();
ReactTestRenderer.act(() => {
simulateLayout(component, {
viewport: {width: 10, height: 50},
content: {width: 10, height: 200},
});
performAllBatches();
});
expect(scrollTo).toHaveBeenLastCalledWith({y: 10, animated: false});
});
it('scrolls after content sizing with near-zero initialScrollIndex', () => {
const items = generateItems(10);
const ITEM_HEIGHT = 10;
const listRef = React.createRef(null);
const component = ReactTestRenderer.create(
<VirtualizedList
initialScrollIndex={0.0001}
initialNumToRender={4}
ref={listRef}
{...baseItemProps(items)}
{...fixedHeightItemLayoutProps(ITEM_HEIGHT)}
/>,
);
const {scrollTo} = listRef.current.getScrollRef();
ReactTestRenderer.act(() => {
simulateLayout(component, {
viewport: {width: 10, height: 50},
content: {width: 10, height: 200},
});
performAllBatches();
});
expect(scrollTo).toHaveBeenLastCalledWith({y: 0.001, animated: false});
});
it('scrolls after content sizing with near-end initialScrollIndex', () => {
const items = generateItems(10);
const ITEM_HEIGHT = 10;
const listRef = React.createRef(null);
const component = ReactTestRenderer.create(
<VirtualizedList
initialScrollIndex={9.9999}
initialNumToRender={4}
ref={listRef}
{...baseItemProps(items)}
{...fixedHeightItemLayoutProps(ITEM_HEIGHT)}
/>,
);
const {scrollTo} = listRef.current.getScrollRef();
ReactTestRenderer.act(() => {
simulateLayout(component, {
viewport: {width: 10, height: 50},
content: {width: 10, height: 200},
});
performAllBatches();
});
expect(scrollTo).toHaveBeenLastCalledWith({y: 99.999, animated: false});
});
it('scrolls after content sizing with fractional initialScrollIndex (getItemLayout())', () => {
const items = generateItems(10);
const itemHeights = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const getItemLayout = (_, index) => ({
length: itemHeights[index],
offset: itemHeights.slice(0, index).reduce((a, b) => a + b, 0),
index,
});
const listRef = React.createRef(null);
const component = ReactTestRenderer.create(
<VirtualizedList
initialScrollIndex={1.5}
initialNumToRender={4}
ref={listRef}
getItemLayout={getItemLayout}
{...baseItemProps(items)}
/>,
);
const {scrollTo} = listRef.current.getScrollRef();
ReactTestRenderer.act(() => {
simulateLayout(component, {
viewport: {width: 10, height: 50},
content: {width: 10, height: 200},
});
performAllBatches();
});
if (useExperimentalList) {
expect(scrollTo).toHaveBeenLastCalledWith({y: 2.0, animated: false});
} else {
// Legacy incorrect results
expect(scrollTo).toHaveBeenLastCalledWith({y: Number.NaN, animated: false});
}
});
it('scrolls after content sizing with fractional initialScrollIndex (cached layout)', () => {
const items = generateItems(10);
const listRef = React.createRef(null);
const component = ReactTestRenderer.create(
<VirtualizedList
initialScrollIndex={1.5}
initialNumToRender={4}
ref={listRef}
{...baseItemProps(items)}
/>,
);
const {scrollTo} = listRef.current.getScrollRef();
ReactTestRenderer.act(() => {
let y = 0;
for (let i = 0; i < 10; ++i) {
const height = i + 1;
simulateCellLayout(component, items, i, {
width: 10,
height,
x: 0,
y,
});
y += height;
}
simulateLayout(component, {
viewport: {width: 10, height: 50},
content: {width: 10, height: 200},
});
performAllBatches();
});
if (useExperimentalList) {
expect(scrollTo).toHaveBeenLastCalledWith({y: 2.0, animated: false});
} else {
// Legacy incorrect results
expect(scrollTo).toHaveBeenLastCalledWith({y: 8.25, animated: false});
}
});
it('scrolls after content sizing with fractional initialScrollIndex (layout estimation)', () => {
const items = generateItems(10);
const listRef = React.createRef(null);
const component = ReactTestRenderer.create(
<VirtualizedList
initialScrollIndex={1.5}
initialNumToRender={4}
ref={listRef}
{...baseItemProps(items)}
/>,
);
const {scrollTo} = listRef.current.getScrollRef();
ReactTestRenderer.act(() => {
let y = 0;
for (let i = 5; i < 10; ++i) {
const height = i + 1;
simulateCellLayout(component, items, i, {
width: 10,
height,
x: 0,
y,
});
y += height;
}
simulateLayout(component, {
viewport: {width: 10, height: 50},
content: {width: 10, height: 200},
});
performAllBatches();
});
expect(scrollTo).toHaveBeenLastCalledWith({y: 12, animated: false});
});
it('initially renders nothing when initialNumToRender is 0', () => {
const items = generateItems(10);
const ITEM_HEIGHT = 10;