From f44dfef923b887308300cee9ae5a55269414c45a Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Wed, 7 Sep 2022 16:52:01 -0700 Subject: [PATCH] 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 --- .../Lists/VirtualizedList_EXPERIMENTAL.js | 23 +- .../Lists/__tests__/VirtualizedList-test.js | 207 ++++++++++++++++++ 2 files changed, 227 insertions(+), 3 deletions(-) diff --git a/Libraries/Lists/VirtualizedList_EXPERIMENTAL.js b/Libraries/Lists/VirtualizedList_EXPERIMENTAL.js index ebe059a01bd14f..b54c238d1e8646 100644 --- a/Libraries/Lists/VirtualizedList_EXPERIMENTAL.js +++ b/Libraries/Lists/VirtualizedList_EXPERIMENTAL.js @@ -235,11 +235,11 @@ class VirtualizedList extends StateSafePureComponent { }); 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 { 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 { }; }; + /** + * 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, diff --git a/Libraries/Lists/__tests__/VirtualizedList-test.js b/Libraries/Lists/__tests__/VirtualizedList-test.js index 0928fd57c98c02..08be48906c687f 100644 --- a/Libraries/Lists/__tests__/VirtualizedList-test.js +++ b/Libraries/Lists/__tests__/VirtualizedList-test.js @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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;