From 5dcc6b27c8724247f40ed9749893d2d23d952ae5 Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Fri, 23 Apr 2021 17:42:02 -0700 Subject: [PATCH 1/2] Shift VirtualizedList State Representation to CellRenderMask Depends on: https://github.com/facebook/react-native/pull/31401 https://github.com/facebook/react-native/pull/31420 VirtualizedList currently keeps a [first, last] range as state, tracking the region of cells to render. The render functions uses this as an input, along with a few special cases to render more (sticky headers, initial render region.) This change moves to instead keep state which describes discontiguous render regions. This mask is continually updated as the viewport changes, batch renders expand the region, etc. Special cases are baked into the render mask, with a relatively simple tranformation from the mask to render function. This representation makes it much easier to support keyboarding scenarios, which require keeping distinct regions (e.g. for last focused) realized while out of viewport. MSFT employees can see more here: https://msit.microsoftstream.com/video/fe01a1ff-0400-94b1-d4f1-f1eb924b1809 --- Libraries/Lists/VirtualizedList.js | 525 +++++++++++++++++------------ 1 file changed, 309 insertions(+), 216 deletions(-) diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index a7c1567b3f607a..dcaa4022ed6574 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -41,7 +41,10 @@ import { VirtualizedListContextProvider, type ChildListState, type ListDebugInfo, -} from './VirtualizedListContext.js'; +} from './VirtualizedListContext'; + +import {CellRenderMask} from './CellRenderMask'; +import clamp from '../Utilities/clamp'; type Item = any; @@ -310,8 +313,8 @@ let _usedIndexForKey = false; let _keylessItemComponentName: string = ''; type State = { - first: number, - last: number, + renderMask: CellRenderMask, + viewportWindow: {first: number, last: number}, }; /** @@ -349,6 +352,19 @@ function windowSizeOrDefault(windowSize: ?number) { return windowSize ?? 21; } +function findLastWhere( + arr: Array, + pred: (element: T) => boolean, +): T | null { + for (let i = arr.length - 1; i >= 0; i--) { + if (pred(arr[i])) { + return arr[i]; + } + } + + return null; +} + /** * Base implementation for the more convenient [``](https://reactnative.dev/docs/flatlist.html) * and [``](https://reactnative.dev/docs/sectionlist.html) components, which are also better @@ -691,6 +707,11 @@ class VirtualizedList extends React.PureComponent { 'VirtualizedList: The windowSize prop must be present and set to a value greater than 0.', ); + invariant( + props.getItemCount, + 'VirtualizedList: The "getItemCount" prop must be provided', + ); + this._fillRateHelper = new FillRateHelper(this._getFrameMetrics); this._updateCellsToRenderBatcher = new Batchinator( this._updateCellsToRender, @@ -712,15 +733,10 @@ class VirtualizedList extends React.PureComponent { }); } - let initialState = { - first: this.props.initialScrollIndex || 0, - last: - Math.min( - this.props.getItemCount(this.props.data), - (this.props.initialScrollIndex || 0) + - initialNumToRenderOrDefault(this.props.initialNumToRender), - ) - 1, - }; + let initialState = VirtualizedList._prepareState( + props, + VirtualizedList._initialRenderRegion(props), + ); if (this._isNestedWithSameOrientation()) { const storedState = this.context.getNestedChildState(this._getListKey()); @@ -734,6 +750,163 @@ class VirtualizedList extends React.PureComponent { this.state = initialState; } + static _prepareState( + props: Props, + viewportWindow: {first: number, last: number}, + ): State { + const itemCount = props.getItemCount(props.data); + + invariant( + viewportWindow.first >= 0 && + viewportWindow.last >= viewportWindow.first - 1 && + viewportWindow.last < itemCount, + `Invalid viewport window "${JSON.stringify( + viewportWindow, + )}" was passed to VirtualizedList._prepareState`, + ); + + const renderMask = new CellRenderMask(itemCount); + + if (itemCount > 0) { + renderMask.addCells(viewportWindow); + + const scrollIndex = props.initialScrollIndex || 0; + if (scrollIndex === 0) { + const initialRegion = VirtualizedList._initialRenderRegion(props); + renderMask.addCells(initialRegion); + } + + const stickyIndicesSet = new Set(props.stickyHeaderIndices); + VirtualizedList._ensureStickyHeadersBefore( + props, + stickyIndicesSet, + renderMask, + viewportWindow.first, + ); + } + + return {renderMask, viewportWindow}; + } + + static _initialRenderRegion(props: Props): {first: number, last: number} { + const itemCount = props.getItemCount(props.data); + const scrollIndex = props.initialScrollIndex || 0; + + return { + first: scrollIndex, + last: + Math.min( + itemCount, + scrollIndex + initialNumToRenderOrDefault(props.initialNumToRender), + ) - 1, + }; + } + + static _ensureStickyHeadersBefore( + props: Props, + stickyIndicesSet: Set, + renderMask: CellRenderMask, + cellIdx: number, + ) { + const stickyOffset = props.ListHeaderComponent ? 1 : 0; + + for (let itemIdx = cellIdx - 1; itemIdx >= 0; itemIdx--) { + if (stickyIndicesSet.has(itemIdx + stickyOffset)) { + renderMask.addCells({first: itemIdx, last: itemIdx}); + } + } + } + + _adjustViewportWindow( + props: Props, + viewportWindow: {first: number, last: number}, + ): {first: number, last: number} { + const {data, getItemCount} = props; + const onEndReachedThreshold = onEndReachedThresholdOrDefault( + props.onEndReachedThreshold, + ); + this._updateViewableItems(data); + + const {contentLength, offset, visibleLength} = this._scrollMetrics; + + // Wait until the scroll view metrics have been set up. And until then, + // we will trust the initialNumToRender suggestion + if (visibleLength <= 0 || contentLength <= 0) { + return viewportWindow; + } + + let newviewportWindow: {first: number, last: number}; + if (this._isVirtualizationDisabled()) { + const distanceFromEnd = contentLength - visibleLength - offset; + const renderAhead = + distanceFromEnd < onEndReachedThreshold * visibleLength + ? maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch) + : 0; + + newviewportWindow = { + first: 0, + last: Math.min( + this.state.viewportWindow.last + renderAhead, + getItemCount(data) - 1, + ), + }; + } else { + // If we have a non-zero initialScrollIndex and run this before we've scrolled, + // wait until we've scrolled the view to the right place. And until then, + // we will trust the initialScrollIndex suggestion. + if (this.props.initialScrollIndex && !this._scrollMetrics.offset) { + return viewportWindow; + } + + newviewportWindow = computeWindowedRenderLimits( + props.data, + props.getItemCount, + maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch), + windowSizeOrDefault(props.windowSize), + viewportWindow, + this._getFrameMetricsApprox, + this._scrollMetrics, + ); + } + + if (this._nestedChildLists.size > 0) { + // If some cell in the new state has a child list in it, we should only render + // up through that item, so that we give that list a chance to render. + // Otherwise there's churn from multiple child lists mounting and un-mounting + // their items. + + // Will this prevent rendering if the nested list doesn't realize the end? + const childIdx = this._findFirstChildWithMore( + newviewportWindow.first, + newviewportWindow.last, + ); + + newviewportWindow.last = childIdx || newviewportWindow.last; + } + + return newviewportWindow; + } + + _findFirstChildWithMore(first: number, last: number): number | null { + for (let ii = first; ii <= last; ii++) { + const cellKeyForIndex = this._indicesToKeys.get(ii); + const childListKeys = + cellKeyForIndex && this._cellKeysToChildListKeys.get(cellKeyForIndex); + if (!childListKeys) { + continue; + } + // For each cell, need to check whether any child list in it has more elements to render + for (let childKey of childListKeys) { + const childList = this._nestedChildLists.get(childKey); + if (childList && childList.ref && childList.ref.hasMore()) { + return ii; + } + } + } + + return null; + } + componentDidMount() { if (this._isNestedWithSameOrientation()) { this.context.registerAsNestedChild({ @@ -754,8 +927,7 @@ class VirtualizedList extends React.PureComponent { this.context.unregisterAsNestedChild({ key: this._getListKey(), state: { - first: this.state.first, - last: this.state.last, + ...this.state, frames: this._frames, }, }); @@ -769,19 +941,10 @@ class VirtualizedList extends React.PureComponent { } static getDerivedStateFromProps(newProps: Props, prevState: State): State { - const {data, getItemCount} = newProps; - const maxToRenderPerBatch = maxToRenderPerBatchOrDefault( - newProps.maxToRenderPerBatch, + return VirtualizedList._prepareState( + newProps, + VirtualizedList._constrainWindow(prevState, newProps), ); - // first and last could be stale (e.g. if a new, shorter items props is passed in), so we make - // sure we're rendering a reasonable range here. - return { - first: Math.max( - 0, - Math.min(prevState.first, getItemCount(data) - 1 - maxToRenderPerBatch), - ), - last: Math.max(0, Math.min(prevState.last, getItemCount(data) - 1)), - }; } _pushCells( @@ -836,6 +999,29 @@ class VirtualizedList extends React.PureComponent { } } + static _constrainWindow( + prevState: State, + props: Props, + ): {first: number, last: number} { + // first and last could be stale (e.g. if a new, shorter items props is passed in), so we make + // sure we're rendering a reasonable range here. + const prevWindow = prevState.viewportWindow; + + const itemCount = props.getItemCount(props.data); + return { + first: Math.max( + 0, + Math.min( + prevWindow.first, + itemCount - + 1 - + maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch), + ), + ), + last: Math.min(itemCount - 1, prevWindow.last), + }; + } + _onUpdateSeparators = (keys: Array, newProps: Object) => { keys.forEach(key => { const ref = key != null && this._cellRefs[key]; @@ -889,7 +1075,6 @@ class VirtualizedList extends React.PureComponent { ListHeaderComponent, } = this.props; const {data, horizontal} = this.props; - const isVirtualizationDisabled = this._isVirtualizationDisabled(); const inversionStyle = this.props.inverted ? horizontalOrDefault(this.props.horizontal) ? styles.horizontallyInverted @@ -898,6 +1083,8 @@ class VirtualizedList extends React.PureComponent { const cells = []; const stickyIndicesFromProps = new Set(this.props.stickyHeaderIndices); const stickyHeaderIndices = []; + + // 1. Add cell for ListHeaderComponent if (ListHeaderComponent) { if (stickyIndicesFromProps.has(0)) { stickyHeaderIndices.push(0); @@ -927,103 +1114,10 @@ class VirtualizedList extends React.PureComponent { , ); } + + // 2a. Add a cell for ListEmptyComponent if applicable const itemCount = this.props.getItemCount(data); - if (itemCount > 0) { - _usedIndexForKey = false; - _keylessItemComponentName = ''; - const spacerKey = this._getSpacerKey(!horizontal); - const lastInitialIndex = this.props.initialScrollIndex - ? -1 - : initialNumToRenderOrDefault(this.props.initialNumToRender) - 1; - const {first, last} = this.state; - this._pushCells( - cells, - stickyHeaderIndices, - stickyIndicesFromProps, - 0, - lastInitialIndex, - inversionStyle, - ); - const firstAfterInitial = Math.max(lastInitialIndex + 1, first); - if (!isVirtualizationDisabled && first > lastInitialIndex + 1) { - let insertedStickySpacer = false; - if (stickyIndicesFromProps.size > 0) { - const stickyOffset = ListHeaderComponent ? 1 : 0; - // See if there are any sticky headers in the virtualized space that we need to render. - for (let ii = firstAfterInitial - 1; ii > lastInitialIndex; ii--) { - if (stickyIndicesFromProps.has(ii + stickyOffset)) { - const initBlock = this._getFrameMetricsApprox(lastInitialIndex); - const stickyBlock = this._getFrameMetricsApprox(ii); - const leadSpace = - stickyBlock.offset - - initBlock.offset - - (this.props.initialScrollIndex ? 0 : initBlock.length); - cells.push( - , - ); - this._pushCells( - cells, - stickyHeaderIndices, - stickyIndicesFromProps, - ii, - ii, - inversionStyle, - ); - const trailSpace = - this._getFrameMetricsApprox(first).offset - - (stickyBlock.offset + stickyBlock.length); - cells.push( - , - ); - insertedStickySpacer = true; - break; - } - } - } - if (!insertedStickySpacer) { - const initBlock = this._getFrameMetricsApprox(lastInitialIndex); - const firstSpace = - this._getFrameMetricsApprox(first).offset - - (initBlock.offset + initBlock.length); - cells.push( - , - ); - } - } - this._pushCells( - cells, - stickyHeaderIndices, - stickyIndicesFromProps, - firstAfterInitial, - last, - inversionStyle, - ); - if (!this._hasWarned.keys && _usedIndexForKey) { - console.warn( - 'VirtualizedList: missing keys for items, make sure to specify a key or id property on each ' + - 'item or provide a custom keyExtractor.', - _keylessItemComponentName, - ); - this._hasWarned.keys = true; - } - if (!isVirtualizationDisabled && last < itemCount - 1) { - const lastFrame = this._getFrameMetricsApprox(last); - // Without getItemLayout, we limit our tail spacer to the _highestMeasuredFrameIndex to - // prevent the user for hyperscrolling into un-measured area because otherwise content will - // likely jump around as it renders in above the viewport. - const end = this.props.getItemLayout - ? itemCount - 1 - : Math.min(itemCount - 1, this._highestMeasuredFrameIndex); - const endFrame = this._getFrameMetricsApprox(end); - const tailSpacerLength = - endFrame.offset + - endFrame.length - - (lastFrame.offset + lastFrame.length); - cells.push( - , - ); - } - } else if (ListEmptyComponent) { + if (itemCount === 0 && ListEmptyComponent) { const element: React.Element = ((React.isValidElement( ListEmptyComponent, ) ? ( @@ -1046,6 +1140,70 @@ class VirtualizedList extends React.PureComponent { }), ); } + + // 2b. Add cells and gaps for each item + if (itemCount > 0) { + _usedIndexForKey = false; + _keylessItemComponentName = ''; + const spacerKey = this._getSpacerKey(!horizontal); + + const renderRegions = this.state.renderMask.enumerateRegions(); + const lastSpacer = findLastWhere(renderRegions, r => r.isSpacer); + + for (const section of renderRegions) { + if (section.isSpacer) { + // Legacy behavior is to avoid spacers when virtualization is + // disabled (including head spacers on initial render). + if (this._isVirtualizationDisabled()) { + continue; + } + + // Without getItemLayout, we limit our tail spacer to the _highestMeasuredFrameIndex to + // prevent the user for hyperscrolling into un-measured area because otherwise content will + // likely jump around as it renders in above the viewport. + const isLastSpacer = section === lastSpacer; + const constrainToMeasured = isLastSpacer && !this.props.getItemLayout; + const last = constrainToMeasured + ? clamp( + section.first - 1, + section.last, + this._highestMeasuredFrameIndex, + ) + : section.last; + + const firstMetrics = this._getFrameMetricsApprox(section.first); + const lastMetrics = this._getFrameMetricsApprox(last); + const spacerSize = + lastMetrics.offset + lastMetrics.length - firstMetrics.offset; + cells.push( + , + ); + } else { + this._pushCells( + cells, + stickyHeaderIndices, + stickyIndicesFromProps, + section.first, + section.last, + inversionStyle, + ); + } + } + + if (!this._hasWarned.keys && _usedIndexForKey) { + console.warn( + 'VirtualizedList: missing keys for items, make sure to specify a key or id property on each ' + + 'item or provide a custom keyExtractor.', + _keylessItemComponentName, + ); + this._hasWarned.keys = true; + } + } + + // 3. Add cell for ListFooterComponent if (ListFooterComponent) { const element = React.isValidElement(ListFooterComponent) ? ( ListFooterComponent @@ -1072,6 +1230,8 @@ class VirtualizedList extends React.PureComponent { , ); } + + // 4. Render the ScrollView const scrollProps = { ...this.props, onContentSizeChange: this._onContentSizeChange, @@ -1094,8 +1254,7 @@ class VirtualizedList extends React.PureComponent { : this.props.style, }; - this._hasMore = - this.state.last < this.props.getItemCount(this.props.data) - 1; + this._hasMore = this.state.viewportWindow.last < itemCount - 1; const innerRet = ( { _computeBlankness() { this._fillRateHelper.computeBlankness( this.props, - this.state, + this.state.viewportWindow, this._scrollMetrics, ); } @@ -1433,8 +1592,12 @@ class VirtualizedList extends React.PureComponent { framesInLayout.push(frame); } } - const windowTop = this._getFrameMetricsApprox(this.state.first).offset; - const frameLast = this._getFrameMetricsApprox(this.state.last); + const windowTop = this._getFrameMetricsApprox( + this.state.viewportWindow.first, + ).offset; + const frameLast = this._getFrameMetricsApprox( + this.state.viewportWindow.last, + ); const windowLen = frameLast.offset + frameLast.length - windowTop; const visTop = this._scrollMetrics.offset; const visLen = this._scrollMetrics.visibleLength; @@ -1513,7 +1676,7 @@ class VirtualizedList extends React.PureComponent { onEndReachedThreshold != null ? onEndReachedThreshold * visibleLength : 2; if ( onEndReached && - this.state.last === getItemCount(data) - 1 && + this.state.viewportWindow.last === getItemCount(data) - 1 && distanceFromEnd < threshold && this._scrollMetrics.contentLength !== this._sentEndForContentLength ) { @@ -1645,7 +1808,7 @@ class VirtualizedList extends React.PureComponent { }; _scheduleCellsToRenderUpdate() { - const {first, last} = this.state; + const {first, last} = this.state.viewportWindow; const {offset, visibleLength, velocity} = this._scrollMetrics; const itemCount = this.props.getItemCount(this.props.data); let hiPri = false; @@ -1733,94 +1896,24 @@ class VirtualizedList extends React.PureComponent { }; _updateCellsToRender = () => { - const { - data, - getItemCount, - onEndReachedThreshold: _onEndReachedThreshold, - } = this.props; - const onEndReachedThreshold = onEndReachedThresholdOrDefault( - _onEndReachedThreshold, - ); - const isVirtualizationDisabled = this._isVirtualizationDisabled(); - this._updateViewableItems(data); - if (!data) { - return; - } - this.setState(state => { - let newState; - const {contentLength, offset, visibleLength} = this._scrollMetrics; - if (!isVirtualizationDisabled) { - // If we run this with bogus data, we'll force-render window {first: 0, last: 0}, - // and wipe out the initialNumToRender rendered elements. - // So let's wait until the scroll view metrics have been set up. And until then, - // we will trust the initialNumToRender suggestion - if (visibleLength > 0 && contentLength > 0) { - // If we have a non-zero initialScrollIndex and run this before we've scrolled, - // we'll wipe out the initialNumToRender rendered elements starting at initialScrollIndex. - // So let's wait until we've scrolled the view to the right place. And until then, - // we will trust the initialScrollIndex suggestion. - if (!this.props.initialScrollIndex || this._scrollMetrics.offset) { - newState = computeWindowedRenderLimits( - this.props.data, - this.props.getItemCount, - maxToRenderPerBatchOrDefault(this.props.maxToRenderPerBatch), - windowSizeOrDefault(this.props.windowSize), - state, - this._getFrameMetricsApprox, - this._scrollMetrics, - ); - } - } - } else { - const distanceFromEnd = contentLength - visibleLength - offset; - const renderAhead = - distanceFromEnd < onEndReachedThreshold * visibleLength - ? maxToRenderPerBatchOrDefault(this.props.maxToRenderPerBatch) - : 0; - newState = { - first: 0, - last: Math.min(state.last + renderAhead, getItemCount(data) - 1), - }; - } - if (newState && this._nestedChildLists.size > 0) { - const newFirst = newState.first; - const newLast = newState.last; - // If some cell in the new state has a child list in it, we should only render - // up through that item, so that we give that list a chance to render. - // Otherwise there's churn from multiple child lists mounting and un-mounting - // their items. - for (let ii = newFirst; ii <= newLast; ii++) { - const cellKeyForIndex = this._indicesToKeys.get(ii); - const childListKeys = - cellKeyForIndex && - this._cellKeysToChildListKeys.get(cellKeyForIndex); - if (!childListKeys) { - continue; - } - let someChildHasMore = false; - // For each cell, need to check whether any child list in it has more elements to render - for (let childKey of childListKeys) { - const childList = this._nestedChildLists.get(childKey); - if (childList && childList.ref && childList.ref.hasMore()) { - someChildHasMore = true; - break; - } - } - if (someChildHasMore) { - // $FlowFixMe[incompatible-use] - newState.last = ii; - break; - } - } - } + this.setState((state, props) => { + const nextState = VirtualizedList._prepareState( + props, + this._adjustViewportWindow(props, state.viewportWindow), + ); + + const viewportWindow = state.viewportWindow; + const nextviewportWindow = nextState.viewportWindow; + if ( - newState != null && - newState.first === state.first && - newState.last === state.last + nextviewportWindow.first === viewportWindow.first && + nextviewportWindow.last === viewportWindow.last && + nextState.renderMask.equals(state.renderMask) ) { - newState = null; + return null; } - return newState; + + return nextState; }); }; @@ -1892,7 +1985,7 @@ class VirtualizedList extends React.PureComponent { this._getFrameMetrics, this._createViewToken, tuple.onViewableItemsChanged, - this.state, + this.state.viewportWindow, ); }); } From ce3438951cf5aadebd6da5a6817daa27393b7099 Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Sun, 21 Nov 2021 10:06:57 -0800 Subject: [PATCH 2/2] Mostly there --- Libraries/Lists/CellRenderMask.js | 4 + Libraries/Lists/VirtualizeUtils.js | 2 - Libraries/Lists/VirtualizedList.js | 155 ++++---- .../Lists/__tests__/VirtualizedList-test.js | 11 +- .../VirtualizedList-test.js.snap | 334 +++++------------- 5 files changed, 188 insertions(+), 318 deletions(-) diff --git a/Libraries/Lists/CellRenderMask.js b/Libraries/Lists/CellRenderMask.js index 786ae7b00bad42..15ce117dbe638d 100644 --- a/Libraries/Lists/CellRenderMask.js +++ b/Libraries/Lists/CellRenderMask.js @@ -110,6 +110,10 @@ export class CellRenderMask { ); } + numCells(): number { + return this._numCells; + } + equals(other: CellRenderMask): boolean { return ( this._numCells === other._numCells && diff --git a/Libraries/Lists/VirtualizeUtils.js b/Libraries/Lists/VirtualizeUtils.js index 11dfd8cc20365b..09d448ae285f50 100644 --- a/Libraries/Lists/VirtualizeUtils.js +++ b/Libraries/Lists/VirtualizeUtils.js @@ -92,7 +92,6 @@ export function computeWindowedRenderLimits( prev: { first: number, last: number, - ... }, getFrameMetricsApprox: (index: number) => { length: number, @@ -109,7 +108,6 @@ export function computeWindowedRenderLimits( ): { first: number, last: number, - ... } { const itemCount = getItemCount(data); if (itemCount === 0) { diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index 45b39c7e8ec1ea..431248f96c218e 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -313,7 +313,7 @@ let _keylessItemComponentName: string = ''; type State = { renderMask: CellRenderMask, - viewportWindow: {first: number, last: number}, + cellsAroundViewport: {first: number, last: number}, }; /** @@ -352,11 +352,11 @@ function windowSizeOrDefault(windowSize: ?number) { } function findLastWhere( - arr: Array, - pred: (element: T) => boolean, + arr: $ReadOnlyArray, + predicate: (element: T) => boolean, ): T | null { for (let i = arr.length - 1; i >= 0; i--) { - if (pred(arr[i])) { + if (predicate(arr[i])) { return arr[i]; } } @@ -732,10 +732,12 @@ class VirtualizedList extends React.PureComponent { }); } - let initialState = VirtualizedList._prepareState( - props, - VirtualizedList._initialRenderRegion(props), - ); + const initialRenderRegion = VirtualizedList._initialRenderRegion(props); + + let initialState: State = { + cellsAroundViewport: initialRenderRegion, + renderMask: VirtualizedList._createRenderMask(props, initialRenderRegion), + }; if (this._isNestedWithSameOrientation()) { const storedState = this.context.getNestedChildState(this._getListKey()); @@ -749,42 +751,44 @@ class VirtualizedList extends React.PureComponent { this.state = initialState; } - static _prepareState( + static _createRenderMask( props: Props, - viewportWindow: {first: number, last: number}, - ): State { + cellsAroundViewport: {first: number, last: number}, + ): CellRenderMask { const itemCount = props.getItemCount(props.data); invariant( - viewportWindow.first >= 0 && - viewportWindow.last >= viewportWindow.first - 1 && - viewportWindow.last < itemCount, - `Invalid viewport window "${JSON.stringify( - viewportWindow, - )}" was passed to VirtualizedList._prepareState`, + cellsAroundViewport.first >= 0 && + cellsAroundViewport.last >= cellsAroundViewport.first - 1 && + cellsAroundViewport.last < itemCount, + `Invalid cells around viewport "[${cellsAroundViewport.first}, ${cellsAroundViewport.last}]" was passed to VirtualizedList._createRenderMask`, ); const renderMask = new CellRenderMask(itemCount); if (itemCount > 0) { - renderMask.addCells(viewportWindow); + renderMask.addCells(cellsAroundViewport); - const scrollIndex = props.initialScrollIndex || 0; - if (scrollIndex === 0) { + // The initially rendered cells are retained as part of the + // "scroll-to-top" optimization + if (props.initialScrollIndex == null || props.initialScrollIndex === 0) { const initialRegion = VirtualizedList._initialRenderRegion(props); renderMask.addCells(initialRegion); } + // The layout coordinates of sticker headers may be off-screen while the + // actual header is on-screen. Keep the most recent before the viewport + // rendered, even if its layout coordinates are not in viewport. const stickyIndicesSet = new Set(props.stickyHeaderIndices); - VirtualizedList._ensureStickyHeadersBefore( + VirtualizedList._ensureClosestStickyHeader( props, stickyIndicesSet, renderMask, - viewportWindow.first, + cellsAroundViewport.first, ); } - return {renderMask, viewportWindow}; + return renderMask; } static _initialRenderRegion(props: Props): {first: number, last: number} { @@ -801,7 +805,7 @@ class VirtualizedList extends React.PureComponent { }; } - static _ensureStickyHeadersBefore( + static _ensureClosestStickyHeader( props: Props, stickyIndicesSet: Set, renderMask: CellRenderMask, @@ -816,9 +820,9 @@ class VirtualizedList extends React.PureComponent { } } - _adjustViewportWindow( + _adjustCellsAroundViewport( props: Props, - viewportWindow: {first: number, last: number}, + cellsAroundViewport: {first: number, last: number}, ): {first: number, last: number} { const {data, getItemCount} = props; const onEndReachedThreshold = onEndReachedThresholdOrDefault( @@ -831,10 +835,10 @@ class VirtualizedList extends React.PureComponent { // Wait until the scroll view metrics have been set up. And until then, // we will trust the initialNumToRender suggestion if (visibleLength <= 0 || contentLength <= 0) { - return viewportWindow; + return cellsAroundViewport; } - let newviewportWindow: {first: number, last: number}; + let newCellsAroundViewport: {first: number, last: number}; if (this._isVirtualizationDisabled()) { const distanceFromEnd = contentLength - visibleLength - offset; const renderAhead = @@ -842,10 +846,10 @@ class VirtualizedList extends React.PureComponent { ? maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch) : 0; - newviewportWindow = { + newCellsAroundViewport = { first: 0, last: Math.min( - this.state.viewportWindow.last + renderAhead, + this.state.cellsAroundViewport.last + renderAhead, getItemCount(data) - 1, ), }; @@ -854,15 +858,15 @@ class VirtualizedList extends React.PureComponent { // wait until we've scrolled the view to the right place. And until then, // we will trust the initialScrollIndex suggestion. if (this.props.initialScrollIndex && !this._scrollMetrics.offset) { - return viewportWindow; + return cellsAroundViewport; } - newviewportWindow = computeWindowedRenderLimits( + newCellsAroundViewport = computeWindowedRenderLimits( props.data, props.getItemCount, maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch), windowSizeOrDefault(props.windowSize), - viewportWindow, + cellsAroundViewport, this._getFrameMetricsApprox, this._scrollMetrics, ); @@ -876,14 +880,14 @@ class VirtualizedList extends React.PureComponent { // Will this prevent rendering if the nested list doesn't realize the end? const childIdx = this._findFirstChildWithMore( - newviewportWindow.first, - newviewportWindow.last, + newCellsAroundViewport.first, + newCellsAroundViewport.last, ); - newviewportWindow.last = childIdx || newviewportWindow.last; + newCellsAroundViewport.last = childIdx ?? newCellsAroundViewport.last; } - return newviewportWindow; + return newCellsAroundViewport; } _findFirstChildWithMore(first: number, last: number): number | null { @@ -940,10 +944,22 @@ class VirtualizedList extends React.PureComponent { } static getDerivedStateFromProps(newProps: Props, prevState: State): State { - return VirtualizedList._prepareState( + // first and last could be stale (e.g. if a new, shorter items props is passed in), so we make + // sure we're rendering a reasonable range here. + const itemCount = newProps.getItemCount(newProps.data); + if (itemCount === prevState.renderMask.numCells()) { + return prevState; + } + + const constrainedCells = VirtualizedList._constrainToItemCount( + prevState.cellsAroundViewport, newProps, - VirtualizedList._constrainWindow(prevState, newProps), ); + + return { + cellsAroundViewport: prevState.cellsAroundViewport, + renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells), + }; } _pushCells( @@ -998,26 +1014,20 @@ class VirtualizedList extends React.PureComponent { } } - static _constrainWindow( - prevState: State, + static _constrainToItemCount( + cells: {first: number, last: number}, props: Props, ): {first: number, last: number} { - // first and last could be stale (e.g. if a new, shorter items props is passed in), so we make - // sure we're rendering a reasonable range here. - const prevWindow = prevState.viewportWindow; - const itemCount = props.getItemCount(props.data); + const last = Math.min(itemCount - 1, cells.last); + + const maxToRenderPerBatch = maxToRenderPerBatchOrDefault( + props.maxToRenderPerBatch, + ); + return { - first: Math.max( - 0, - Math.min( - prevWindow.first, - itemCount - - 1 - - maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch), - ), - ), - last: Math.min(itemCount - 1, prevWindow.last), + first: clamp(0, itemCount - 1 - maxToRenderPerBatch, cells.first), + last, }; } @@ -1137,7 +1147,7 @@ class VirtualizedList extends React.PureComponent { ); } - // 2b. Add cells and gaps for each item + // 2b. Add cells and spacers for each item if (itemCount > 0) { _usedIndexForKey = false; _keylessItemComponentName = ''; @@ -1250,7 +1260,7 @@ class VirtualizedList extends React.PureComponent { : this.props.style, }; - this._hasMore = this.state.viewportWindow.last < itemCount - 1; + this._hasMore = this.state.cellsAroundViewport.last < itemCount - 1; const innerRet = ( { _computeBlankness() { this._fillRateHelper.computeBlankness( this.props, - this.state.viewportWindow, + this.state.cellsAroundViewport, this._scrollMetrics, ); } @@ -1585,10 +1595,10 @@ class VirtualizedList extends React.PureComponent { } } const windowTop = this._getFrameMetricsApprox( - this.state.viewportWindow.first, + this.state.cellsAroundViewport.first, ).offset; const frameLast = this._getFrameMetricsApprox( - this.state.viewportWindow.last, + this.state.cellsAroundViewport.last, ); const windowLen = frameLast.offset + frameLast.length - windowTop; const visTop = this._scrollMetrics.offset; @@ -1664,7 +1674,7 @@ class VirtualizedList extends React.PureComponent { onEndReachedThreshold != null ? onEndReachedThreshold * visibleLength : 2; if ( onEndReached && - this.state.viewportWindow.last === getItemCount(data) - 1 && + this.state.cellsAroundViewport.last === getItemCount(data) - 1 && distanceFromEnd < threshold && this._scrollMetrics.contentLength !== this._sentEndForContentLength ) { @@ -1792,7 +1802,7 @@ class VirtualizedList extends React.PureComponent { }; _scheduleCellsToRenderUpdate() { - const {first, last} = this.state.viewportWindow; + const {first, last} = this.state.cellsAroundViewport; const {offset, visibleLength, velocity} = this._scrollMetrics; const itemCount = this.props.getItemCount(this.props.data); let hiPri = false; @@ -1881,23 +1891,24 @@ class VirtualizedList extends React.PureComponent { _updateCellsToRender = () => { this.setState((state, props) => { - const nextState = VirtualizedList._prepareState( + const cellsAroundViewport = this._adjustCellsAroundViewport( props, - this._adjustViewportWindow(props, state.viewportWindow), + state.cellsAroundViewport, + ); + const renderMask = VirtualizedList._createRenderMask( + props, + cellsAroundViewport, ); - - const viewportWindow = state.viewportWindow; - const nextviewportWindow = nextState.viewportWindow; if ( - nextviewportWindow.first === viewportWindow.first && - nextviewportWindow.last === viewportWindow.last && - nextState.renderMask.equals(state.renderMask) + cellsAroundViewport.first === state.cellsAroundViewport.first && + cellsAroundViewport.last === state.cellsAroundViewport.last && + renderMask.equals(state.renderMask) ) { return null; } - return nextState; + return {cellsAroundViewport, renderMask}; }); }; @@ -1969,7 +1980,7 @@ class VirtualizedList extends React.PureComponent { this._getFrameMetrics, this._createViewToken, tuple.onViewableItemsChanged, - this.state.viewportWindow, + this.state.cellsAroundViewport, ); }); } diff --git a/Libraries/Lists/__tests__/VirtualizedList-test.js b/Libraries/Lists/__tests__/VirtualizedList-test.js index b2093ce07c1b10..26223e43fe3beb 100644 --- a/Libraries/Lists/__tests__/VirtualizedList-test.js +++ b/Libraries/Lists/__tests__/VirtualizedList-test.js @@ -965,13 +965,12 @@ it('renders no spacers up to initialScrollIndex on first render when virtualizat ); }); - // There should be no spacers present in an offset initial render with - // virtualiztion disabled. Only initialNumToRender items starting at - // initialScrollIndex. + // We should render initialNumToRender items with no spacers on initial render + // when virtualization is disabled expect(component).toMatchSnapshot(); }); -it('expands first in viewport to render up to maxToRenderPerBatch on initial render', () => { +it('renders initialNumToRender when initialScrollIndex is offset', () => { const items = generateItems(10); const ITEM_HEIGHT = 10; @@ -988,9 +987,7 @@ it('expands first in viewport to render up to maxToRenderPerBatch on initial ren ); }); - // When virtualization is disabled we may render items before initialItemIndex - // if initialItemIndex + initialNumToRender < maToRenderPerBatch. Expect cells - // 0-3 to be rendered in this example, even though initialScrollIndex is 4. + // We should render initialNumToRender items starting at initialScrollIndex. expect(component).toMatchSnapshot(); }); diff --git a/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap b/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap index ad93703101b2ad..a01d19feb4c26d 100644 --- a/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap +++ b/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap @@ -1993,45 +1993,10 @@ exports[`discards intitial render if initialScrollIndex != 0 1`] = ` - - - - - - - - - - - - - - - @@ -2399,33 +2364,12 @@ exports[`does not over-render when there is less than initialNumToRender cells 1 > - - - - - - - - - - - + style={ + Object { + "height": 40, + } + } + /> @@ -2602,113 +2546,6 @@ exports[`eventually renders all items when virtualization disabled 1`] = ` `; -exports[`expands first in viewport to render up to maxToRenderPerBatch on initial render 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - -`; - exports[`expands render area by maxToRenderPerBatch on tick 1`] = ` + + + + + +`; + +exports[`renders initialNumToRender when initialScrollIndex is offset 1`] = ` + + + @@ -3138,6 +3047,13 @@ exports[`renders initialNumToRender cells when virtualization disabled 1`] = ` value={5} /> + `; @@ -3377,33 +3293,12 @@ exports[`renders offset cells in initial render when initialScrollIndex set 1`] > - - - - - - - - - - - + style={ + Object { + "height": 40, + } + } + /> @@ -4405,45 +4300,10 @@ exports[`retains intitial render if initialScrollIndex == 0 1`] = ` - - - - - - - - - - - - - - -