Skip to content

Commit

Permalink
Add proper support for fractional scrollIndex in VirtualizedList
Browse files Browse the repository at this point in the history
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
  • Loading branch information
NickGerleman authored and facebook-github-bot committed Sep 7, 2022
1 parent b6bf1fd commit f44dfef
Show file tree
Hide file tree
Showing 2 changed files with 227 additions and 3 deletions.
23 changes: 20 additions & 3 deletions Libraries/Lists/VirtualizedList_EXPERIMENTAL.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
207 changes: 207 additions & 0 deletions Libraries/Lists/__tests__/VirtualizedList-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down

0 comments on commit f44dfef

Please sign in to comment.