From 971599317b7bdf1152157206f9503a23ac8c4162 Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Thu, 13 Oct 2022 05:04:40 -0700 Subject: [PATCH] Ship VirtualizedList_EXPERIMENTAL Summary: `VirtualizedList_EXPERIMENTAL` is a fork of VirtualizedList. It was originally created for the purpose of keyboard/a11y fixes, which required the list to allow rendering discontiguous regions of cells. So, the large part of the refactor is in the data structure used to represent state (now a sparse bitmask), and building out the render function to operate off the bitmask. This structure allows pre-rendering areas where we know focus must be able to be synchronously be moved to. This is exercised on platforms like Windows and macOS which fire view level focus events. The new implementation otherwise aims to preserve near-exact list behavior as previously. Apart from the structure change, the refactor has made the code (subjectively) a lot easier to reason about, and there are now stronger internal invariants than before. The bitmask structure also enables new approaches for nested list stability, and implementing OffScreen. **Why ship it now?** Having two implementations has multiple times prevented other changes to VirtualizedList, so it is incurring a constant engineer cost. At this point, we have a lot of reason to be confident in the maturity of the new list implementation (see the test plan below). So, reconciling the implementations not only unlocks the enhancements of the new list, but unblocks future changes to VirtualizedList. ## Test Plan 1. **Unit tests:** A few dozen unit tests were added to VirtualizedList, validating the regions it renders in response to injected input, across its API surface. Both VirtualizedList implementations must pass the same tests, around expected behavior. 2. **RNTester:** Scenarios in RNTester were manually validated, and all worked as before 3. **New invariants:** I added a lot of internal logic checks and bounds checks as invariant violations, to try to produce signal if any part of the refactor went unexpected in the wild. 4. **MSFT Rollout:** I rolled out the changes as a fork of VirtualizedList at MSFT to a couple of high-usage surfaces using Android, iOS, and Windows on Paper. Some invariant violations were surfaced and fixed, but telemetry showed solid system and scenario health with the change. 5. **Meta import:** Fixed all land-blocking test failures when using the new list. These were confined to updating snapshots, and adding additional checks for invalid input. 6. **Panel apps:** Manually validated top-level surfaces of panel apps in debug/release modes. Fixed a couple of invariant violations and novel usages. Profiled new JS structures against an expensive list with Hermes profiler. 7. **Facebook App Rollout:** After some manual validation of top-level surfaces, the change was rolled out to Facebook for Android and iOS as an experiment. New invariant violations were fixed, and the change has sat in production for several weeks at this point, while measuring impact to metrics like error rate, responsiveness, and impressions. This is the first introduction to all of OSS, and some Meta internal apps. This means there may still be novel usages of VirtualizedList that were not picked up during testing, but I think there has been enough diligence to roll out what has been tested, and forward fix anything unexpected that might still come up. Changelog: [General][Changed] - Ship VirtualizedList_EXPERIMENTAL Reviewed By: javache Differential Revision: D40259791 fbshipit-source-id: 63eee9381d197a1e38ae663b2158436ff135c0e1 --- Libraries/Lists/FlatList.js | 2 +- Libraries/Lists/FlatList.js.flow | 4 +- Libraries/Lists/VirtualizedList.js | 772 ++- Libraries/Lists/VirtualizedListContext.js | 9 +- Libraries/Lists/VirtualizedListInjection.js | 73 - .../Lists/VirtualizedList_EXPERIMENTAL.js | 1880 ------ .../Lists/__tests__/VirtualizedList-test.js | 321 +- .../VirtualizedList_EXPERIMENTAL-test.js | 14 - .../__snapshots__/FlatList-test.js.snap | 13 + .../__snapshots__/SectionList-test.js.snap | 20 + .../VirtualizedList-test.js.snap | 1661 +++-- .../VirtualizedList_EXPERIMENTAL-test.js.snap | 5837 ----------------- .../VirtualizedSectionList-test.js.snap | 47 + Libraries/Utilities/ReactNativeTestTools.js | 2 +- index.js | 2 +- 15 files changed, 1984 insertions(+), 8673 deletions(-) delete mode 100644 Libraries/Lists/VirtualizedListInjection.js delete mode 100644 Libraries/Lists/VirtualizedList_EXPERIMENTAL.js delete mode 100644 Libraries/Lists/__tests__/VirtualizedList_EXPERIMENTAL-test.js delete mode 100644 Libraries/Lists/__tests__/__snapshots__/VirtualizedList_EXPERIMENTAL-test.js.snap diff --git a/Libraries/Lists/FlatList.js b/Libraries/Lists/FlatList.js index f3edf6105c53a5..66e50b5f7a21d4 100644 --- a/Libraries/Lists/FlatList.js +++ b/Libraries/Lists/FlatList.js @@ -17,6 +17,7 @@ import type { import type {RenderItemProps, RenderItemType} from './VirtualizedList'; import {type ScrollResponderType} from '../Components/ScrollView/ScrollView'; +import VirtualizedList from './VirtualizedList'; import {keyExtractor as defaultKeyExtractor} from './VirtualizeUtils'; import memoizeOne from 'memoize-one'; @@ -24,7 +25,6 @@ const View = require('../Components/View/View'); const StyleSheet = require('../StyleSheet/StyleSheet'); const deepDiffer = require('../Utilities/differ/deepDiffer'); const Platform = require('../Utilities/Platform'); -const VirtualizedList = require('./VirtualizedList'); const invariant = require('invariant'); const React = require('react'); diff --git a/Libraries/Lists/FlatList.js.flow b/Libraries/Lists/FlatList.js.flow index 7c6f6894bb3ee9..4a0ee0be1d7269 100644 --- a/Libraries/Lists/FlatList.js.flow +++ b/Libraries/Lists/FlatList.js.flow @@ -10,7 +10,6 @@ const React = require('react'); const View = require('../Components/View/View'); -const VirtualizedList = require('./VirtualizedList'); import typeof ScrollViewNativeComponent from '../Components/ScrollView/ScrollViewNativeComponent'; import {type ScrollResponderType} from '../Components/ScrollView/ScrollView'; @@ -20,6 +19,7 @@ import type { ViewabilityConfigCallbackPair, } from './ViewabilityHelper'; import type {RenderItemType, RenderItemProps} from './VirtualizedList'; +import typeof VirtualizedList from './VirtualizedList'; type RequiredProps = {| /** @@ -61,7 +61,7 @@ type FlatListProps = {| ...OptionalProps, |}; -type VirtualizedListProps = React.ElementConfig; +type VirtualizedListProps = React.ElementConfig; export type Props = { ...$Diff< diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index 096772778fc983..00343aff436d7d 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -21,32 +21,33 @@ import type { Separators, } from './VirtualizedListProps'; +import RefreshControl from '../Components/RefreshControl/RefreshControl'; +import ScrollView from '../Components/ScrollView/ScrollView'; +import View from '../Components/View/View'; +import Batchinator from '../Interaction/Batchinator'; +import {findNodeHandle} from '../ReactNative/RendererProxy'; +import flattenStyle from '../StyleSheet/flattenStyle'; +import StyleSheet from '../StyleSheet/StyleSheet'; +import clamp from '../Utilities/clamp'; +import infoLog from '../Utilities/infoLog'; +import {CellRenderMask} from './CellRenderMask'; import ChildListCollection from './ChildListCollection'; +import FillRateHelper from './FillRateHelper'; +import StateSafePureComponent from './StateSafePureComponent'; +import ViewabilityHelper from './ViewabilityHelper'; import CellRenderer from './VirtualizedListCellRenderer'; import { VirtualizedListCellContextProvider, VirtualizedListContext, VirtualizedListContextProvider, } from './VirtualizedListContext.js'; -import * as VirtualizedListInjection from './VirtualizedListInjection'; import { computeWindowedRenderLimits, keyExtractor as defaultKeyExtractor, } from './VirtualizeUtils'; +import invariant from 'invariant'; import * as React from 'react'; -const RefreshControl = require('../Components/RefreshControl/RefreshControl'); -const ScrollView = require('../Components/ScrollView/ScrollView'); -const View = require('../Components/View/View'); -const Batchinator = require('../Interaction/Batchinator'); -const {findNodeHandle} = require('../ReactNative/RendererProxy'); -const flattenStyle = require('../StyleSheet/flattenStyle'); -const StyleSheet = require('../StyleSheet/StyleSheet'); -const infoLog = require('../Utilities/infoLog'); -const FillRateHelper = require('./FillRateHelper'); -const ViewabilityHelper = require('./ViewabilityHelper'); -const invariant = require('invariant'); - export type {RenderItemProps, RenderItemType, Separators}; const ON_END_REACHED_EPSILON = 0.001; @@ -65,8 +66,8 @@ type ViewabilityHelperCallbackTuple = { }; type State = { - first: number, - last: number, + renderMask: CellRenderMask, + cellsAroundViewport: {first: number, last: number}, }; /** @@ -104,6 +105,19 @@ function windowSizeOrDefault(windowSize: ?number) { return windowSize ?? 21; } +function findLastWhere( + arr: $ReadOnlyArray, + predicate: (element: T) => boolean, +): T | null { + for (let i = arr.length - 1; i >= 0; i--) { + if (predicate(arr[i])) { + return arr[i]; + } + } + + return null; +} + /** * Base implementation for the more convenient [``](https://reactnative.dev/docs/flatlist) * and [``](https://reactnative.dev/docs/sectionlist) components, which are also better @@ -133,14 +147,17 @@ function windowSizeOrDefault(windowSize: ?number) { * - As an effort to remove defaultProps, use helper functions when referencing certain props * */ -class VirtualizedList extends React.PureComponent { +export default class VirtualizedList extends StateSafePureComponent< + Props, + State, +> { static contextType: typeof VirtualizedListContext = VirtualizedListContext; // scrollToEnd may be janky without getItemLayout prop scrollToEnd(params?: ?{animated?: ?boolean, ...}) { const animated = params ? params.animated : true; const veryLast = this.props.getItemCount(this.props.data) - 1; - const frame = this.__getFrameMetricsApprox(veryLast); + const frame = this.__getFrameMetricsApprox(veryLast, this.props); const offset = Math.max( 0, frame.offset + @@ -214,11 +231,11 @@ class VirtualizedList extends React.PureComponent { }); return; } - const frame = this.__getFrameMetricsApprox(index); + 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); @@ -300,7 +317,7 @@ class VirtualizedList extends React.PureComponent { this._viewabilityTuples.forEach(t => { t.viewabilityHelper.recordInteraction(); }); - this._updateViewableItems(this.props.data); + this._updateViewableItems(this.props, this.state.cellsAroundViewport); } flashScrollIndicators() { @@ -370,20 +387,18 @@ class VirtualizedList extends React.PureComponent { _registerAsNestedChild = (childList: { cellKey: string, - ref: React.ElementRef, + ref: React.ElementRef, }): void => { - const listRef = ((childList.ref: any): VirtualizedList); - this._nestedChildLists.add(listRef, childList.cellKey); + this._nestedChildLists.add(childList.ref, childList.cellKey); if (this._hasInteracted) { - listRef.recordInteraction(); + childList.ref.recordInteraction(); } }; _unregisterAsNestedChild = (childList: { - ref: React.ElementRef, + ref: React.ElementRef, }): void => { - const listRef = ((childList.ref: any): VirtualizedList); - this._nestedChildLists.remove(listRef); + this._nestedChildLists.remove(childList.ref); }; state: State; @@ -401,6 +416,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, @@ -429,17 +449,192 @@ class VirtualizedList extends React.PureComponent { 'Unexpectedly saw VirtualizedListContext available in ctor', ); + const initialRenderRegion = VirtualizedList._initialRenderRegion(props); + this.state = { - first: this.props.initialScrollIndex || 0, + cellsAroundViewport: initialRenderRegion, + renderMask: VirtualizedList._createRenderMask(props, initialRenderRegion), + }; + } + + static _createRenderMask( + props: Props, + cellsAroundViewport: {first: number, last: number}, + additionalRegions?: ?$ReadOnlyArray<{first: number, last: number}>, + ): CellRenderMask { + const itemCount = props.getItemCount(props.data); + + invariant( + 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) { + const allRegions = [cellsAroundViewport, ...(additionalRegions ?? [])]; + for (const region of allRegions) { + renderMask.addCells(region); + } + + // 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._ensureClosestStickyHeader( + props, + stickyIndicesSet, + renderMask, + cellsAroundViewport.first, + ); + } + + return renderMask; + } + + static _initialRenderRegion(props: Props): {first: number, last: number} { + const itemCount = props.getItemCount(props.data); + const scrollIndex = Math.floor(Math.max(0, props.initialScrollIndex ?? 0)); + + return { + first: scrollIndex, last: Math.min( - this.props.getItemCount(this.props.data), - (this.props.initialScrollIndex || 0) + - initialNumToRenderOrDefault(this.props.initialNumToRender), + itemCount, + scrollIndex + initialNumToRenderOrDefault(props.initialNumToRender), ) - 1, }; } + static _ensureClosestStickyHeader( + 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}); + break; + } + } + } + + _adjustCellsAroundViewport( + props: Props, + cellsAroundViewport: {first: number, last: number}, + ): {first: number, last: number} { + const {data, getItemCount} = props; + const onEndReachedThreshold = onEndReachedThresholdOrDefault( + props.onEndReachedThreshold, + ); + this._updateViewableItems(props, cellsAroundViewport); + + const {contentLength, offset, visibleLength} = this._scrollMetrics; + const distanceFromEnd = contentLength - visibleLength - offset; + + // 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 cellsAroundViewport.last >= getItemCount(data) + ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) + : cellsAroundViewport; + } + + let newCellsAroundViewport: {first: number, last: number}; + if (props.disableVirtualization) { + const renderAhead = + distanceFromEnd < onEndReachedThreshold * visibleLength + ? maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch) + : 0; + + newCellsAroundViewport = { + first: 0, + last: Math.min( + cellsAroundViewport.last + renderAhead, + getItemCount(data) - 1, + ), + }; + } else { + // 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. + + // Thus, we want to recalculate the windowed render limits if any of the following hold: + // - initialScrollIndex is undefined or is 0 + // - initialScrollIndex > 0 AND scrolling is complete + // - initialScrollIndex > 0 AND the end of the list is visible (this handles the case + // where the list is shorter than the visible area) + if ( + props.initialScrollIndex && + !this._scrollMetrics.offset && + Math.abs(distanceFromEnd) >= Number.EPSILON + ) { + return cellsAroundViewport.last >= getItemCount(data) + ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) + : cellsAroundViewport; + } + + newCellsAroundViewport = computeWindowedRenderLimits( + props, + maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch), + windowSizeOrDefault(props.windowSize), + cellsAroundViewport, + this.__getFrameMetricsApprox, + this._scrollMetrics, + ); + invariant( + newCellsAroundViewport.last < getItemCount(data), + 'computeWindowedRenderLimits() should return range in-bounds', + ); + } + + 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( + newCellsAroundViewport.first, + newCellsAroundViewport.last, + ); + + newCellsAroundViewport.last = childIdx ?? newCellsAroundViewport.last; + } + + return newCellsAroundViewport; + } + + _findFirstChildWithMore(first: number, last: number): number | null { + for (let ii = first; ii <= last; ii++) { + const cellKeyForIndex = this._indicesToKeys.get(ii); + if ( + cellKeyForIndex != null && + this._nestedChildLists.anyInCell(cellKeyForIndex, childList => + childList.hasMore(), + ) + ) { + return ii; + } + } + + return null; + } + componentDidMount() { if (this._isNestedWithSameOrientation()) { this.context.registerAsNestedChild({ @@ -453,7 +648,6 @@ class VirtualizedList extends React.PureComponent { if (this._isNestedWithSameOrientation()) { this.context.unregisterAsNestedChild({ref: this}); } - this._updateViewableItems(null); this._updateCellsToRenderBatcher.dispose({abort: true}); this._viewabilityTuples.forEach(tuple => { tuple.viewabilityHelper.dispose(); @@ -462,18 +656,21 @@ class VirtualizedList extends React.PureComponent { } static getDerivedStateFromProps(newProps: Props, prevState: State): State { - const {data, getItemCount} = newProps; - const maxToRenderPerBatch = maxToRenderPerBatchOrDefault( - newProps.maxToRenderPerBatch, - ); // 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, + ); + return { - first: Math.max( - 0, - Math.min(prevState.first, getItemCount(data) - 1 - maxToRenderPerBatch), - ), - last: Math.max(0, Math.min(prevState.last, getItemCount(data) - 1)), + cellsAroundViewport: constrainedCells, + renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells), }; } @@ -504,7 +701,7 @@ class VirtualizedList extends React.PureComponent { last = Math.min(end, last); for (let ii = first; ii <= last; ii++) { const item = getItem(data, ii); - const key = this._keyExtractor(item, ii); + const key = this._keyExtractor(item, ii, this.props); this._indicesToKeys.set(ii, key); if (stickyIndicesFromProps.has(ii + stickyOffset)) { stickyHeaderIndices.push(cells.length); @@ -526,6 +723,7 @@ class VirtualizedList extends React.PureComponent { prevCellKey={prevCellKey} onCellLayout={this._onCellLayout} onUpdateSeparators={this._onUpdateSeparators} + onCellFocusCapture={e => this._onCellFocusCapture(key)} onUnmount={this._onCellUnmount} ref={ref => { this._cellRefs[key] = ref; @@ -537,6 +735,23 @@ class VirtualizedList extends React.PureComponent { } } + static _constrainToItemCount( + cells: {first: number, last: number}, + props: Props, + ): {first: number, last: number} { + const itemCount = props.getItemCount(props.data); + const last = Math.min(itemCount - 1, cells.last); + + const maxToRenderPerBatch = maxToRenderPerBatchOrDefault( + props.maxToRenderPerBatch, + ); + + return { + first: clamp(0, itemCount - 1 - maxToRenderPerBatch, cells.first), + last, + }; + } + _onUpdateSeparators = (keys: Array, newProps: Object) => { keys.forEach(key => { const ref = key != null && this._cellRefs[key]; @@ -544,10 +759,6 @@ class VirtualizedList extends React.PureComponent { }); }; - _isVirtualizationDisabled(): boolean { - return this.props.disableVirtualization || false; - } - _isNestedWithSameOrientation(): boolean { const nestedContext = this.context; return !!( @@ -559,10 +770,17 @@ class VirtualizedList extends React.PureComponent { _getSpacerKey = (isVertical: boolean): string => isVertical ? 'height' : 'width'; - // $FlowFixMe[missing-local-annot] - _keyExtractor(item: Item, index: number) { - if (this.props.keyExtractor != null) { - return this.props.keyExtractor(item, index); + _keyExtractor( + item: Item, + index: number, + props: { + keyExtractor?: ?(item: Item, index: number) => string, + ... + }, + // $FlowFixMe[missing-local-annot] + ) { + if (props.keyExtractor != null) { + return props.keyExtractor(item, index); } const key = defaultKeyExtractor(item, index); @@ -588,7 +806,6 @@ class VirtualizedList extends React.PureComponent { const {ListEmptyComponent, ListFooterComponent, ListHeaderComponent} = this.props; const {data, horizontal} = this.props; - const isVirtualizationDisabled = this._isVirtualizationDisabled(); const inversionStyle = this.props.inverted ? horizontalOrDefault(this.props.horizontal) ? styles.horizontallyInverted @@ -597,6 +814,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); @@ -626,103 +845,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, ) ? ( @@ -745,6 +871,73 @@ class VirtualizedList extends React.PureComponent { }), ); } + + // 2b. Add cells and spacers 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.props.disableVirtualization) { + 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, + this.props, + ); + const lastMetrics = this.__getFrameMetricsApprox(last, this.props); + 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 @@ -771,6 +964,8 @@ class VirtualizedList extends React.PureComponent { , ); } + + // 4. Render the ScrollView const scrollProps = { ...this.props, onContentSizeChange: this._onContentSizeChange, @@ -793,8 +988,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.cellsAroundViewport.last < itemCount - 1; const innerRet = ( { !scrollContext.horizontal === !horizontalOrDefault(this.props.horizontal) && !this._hasWarned.nesting && - this.context == null + this.context == null && + this.props.scrollEnabled !== false ) { // TODO (T46547044): use React.warn once 16.9 is sync'd: https://github.com/facebook/react/pull/15170 console.error( @@ -900,6 +1095,7 @@ class VirtualizedList extends React.PureComponent { _hiPriInProgress: boolean = false; // flag to prevent infinite hiPri cell limit update _highestMeasuredFrameIndex = 0; _indicesToKeys: Map = new Map(); + _lastFocusedCellKey: ?string = null; _nestedChildLists: ChildListCollection = new ChildListCollection(); _offsetFromParentVirtualizedList: number = 0; @@ -931,7 +1127,7 @@ class VirtualizedList extends React.PureComponent { _computeBlankness() { this._fillRateHelper.computeBlankness( this.props, - this.state, + this.state.cellsAroundViewport, this._scrollMetrics, ); } @@ -1008,9 +1204,25 @@ class VirtualizedList extends React.PureComponent { this._triggerRemeasureForChildListsInCell(cellKey); this._computeBlankness(); - this._updateViewableItems(this.props.data); + this._updateViewableItems(this.props, this.state.cellsAroundViewport); }; + _onCellFocusCapture(cellKey: string) { + this._lastFocusedCellKey = cellKey; + const renderMask = VirtualizedList._createRenderMask( + this.props, + this.state.cellsAroundViewport, + this._getNonViewportRenderRegions(this.props), + ); + + this.setState(state => { + if (!renderMask.equals(state.renderMask)) { + return {renderMask}; + } + return null; + }); + } + _onCellUnmount = (cellKey: string) => { const curr = this._frames[cellKey]; if (curr) { @@ -1115,7 +1327,7 @@ class VirtualizedList extends React.PureComponent { const framesInLayout = []; const itemCount = this.props.getItemCount(this.props.data); for (let ii = 0; ii < itemCount; ii++) { - const frame = this.__getFrameMetricsApprox(ii); + const frame = this.__getFrameMetricsApprox(ii, this.props); /* $FlowFixMe[prop-missing] (>=0.68.0 site=react_native_fb) This comment * suppresses an error found when Flow v0.68 was deployed. To see the * error delete this comment and run Flow. */ @@ -1123,8 +1335,14 @@ 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.cellsAroundViewport.first, + this.props, + ).offset; + const frameLast = this.__getFrameMetricsApprox( + this.state.cellsAroundViewport.last, + this.props, + ); const windowLen = frameLast.offset + frameLast.length - windowTop; const visTop = this._scrollMetrics.offset; const visLen = this._scrollMetrics.visibleLength; @@ -1208,7 +1426,7 @@ class VirtualizedList extends React.PureComponent { onEndReachedThreshold != null ? onEndReachedThreshold * visibleLength : 2; if ( onEndReached && - this.state.last === getItemCount(data) - 1 && + this.state.cellsAroundViewport.last === getItemCount(data) - 1 && distanceFromEnd <= threshold && this._scrollMetrics.contentLength !== this._sentEndForContentLength ) { @@ -1327,7 +1545,7 @@ class VirtualizedList extends React.PureComponent { visibleLength, zoomScale, }; - this._updateViewableItems(this.props.data); + this._updateViewableItems(this.props, this.state.cellsAroundViewport); if (!this.props) { return; } @@ -1340,7 +1558,7 @@ class VirtualizedList extends React.PureComponent { }; _scheduleCellsToRenderUpdate() { - const {first, last} = this.state; + const {first, last} = this.state.cellsAroundViewport; const {offset, visibleLength, velocity} = this._scrollMetrics; const itemCount = this.props.getItemCount(this.props.data); let hiPri = false; @@ -1351,15 +1569,17 @@ class VirtualizedList extends React.PureComponent { // Mark as high priority if we're close to the start of the first item // But only if there are items before the first rendered item if (first > 0) { - const distTop = offset - this.__getFrameMetricsApprox(first).offset; + const distTop = + offset - this.__getFrameMetricsApprox(first, this.props).offset; hiPri = hiPri || distTop < 0 || (velocity < -2 && distTop < scrollingThreshold); } // Mark as high priority if we're close to the end of the last item // But only if there are items after the last rendered item - if (last < itemCount - 1) { + if (last >= 0 && last < itemCount - 1) { const distBottom = - this.__getFrameMetricsApprox(last).offset - (offset + visibleLength); + this.__getFrameMetricsApprox(last, this.props).offset - + (offset + visibleLength); hiPri = hiPri || distBottom < 0 || @@ -1428,124 +1648,80 @@ 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: ?( - | {first: number, last: number, ...} - | $TEMPORARY$object<{first: number, last: number}> + this.setState((state, props) => { + const cellsAroundViewport = this._adjustCellsAroundViewport( + props, + state.cellsAroundViewport, + ); + const renderMask = VirtualizedList._createRenderMask( + props, + cellsAroundViewport, + this._getNonViewportRenderRegions(props), ); - const {contentLength, offset, visibleLength} = this._scrollMetrics; - const distanceFromEnd = contentLength - visibleLength - offset; - 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. - - // Thus, we want to recalculate the windowed render limits if any of the following hold: - // - initialScrollIndex is undefined or is 0 - // - initialScrollIndex > 0 AND scrolling is complete - // - initialScrollIndex > 0 AND the end of the list is visible (this handles the case - // where the list is shorter than the visible area) - if ( - !this.props.initialScrollIndex || - this._scrollMetrics.offset || - Math.abs(distanceFromEnd) < Number.EPSILON - ) { - newState = computeWindowedRenderLimits( - this.props, - maxToRenderPerBatchOrDefault(this.props.maxToRenderPerBatch), - windowSizeOrDefault(this.props.windowSize), - state, - this.__getFrameMetricsApprox, - this._scrollMetrics, - ); - } - } - } else { - 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); - if (cellKeyForIndex == null) { - continue; - } - // For each cell, need to check whether any child list in it has more elements to render - - const someChildHasMore = this._nestedChildLists.anyInCell( - cellKeyForIndex, - childList => childList.hasMore(), - ); - if (someChildHasMore) { - newState.last = ii; - break; - } - } - } if ( - newState != null && - newState.first === state.first && - newState.last === state.last + cellsAroundViewport.first === state.cellsAroundViewport.first && + cellsAroundViewport.last === state.cellsAroundViewport.last && + renderMask.equals(state.renderMask) ) { - newState = null; + return null; } - return newState; + + return {cellsAroundViewport, renderMask}; }); }; - // $FlowFixMe[missing-local-annot] - _createViewToken = (index: number, isViewable: boolean) => { - const {data, getItem} = this.props; + _createViewToken = ( + index: number, + isViewable: boolean, + props: FrameMetricProps, + // $FlowFixMe[missing-local-annot] + ) => { + const {data, getItem} = props; const item = getItem(data, index); - return {index, item, key: this._keyExtractor(item, index), isViewable}; + return { + index, + item, + key: this._keyExtractor(item, index, props), + isViewable, + }; + }; + + /** + * 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, + props: FrameMetricProps, ) => { length: number, offset: number, ... - } = index => { - const frame = this._getFrameMetrics(index); + } = (index, props) => { + const frame = this._getFrameMetrics(index, props); if (frame && frame.index === index) { // check for invalid frames due to row re-ordering return frame; } else { - const {getItemLayout} = this.props; + const {data, getItemCount, getItemLayout} = props; + invariant( + index >= 0 && index < getItemCount(data), + 'Tried to get frame for out of range index ' + index, + ); invariant( !getItemLayout, 'Should not have to estimate frames when a measurement metrics function is provided', @@ -1559,7 +1735,7 @@ class VirtualizedList extends React.PureComponent { _getFrameMetrics = ( index: number, - props?: FrameMetricProps, + props: FrameMetricProps, ): ?{ length: number, offset: number, @@ -1567,13 +1743,13 @@ class VirtualizedList extends React.PureComponent { inLayout?: boolean, ... } => { - const {data, getItem, getItemCount, getItemLayout} = this.props; + const {data, getItem, getItemCount, getItemLayout} = props; invariant( - getItemCount(data) > index, + index >= 0 && index < getItemCount(data), 'Tried to get frame for out of range index ' + index, ); const item = getItem(data, index); - const frame = item && this._frames[this._keyExtractor(item, index)]; + const frame = item && this._frames[this._keyExtractor(item, index, props)]; if (!frame || frame.index !== index) { if (getItemLayout) { /* $FlowFixMe[prop-missing] (>=0.63.0 site=react_native_fb) This comment @@ -1585,16 +1761,78 @@ class VirtualizedList extends React.PureComponent { return frame; }; - _updateViewableItems(data: any) { + _getNonViewportRenderRegions = ( + props: FrameMetricProps, + ): $ReadOnlyArray<{ + first: number, + last: number, + }> => { + // Keep a viewport's worth of content around the last focused cell to allow + // random navigation around it without any blanking. E.g. tabbing from one + // focused item out of viewport to another. + if ( + !(this._lastFocusedCellKey && this._cellRefs[this._lastFocusedCellKey]) + ) { + return []; + } + + const lastFocusedCellRenderer = this._cellRefs[this._lastFocusedCellKey]; + const focusedCellIndex = lastFocusedCellRenderer.props.index; + const itemCount = props.getItemCount(props.data); + + // The cell may have been unmounted and have a stale index + if ( + focusedCellIndex >= itemCount || + this._indicesToKeys.get(focusedCellIndex) !== this._lastFocusedCellKey + ) { + return []; + } + + let first = focusedCellIndex; + let heightOfCellsBeforeFocused = 0; + for ( + let i = first - 1; + i >= 0 && heightOfCellsBeforeFocused < this._scrollMetrics.visibleLength; + i-- + ) { + first--; + heightOfCellsBeforeFocused += this.__getFrameMetricsApprox( + i, + props, + ).length; + } + + let last = focusedCellIndex; + let heightOfCellsAfterFocused = 0; + for ( + let i = last + 1; + i < itemCount && + heightOfCellsAfterFocused < this._scrollMetrics.visibleLength; + i++ + ) { + last++; + heightOfCellsAfterFocused += this.__getFrameMetricsApprox( + i, + props, + ).length; + } + + return [{first, last}]; + }; + + _updateViewableItems( + props: FrameMetricProps, + cellsAroundViewport: {first: number, last: number}, + ) { this._viewabilityTuples.forEach(tuple => { tuple.viewabilityHelper.onUpdate( - this.props, + props, this._scrollMetrics.offset, this._scrollMetrics.visibleLength, this._getFrameMetrics, this._createViewToken, tuple.onViewableItemsChanged, - this.state, + cellsAroundViewport, ); }); } @@ -1636,7 +1874,3 @@ const styles = StyleSheet.create({ borderWidth: 2, }, }); - -module.exports = (VirtualizedListInjection.getOrDefault( - VirtualizedList, -): typeof VirtualizedList); diff --git a/Libraries/Lists/VirtualizedListContext.js b/Libraries/Lists/VirtualizedListContext.js index cce16deb9f1caf..904947c428f425 100644 --- a/Libraries/Lists/VirtualizedListContext.js +++ b/Libraries/Lists/VirtualizedListContext.js @@ -4,10 +4,11 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow strict + * @flow strict-local * @format */ +import VirtualizedList from './VirtualizedList'; import * as React from 'react'; import {useContext, useMemo} from 'react'; @@ -24,13 +25,13 @@ type Context = $ReadOnly<{ zoomScale: number, }, horizontal: ?boolean, - getOutermostParentListRef: () => React.ElementRef, + getOutermostParentListRef: () => React.ElementRef, registerAsNestedChild: ({ cellKey: string, - ref: React.ElementRef, + ref: React.ElementRef, }) => void, unregisterAsNestedChild: ({ - ref: React.ElementRef, + ref: React.ElementRef, }) => void, }>; diff --git a/Libraries/Lists/VirtualizedListInjection.js b/Libraries/Lists/VirtualizedListInjection.js deleted file mode 100644 index 651cd048af451a..00000000000000 --- a/Libraries/Lists/VirtualizedListInjection.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * 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 strict-local - * @format - */ - -'use strict'; - -import type VirtualizedList from './VirtualizedList'; -import type {Props as VirtualizedListProps} from './VirtualizedListProps'; - -import invariant from 'invariant'; -import * as React from 'react'; - -export type ListImplementation = React.ComponentType & - interface {}; - -/** - * Global override to the VirtualizedList implementation used when imported - */ -let injection: ?ListImplementation; -let retrieved = false; - -export function inject(listImplementation: ListImplementation): void { - invariant( - !retrieved, - 'VirtualizedListInjection.inject() called after the injection was already retrieved', - ); - injection = listImplementation; -} - -export function getOrDefault( - defaultImplementation: Class, -): Class { - retrieved = true; - return injection - ? verifyVirtualizedList(injection, defaultImplementation) - : defaultImplementation; -} - -function verifyVirtualizedList( - injectedImplementation: ListImplementation, - defaultImplementation: Class, -): Class { - // The original VirtualizedList marks method as "private by convention" by - // prefixing with underscore. These methods may still be called at runtime - // by tests or other internals, so they cannot be truly private, and will be - // included in the Flow type of VirtualizedList. - // Allow the injection to have different private methods by allowing a loose - // Flow type, but check at runtime that the set of public properties matches. - if (__DEV__) { - for (const field of Object.keys(defaultImplementation)) { - if (isPublicField(field)) { - invariant( - injectedImplementation.hasOwnProperty(field), - `VirtualizedList injection missing field: "${field}"`, - ); - } - } - } - - // $FlowExpectedError - return injectedImplementation; -} - -function isPublicField(fieldName: string): boolean { - // Respect JSTransform public methods by double underscore (D33982339) - return fieldName.length > 0 && (fieldName[0] !== '_' || fieldName[1] === '_'); -} diff --git a/Libraries/Lists/VirtualizedList_EXPERIMENTAL.js b/Libraries/Lists/VirtualizedList_EXPERIMENTAL.js deleted file mode 100644 index 4e40437fcbc892..00000000000000 --- a/Libraries/Lists/VirtualizedList_EXPERIMENTAL.js +++ /dev/null @@ -1,1880 +0,0 @@ -/** - * 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 - */ - -import type {ScrollResponderType} from '../Components/ScrollView/ScrollView'; -import type {ViewStyleProp} from '../StyleSheet/StyleSheet'; -import type {LayoutEvent, ScrollEvent} from '../Types/CoreEventTypes'; -import type {ViewToken} from './ViewabilityHelper'; -import type { - FrameMetricProps, - Item, - Props, - RenderItemProps, - RenderItemType, - Separators, -} from './VirtualizedListProps'; - -import RefreshControl from '../Components/RefreshControl/RefreshControl'; -import ScrollView from '../Components/ScrollView/ScrollView'; -import View from '../Components/View/View'; -import Batchinator from '../Interaction/Batchinator'; -import {findNodeHandle} from '../ReactNative/RendererProxy'; -import flattenStyle from '../StyleSheet/flattenStyle'; -import StyleSheet from '../StyleSheet/StyleSheet'; -import clamp from '../Utilities/clamp'; -import infoLog from '../Utilities/infoLog'; -import {CellRenderMask} from './CellRenderMask'; -import ChildListCollection from './ChildListCollection'; -import FillRateHelper from './FillRateHelper'; -import StateSafePureComponent from './StateSafePureComponent'; -import ViewabilityHelper from './ViewabilityHelper'; -import CellRenderer from './VirtualizedListCellRenderer'; -import { - VirtualizedListCellContextProvider, - VirtualizedListContext, - VirtualizedListContextProvider, -} from './VirtualizedListContext.js'; -import { - computeWindowedRenderLimits, - keyExtractor as defaultKeyExtractor, -} from './VirtualizeUtils'; -import invariant from 'invariant'; -import * as React from 'react'; - -export type {RenderItemProps, RenderItemType, Separators}; - -const ON_END_REACHED_EPSILON = 0.001; - -let _usedIndexForKey = false; -let _keylessItemComponentName: string = ''; - -type ViewabilityHelperCallbackTuple = { - viewabilityHelper: ViewabilityHelper, - onViewableItemsChanged: (info: { - viewableItems: Array, - changed: Array, - ... - }) => void, - ... -}; - -type State = { - renderMask: CellRenderMask, - cellsAroundViewport: {first: number, last: number}, -}; - -/** - * Default Props Helper Functions - * Use the following helper functions for default values - */ - -// horizontalOrDefault(this.props.horizontal) -function horizontalOrDefault(horizontal: ?boolean) { - return horizontal ?? false; -} - -// initialNumToRenderOrDefault(this.props.initialNumToRenderOrDefault) -function initialNumToRenderOrDefault(initialNumToRender: ?number) { - return initialNumToRender ?? 10; -} - -// maxToRenderPerBatchOrDefault(this.props.maxToRenderPerBatch) -function maxToRenderPerBatchOrDefault(maxToRenderPerBatch: ?number) { - return maxToRenderPerBatch ?? 10; -} - -// onEndReachedThresholdOrDefault(this.props.onEndReachedThreshold) -function onEndReachedThresholdOrDefault(onEndReachedThreshold: ?number) { - return onEndReachedThreshold ?? 2; -} - -// scrollEventThrottleOrDefault(this.props.scrollEventThrottle) -function scrollEventThrottleOrDefault(scrollEventThrottle: ?number) { - return scrollEventThrottle ?? 50; -} - -// windowSizeOrDefault(this.props.windowSize) -function windowSizeOrDefault(windowSize: ?number) { - return windowSize ?? 21; -} - -function findLastWhere( - arr: $ReadOnlyArray, - predicate: (element: T) => boolean, -): T | null { - for (let i = arr.length - 1; i >= 0; i--) { - if (predicate(arr[i])) { - return arr[i]; - } - } - - return null; -} - -/** - * Base implementation for the more convenient [``](https://reactnative.dev/docs/flatlist) - * and [``](https://reactnative.dev/docs/sectionlist) components, which are also better - * documented. In general, this should only really be used if you need more flexibility than - * `FlatList` provides, e.g. for use with immutable data instead of plain arrays. - * - * Virtualization massively improves memory consumption and performance of large lists by - * maintaining a finite render window of active items and replacing all items outside of the render - * window with appropriately sized blank space. The window adapts to scrolling behavior, and items - * are rendered incrementally with low-pri (after any running interactions) if they are far from the - * visible area, or with hi-pri otherwise to minimize the potential of seeing blank space. - * - * Some caveats: - * - * - Internal state is not preserved when content scrolls out of the render window. Make sure all - * your data is captured in the item data or external stores like Flux, Redux, or Relay. - * - This is a `PureComponent` which means that it will not re-render if `props` remain shallow- - * equal. Make sure that everything your `renderItem` function depends on is passed as a prop - * (e.g. `extraData`) that is not `===` after updates, otherwise your UI may not update on - * changes. This includes the `data` prop and parent component state. - * - In order to constrain memory and enable smooth scrolling, content is rendered asynchronously - * offscreen. This means it's possible to scroll faster than the fill rate ands momentarily see - * blank content. This is a tradeoff that can be adjusted to suit the needs of each application, - * and we are working on improving it behind the scenes. - * - By default, the list looks for a `key` or `id` prop on each item and uses that for the React key. - * Alternatively, you can provide a custom `keyExtractor` prop. - * - As an effort to remove defaultProps, use helper functions when referencing certain props - * - */ -export default class VirtualizedList extends StateSafePureComponent< - Props, - State, -> { - static contextType: typeof VirtualizedListContext = VirtualizedListContext; - - // scrollToEnd may be janky without getItemLayout prop - scrollToEnd(params?: ?{animated?: ?boolean, ...}) { - const animated = params ? params.animated : true; - const veryLast = this.props.getItemCount(this.props.data) - 1; - const frame = this.__getFrameMetricsApprox(veryLast, this.props); - const offset = Math.max( - 0, - frame.offset + - frame.length + - this._footerLength - - this._scrollMetrics.visibleLength, - ); - - if (this._scrollRef == null) { - return; - } - - if (this._scrollRef.scrollTo == null) { - console.warn( - 'No scrollTo method provided. This may be because you have two nested ' + - 'VirtualizedLists with the same orientation, or because you are ' + - 'using a custom component that does not implement scrollTo.', - ); - return; - } - - this._scrollRef.scrollTo( - horizontalOrDefault(this.props.horizontal) - ? {x: offset, animated} - : {y: offset, animated}, - ); - } - - // scrollToIndex may be janky without getItemLayout prop - scrollToIndex(params: { - animated?: ?boolean, - index: number, - viewOffset?: number, - viewPosition?: number, - ... - }): $FlowFixMe { - const { - data, - horizontal, - getItemCount, - getItemLayout, - onScrollToIndexFailed, - } = this.props; - const {animated, index, viewOffset, viewPosition} = params; - invariant( - index >= 0, - `scrollToIndex out of range: requested index ${index} but minimum is 0`, - ); - invariant( - getItemCount(data) >= 1, - `scrollToIndex out of range: item length ${getItemCount( - data, - )} but minimum is 1`, - ); - invariant( - index < getItemCount(data), - `scrollToIndex out of range: requested index ${index} is out of 0 to ${ - getItemCount(data) - 1 - }`, - ); - if (!getItemLayout && index > this._highestMeasuredFrameIndex) { - invariant( - !!onScrollToIndexFailed, - 'scrollToIndex should be used in conjunction with getItemLayout or onScrollToIndexFailed, ' + - 'otherwise there is no way to know the location of offscreen indices or handle failures.', - ); - onScrollToIndexFailed({ - averageItemLength: this._averageCellLength, - highestMeasuredFrameIndex: this._highestMeasuredFrameIndex, - index, - }); - return; - } - const frame = this.__getFrameMetricsApprox(Math.floor(index), this.props); - const offset = - Math.max( - 0, - this._getOffsetApprox(index, this.props) - - (viewPosition || 0) * - (this._scrollMetrics.visibleLength - frame.length), - ) - (viewOffset || 0); - - if (this._scrollRef == null) { - return; - } - - if (this._scrollRef.scrollTo == null) { - console.warn( - 'No scrollTo method provided. This may be because you have two nested ' + - 'VirtualizedLists with the same orientation, or because you are ' + - 'using a custom component that does not implement scrollTo.', - ); - return; - } - - this._scrollRef.scrollTo( - horizontal ? {x: offset, animated} : {y: offset, animated}, - ); - } - - // scrollToItem may be janky without getItemLayout prop. Required linear scan through items - - // use scrollToIndex instead if possible. - scrollToItem(params: { - animated?: ?boolean, - item: Item, - viewPosition?: number, - ... - }) { - const {item} = params; - const {data, getItem, getItemCount} = this.props; - const itemCount = getItemCount(data); - for (let index = 0; index < itemCount; index++) { - if (getItem(data, index) === item) { - this.scrollToIndex({...params, index}); - break; - } - } - } - - /** - * Scroll to a specific content pixel offset in the list. - * - * Param `offset` expects the offset to scroll to. - * In case of `horizontal` is true, the offset is the x-value, - * in any other case the offset is the y-value. - * - * Param `animated` (`true` by default) defines whether the list - * should do an animation while scrolling. - */ - scrollToOffset(params: {animated?: ?boolean, offset: number, ...}) { - const {animated, offset} = params; - - if (this._scrollRef == null) { - return; - } - - if (this._scrollRef.scrollTo == null) { - console.warn( - 'No scrollTo method provided. This may be because you have two nested ' + - 'VirtualizedLists with the same orientation, or because you are ' + - 'using a custom component that does not implement scrollTo.', - ); - return; - } - - this._scrollRef.scrollTo( - horizontalOrDefault(this.props.horizontal) - ? {x: offset, animated} - : {y: offset, animated}, - ); - } - - recordInteraction() { - this._nestedChildLists.forEach(childList => { - childList.recordInteraction(); - }); - this._viewabilityTuples.forEach(t => { - t.viewabilityHelper.recordInteraction(); - }); - this._updateViewableItems(this.props, this.state.cellsAroundViewport); - } - - flashScrollIndicators() { - if (this._scrollRef == null) { - return; - } - - this._scrollRef.flashScrollIndicators(); - } - - /** - * Provides a handle to the underlying scroll responder. - * Note that `this._scrollRef` might not be a `ScrollView`, so we - * need to check that it responds to `getScrollResponder` before calling it. - */ - getScrollResponder(): ?ScrollResponderType { - if (this._scrollRef && this._scrollRef.getScrollResponder) { - return this._scrollRef.getScrollResponder(); - } - } - - getScrollableNode(): ?number { - if (this._scrollRef && this._scrollRef.getScrollableNode) { - return this._scrollRef.getScrollableNode(); - } else { - return findNodeHandle(this._scrollRef); - } - } - - getScrollRef(): - | ?React.ElementRef - | ?React.ElementRef { - if (this._scrollRef && this._scrollRef.getScrollRef) { - return this._scrollRef.getScrollRef(); - } else { - return this._scrollRef; - } - } - - setNativeProps(props: Object) { - if (this._scrollRef) { - this._scrollRef.setNativeProps(props); - } - } - - _getCellKey(): string { - return this.context?.cellKey || 'rootList'; - } - - // $FlowFixMe[missing-local-annot] - _getScrollMetrics = () => { - return this._scrollMetrics; - }; - - hasMore(): boolean { - return this._hasMore; - } - - // $FlowFixMe[missing-local-annot] - _getOutermostParentListRef = () => { - if (this._isNestedWithSameOrientation()) { - return this.context.getOutermostParentListRef(); - } else { - return this; - } - }; - - _registerAsNestedChild = (childList: { - cellKey: string, - ref: React.ElementRef, - }): void => { - const listRef = ((childList.ref: any): VirtualizedList); - this._nestedChildLists.add(listRef, childList.cellKey); - if (this._hasInteracted) { - listRef.recordInteraction(); - } - }; - - _unregisterAsNestedChild = (childList: { - ref: React.ElementRef, - }): void => { - const listRef = ((childList.ref: any): VirtualizedList); - this._nestedChildLists.remove(listRef); - }; - - state: State; - - constructor(props: Props) { - super(props); - invariant( - // $FlowFixMe[prop-missing] - !props.onScroll || !props.onScroll.__isNative, - 'Components based on VirtualizedList must be wrapped with Animated.createAnimatedComponent ' + - 'to support native onScroll events with useNativeDriver', - ); - invariant( - windowSizeOrDefault(props.windowSize) > 0, - '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, - this.props.updateCellsBatchingPeriod ?? 50, - ); - - if (this.props.viewabilityConfigCallbackPairs) { - this._viewabilityTuples = this.props.viewabilityConfigCallbackPairs.map( - pair => ({ - viewabilityHelper: new ViewabilityHelper(pair.viewabilityConfig), - onViewableItemsChanged: pair.onViewableItemsChanged, - }), - ); - } else { - const {onViewableItemsChanged, viewabilityConfig} = this.props; - if (onViewableItemsChanged) { - this._viewabilityTuples.push({ - viewabilityHelper: new ViewabilityHelper(viewabilityConfig), - onViewableItemsChanged: onViewableItemsChanged, - }); - } - } - - invariant( - !this.context, - 'Unexpectedly saw VirtualizedListContext available in ctor', - ); - - const initialRenderRegion = VirtualizedList._initialRenderRegion(props); - - this.state = { - cellsAroundViewport: initialRenderRegion, - renderMask: VirtualizedList._createRenderMask(props, initialRenderRegion), - }; - } - - static _createRenderMask( - props: Props, - cellsAroundViewport: {first: number, last: number}, - additionalRegions?: ?$ReadOnlyArray<{first: number, last: number}>, - ): CellRenderMask { - const itemCount = props.getItemCount(props.data); - - invariant( - 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) { - const allRegions = [cellsAroundViewport, ...(additionalRegions ?? [])]; - for (const region of allRegions) { - renderMask.addCells(region); - } - - // 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._ensureClosestStickyHeader( - props, - stickyIndicesSet, - renderMask, - cellsAroundViewport.first, - ); - } - - return renderMask; - } - - static _initialRenderRegion(props: Props): {first: number, last: number} { - const itemCount = props.getItemCount(props.data); - const scrollIndex = Math.floor(Math.max(0, props.initialScrollIndex ?? 0)); - - return { - first: scrollIndex, - last: - Math.min( - itemCount, - scrollIndex + initialNumToRenderOrDefault(props.initialNumToRender), - ) - 1, - }; - } - - static _ensureClosestStickyHeader( - 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}); - break; - } - } - } - - _adjustCellsAroundViewport( - props: Props, - cellsAroundViewport: {first: number, last: number}, - ): {first: number, last: number} { - const {data, getItemCount} = props; - const onEndReachedThreshold = onEndReachedThresholdOrDefault( - props.onEndReachedThreshold, - ); - this._updateViewableItems(props, cellsAroundViewport); - - const {contentLength, offset, visibleLength} = this._scrollMetrics; - const distanceFromEnd = contentLength - visibleLength - offset; - - // 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 cellsAroundViewport.last >= getItemCount(data) - ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) - : cellsAroundViewport; - } - - let newCellsAroundViewport: {first: number, last: number}; - if (props.disableVirtualization) { - const renderAhead = - distanceFromEnd < onEndReachedThreshold * visibleLength - ? maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch) - : 0; - - newCellsAroundViewport = { - first: 0, - last: Math.min( - cellsAroundViewport.last + renderAhead, - getItemCount(data) - 1, - ), - }; - } else { - // 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. - - // Thus, we want to recalculate the windowed render limits if any of the following hold: - // - initialScrollIndex is undefined or is 0 - // - initialScrollIndex > 0 AND scrolling is complete - // - initialScrollIndex > 0 AND the end of the list is visible (this handles the case - // where the list is shorter than the visible area) - if ( - props.initialScrollIndex && - !this._scrollMetrics.offset && - Math.abs(distanceFromEnd) >= Number.EPSILON - ) { - return cellsAroundViewport.last >= getItemCount(data) - ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) - : cellsAroundViewport; - } - - newCellsAroundViewport = computeWindowedRenderLimits( - props, - maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch), - windowSizeOrDefault(props.windowSize), - cellsAroundViewport, - this.__getFrameMetricsApprox, - this._scrollMetrics, - ); - invariant( - newCellsAroundViewport.last < getItemCount(data), - 'computeWindowedRenderLimits() should return range in-bounds', - ); - } - - 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( - newCellsAroundViewport.first, - newCellsAroundViewport.last, - ); - - newCellsAroundViewport.last = childIdx ?? newCellsAroundViewport.last; - } - - return newCellsAroundViewport; - } - - _findFirstChildWithMore(first: number, last: number): number | null { - for (let ii = first; ii <= last; ii++) { - const cellKeyForIndex = this._indicesToKeys.get(ii); - if ( - cellKeyForIndex != null && - this._nestedChildLists.anyInCell(cellKeyForIndex, childList => - childList.hasMore(), - ) - ) { - return ii; - } - } - - return null; - } - - componentDidMount() { - if (this._isNestedWithSameOrientation()) { - this.context.registerAsNestedChild({ - ref: this, - cellKey: this.context.cellKey, - }); - } - } - - componentWillUnmount() { - if (this._isNestedWithSameOrientation()) { - this.context.unregisterAsNestedChild({ref: this}); - } - this._updateCellsToRenderBatcher.dispose({abort: true}); - this._viewabilityTuples.forEach(tuple => { - tuple.viewabilityHelper.dispose(); - }); - this._fillRateHelper.deactivateAndFlush(); - } - - static getDerivedStateFromProps(newProps: Props, prevState: State): State { - // 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, - ); - - return { - cellsAroundViewport: constrainedCells, - renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells), - }; - } - - _pushCells( - cells: Array, - stickyHeaderIndices: Array, - stickyIndicesFromProps: Set, - first: number, - last: number, - inversionStyle: ViewStyleProp, - ) { - const { - CellRendererComponent, - ItemSeparatorComponent, - ListHeaderComponent, - ListItemComponent, - data, - debug, - getItem, - getItemCount, - getItemLayout, - horizontal, - renderItem, - } = this.props; - const stickyOffset = ListHeaderComponent ? 1 : 0; - const end = getItemCount(data) - 1; - let prevCellKey; - last = Math.min(end, last); - for (let ii = first; ii <= last; ii++) { - const item = getItem(data, ii); - const key = this._keyExtractor(item, ii, this.props); - this._indicesToKeys.set(ii, key); - if (stickyIndicesFromProps.has(ii + stickyOffset)) { - stickyHeaderIndices.push(cells.length); - } - cells.push( - this._onCellFocusCapture(key)} - onUnmount={this._onCellUnmount} - ref={ref => { - this._cellRefs[key] = ref; - }} - renderItem={renderItem} - />, - ); - prevCellKey = key; - } - } - - static _constrainToItemCount( - cells: {first: number, last: number}, - props: Props, - ): {first: number, last: number} { - const itemCount = props.getItemCount(props.data); - const last = Math.min(itemCount - 1, cells.last); - - const maxToRenderPerBatch = maxToRenderPerBatchOrDefault( - props.maxToRenderPerBatch, - ); - - return { - first: clamp(0, itemCount - 1 - maxToRenderPerBatch, cells.first), - last, - }; - } - - _onUpdateSeparators = (keys: Array, newProps: Object) => { - keys.forEach(key => { - const ref = key != null && this._cellRefs[key]; - ref && ref.updateSeparatorProps(newProps); - }); - }; - - _isNestedWithSameOrientation(): boolean { - const nestedContext = this.context; - return !!( - nestedContext && - !!nestedContext.horizontal === horizontalOrDefault(this.props.horizontal) - ); - } - - _getSpacerKey = (isVertical: boolean): string => - isVertical ? 'height' : 'width'; - - _keyExtractor( - item: Item, - index: number, - props: { - keyExtractor?: ?(item: Item, index: number) => string, - ... - }, - // $FlowFixMe[missing-local-annot] - ) { - if (props.keyExtractor != null) { - return props.keyExtractor(item, index); - } - - const key = defaultKeyExtractor(item, index); - if (key === String(index)) { - _usedIndexForKey = true; - if (item.type && item.type.displayName) { - _keylessItemComponentName = item.type.displayName; - } - } - return key; - } - - render(): React.Node { - if (__DEV__) { - const flatStyles = flattenStyle(this.props.contentContainerStyle); - if (flatStyles != null && flatStyles.flexWrap === 'wrap') { - console.warn( - '`flexWrap: `wrap`` is not supported with the `VirtualizedList` components.' + - 'Consider using `numColumns` with `FlatList` instead.', - ); - } - } - const {ListEmptyComponent, ListFooterComponent, ListHeaderComponent} = - this.props; - const {data, horizontal} = this.props; - const inversionStyle = this.props.inverted - ? horizontalOrDefault(this.props.horizontal) - ? styles.horizontallyInverted - : styles.verticallyInverted - : null; - 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); - } - const element = React.isValidElement(ListHeaderComponent) ? ( - ListHeaderComponent - ) : ( - // $FlowFixMe[not-a-component] - // $FlowFixMe[incompatible-type-arg] - - ); - cells.push( - - - { - // $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors - element - } - - , - ); - } - - // 2a. Add a cell for ListEmptyComponent if applicable - const itemCount = this.props.getItemCount(data); - if (itemCount === 0 && ListEmptyComponent) { - const element: React.Element = ((React.isValidElement( - ListEmptyComponent, - ) ? ( - ListEmptyComponent - ) : ( - // $FlowFixMe[not-a-component] - // $FlowFixMe[incompatible-type-arg] - - )): any); - cells.push( - React.cloneElement(element, { - key: '$empty', - onLayout: event => { - this._onLayoutEmpty(event); - if (element.props.onLayout) { - element.props.onLayout(event); - } - }, - style: StyleSheet.compose(inversionStyle, element.props.style), - }), - ); - } - - // 2b. Add cells and spacers 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.props.disableVirtualization) { - 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, - this.props, - ); - const lastMetrics = this.__getFrameMetricsApprox(last, this.props); - 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 - ) : ( - // $FlowFixMe[not-a-component] - // $FlowFixMe[incompatible-type-arg] - - ); - cells.push( - - - { - // $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors - element - } - - , - ); - } - - // 4. Render the ScrollView - const scrollProps = { - ...this.props, - onContentSizeChange: this._onContentSizeChange, - onLayout: this._onLayout, - onScroll: this._onScroll, - onScrollBeginDrag: this._onScrollBeginDrag, - onScrollEndDrag: this._onScrollEndDrag, - onMomentumScrollBegin: this._onMomentumScrollBegin, - onMomentumScrollEnd: this._onMomentumScrollEnd, - scrollEventThrottle: scrollEventThrottleOrDefault( - this.props.scrollEventThrottle, - ), // TODO: Android support - invertStickyHeaders: - this.props.invertStickyHeaders !== undefined - ? this.props.invertStickyHeaders - : this.props.inverted, - stickyHeaderIndices, - style: inversionStyle - ? [inversionStyle, this.props.style] - : this.props.style, - }; - - this._hasMore = this.state.cellsAroundViewport.last < itemCount - 1; - - const innerRet = ( - - {React.cloneElement( - ( - this.props.renderScrollComponent || - this._defaultRenderScrollComponent - )(scrollProps), - { - ref: this._captureScrollRef, - }, - cells, - )} - - ); - let ret: React.Node = innerRet; - if (__DEV__) { - ret = ( - - {scrollContext => { - if ( - scrollContext != null && - !scrollContext.horizontal === - !horizontalOrDefault(this.props.horizontal) && - !this._hasWarned.nesting && - this.context == null && - this.props.scrollEnabled !== false - ) { - // TODO (T46547044): use React.warn once 16.9 is sync'd: https://github.com/facebook/react/pull/15170 - console.error( - 'VirtualizedLists should never be nested inside plain ScrollViews with the same ' + - 'orientation because it can break windowing and other functionality - use another ' + - 'VirtualizedList-backed container instead.', - ); - this._hasWarned.nesting = true; - } - return innerRet; - }} - - ); - } - if (this.props.debug) { - return ( - - {ret} - {this._renderDebugOverlay()} - - ); - } else { - return ret; - } - } - - componentDidUpdate(prevProps: Props) { - const {data, extraData} = this.props; - if (data !== prevProps.data || extraData !== prevProps.extraData) { - // clear the viewableIndices cache to also trigger - // the onViewableItemsChanged callback with the new data - this._viewabilityTuples.forEach(tuple => { - tuple.viewabilityHelper.resetViewableIndices(); - }); - } - // The `this._hiPriInProgress` is guaranteeing a hiPri cell update will only happen - // once per fiber update. The `_scheduleCellsToRenderUpdate` will set it to true - // if a hiPri update needs to perform. If `componentDidUpdate` is triggered with - // `this._hiPriInProgress=true`, means it's triggered by the hiPri update. The - // `_scheduleCellsToRenderUpdate` will check this condition and not perform - // another hiPri update. - const hiPriInProgress = this._hiPriInProgress; - this._scheduleCellsToRenderUpdate(); - // Make sure setting `this._hiPriInProgress` back to false after `componentDidUpdate` - // is triggered with `this._hiPriInProgress = true` - if (hiPriInProgress) { - this._hiPriInProgress = false; - } - } - - _averageCellLength = 0; - _cellRefs: {[string]: null | CellRenderer} = {}; - _fillRateHelper: FillRateHelper; - _frames: { - [string]: { - inLayout?: boolean, - index: number, - length: number, - offset: number, - }, - } = {}; - _footerLength = 0; - // Used for preventing scrollToIndex from being called multiple times for initialScrollIndex - _hasTriggeredInitialScrollToIndex = false; - _hasInteracted = false; - _hasMore = false; - _hasWarned: {[string]: boolean} = {}; - _headerLength = 0; - _hiPriInProgress: boolean = false; // flag to prevent infinite hiPri cell limit update - _highestMeasuredFrameIndex = 0; - _indicesToKeys: Map = new Map(); - _lastFocusedCellKey: ?string = null; - _nestedChildLists: ChildListCollection = - new ChildListCollection(); - _offsetFromParentVirtualizedList: number = 0; - _prevParentOffset: number = 0; - // $FlowFixMe[missing-local-annot] - _scrollMetrics = { - contentLength: 0, - dOffset: 0, - dt: 10, - offset: 0, - timestamp: 0, - velocity: 0, - visibleLength: 0, - zoomScale: 1, - }; - _scrollRef: ?React.ElementRef = null; - _sentEndForContentLength = 0; - _totalCellLength = 0; - _totalCellsMeasured = 0; - _updateCellsToRenderBatcher: Batchinator; - _viewabilityTuples: Array = []; - - /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's - * LTI update could not be added via codemod */ - _captureScrollRef = ref => { - this._scrollRef = ref; - }; - - _computeBlankness() { - this._fillRateHelper.computeBlankness( - this.props, - this.state.cellsAroundViewport, - this._scrollMetrics, - ); - } - - /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's - * LTI update could not be added via codemod */ - _defaultRenderScrollComponent = props => { - const onRefresh = props.onRefresh; - if (this._isNestedWithSameOrientation()) { - // $FlowFixMe[prop-missing] - Typing ReactNativeComponent revealed errors - return ; - } else if (onRefresh) { - invariant( - typeof props.refreshing === 'boolean', - '`refreshing` prop must be set as a boolean in order to use `onRefresh`, but got `' + - JSON.stringify(props.refreshing ?? 'undefined') + - '`', - ); - return ( - // $FlowFixMe[prop-missing] Invalid prop usage - // $FlowFixMe[incompatible-use] - - ) : ( - props.refreshControl - ) - } - /> - ); - } else { - // $FlowFixMe[prop-missing] Invalid prop usage - // $FlowFixMe[incompatible-use] - return ; - } - }; - - _onCellLayout = (e: LayoutEvent, cellKey: string, index: number): void => { - const layout = e.nativeEvent.layout; - const next = { - offset: this._selectOffset(layout), - length: this._selectLength(layout), - index, - inLayout: true, - }; - const curr = this._frames[cellKey]; - if ( - !curr || - next.offset !== curr.offset || - next.length !== curr.length || - index !== curr.index - ) { - this._totalCellLength += next.length - (curr ? curr.length : 0); - this._totalCellsMeasured += curr ? 0 : 1; - this._averageCellLength = - this._totalCellLength / this._totalCellsMeasured; - this._frames[cellKey] = next; - this._highestMeasuredFrameIndex = Math.max( - this._highestMeasuredFrameIndex, - index, - ); - this._scheduleCellsToRenderUpdate(); - } else { - this._frames[cellKey].inLayout = true; - } - - this._triggerRemeasureForChildListsInCell(cellKey); - - this._computeBlankness(); - this._updateViewableItems(this.props, this.state.cellsAroundViewport); - }; - - _onCellFocusCapture(cellKey: string) { - this._lastFocusedCellKey = cellKey; - const renderMask = VirtualizedList._createRenderMask( - this.props, - this.state.cellsAroundViewport, - this._getNonViewportRenderRegions(this.props), - ); - - this.setState(state => { - if (!renderMask.equals(state.renderMask)) { - return {renderMask}; - } - return null; - }); - } - - _onCellUnmount = (cellKey: string) => { - const curr = this._frames[cellKey]; - if (curr) { - this._frames[cellKey] = {...curr, inLayout: false}; - } - }; - - _triggerRemeasureForChildListsInCell(cellKey: string): void { - this._nestedChildLists.forEachInCell(cellKey, childList => { - childList.measureLayoutRelativeToContainingList(); - }); - } - - measureLayoutRelativeToContainingList(): void { - // TODO (T35574538): findNodeHandle sometimes crashes with "Unable to find - // node on an unmounted component" during scrolling - try { - if (!this._scrollRef) { - return; - } - // We are assuming that getOutermostParentListRef().getScrollRef() - // is a non-null reference to a ScrollView - this._scrollRef.measureLayout( - this.context.getOutermostParentListRef().getScrollRef(), - (x, y, width, height) => { - this._offsetFromParentVirtualizedList = this._selectOffset({x, y}); - this._scrollMetrics.contentLength = this._selectLength({ - width, - height, - }); - const scrollMetrics = this._convertParentScrollMetrics( - this.context.getScrollMetrics(), - ); - - const metricsChanged = - this._scrollMetrics.visibleLength !== scrollMetrics.visibleLength || - this._scrollMetrics.offset !== scrollMetrics.offset; - - if (metricsChanged) { - this._scrollMetrics.visibleLength = scrollMetrics.visibleLength; - this._scrollMetrics.offset = scrollMetrics.offset; - - // If metrics of the scrollView changed, then we triggered remeasure for child list - // to ensure VirtualizedList has the right information. - this._nestedChildLists.forEach(childList => { - childList.measureLayoutRelativeToContainingList(); - }); - } - }, - error => { - console.warn( - "VirtualizedList: Encountered an error while measuring a list's" + - ' offset from its containing VirtualizedList.', - ); - }, - ); - } catch (error) { - console.warn( - 'measureLayoutRelativeToContainingList threw an error', - error.stack, - ); - } - } - - _onLayout = (e: LayoutEvent) => { - if (this._isNestedWithSameOrientation()) { - // Need to adjust our scroll metrics to be relative to our containing - // VirtualizedList before we can make claims about list item viewability - this.measureLayoutRelativeToContainingList(); - } else { - this._scrollMetrics.visibleLength = this._selectLength( - e.nativeEvent.layout, - ); - } - this.props.onLayout && this.props.onLayout(e); - this._scheduleCellsToRenderUpdate(); - this._maybeCallOnEndReached(); - }; - - _onLayoutEmpty = (e: LayoutEvent) => { - this.props.onLayout && this.props.onLayout(e); - }; - - _getFooterCellKey(): string { - return this._getCellKey() + '-footer'; - } - - _onLayoutFooter = (e: LayoutEvent) => { - this._triggerRemeasureForChildListsInCell(this._getFooterCellKey()); - this._footerLength = this._selectLength(e.nativeEvent.layout); - }; - - _onLayoutHeader = (e: LayoutEvent) => { - this._headerLength = this._selectLength(e.nativeEvent.layout); - }; - - // $FlowFixMe[missing-local-annot] - _renderDebugOverlay() { - const normalize = - this._scrollMetrics.visibleLength / - (this._scrollMetrics.contentLength || 1); - const framesInLayout = []; - const itemCount = this.props.getItemCount(this.props.data); - for (let ii = 0; ii < itemCount; ii++) { - const frame = this.__getFrameMetricsApprox(ii, this.props); - /* $FlowFixMe[prop-missing] (>=0.68.0 site=react_native_fb) This comment - * suppresses an error found when Flow v0.68 was deployed. To see the - * error delete this comment and run Flow. */ - if (frame.inLayout) { - framesInLayout.push(frame); - } - } - const windowTop = this.__getFrameMetricsApprox( - this.state.cellsAroundViewport.first, - this.props, - ).offset; - const frameLast = this.__getFrameMetricsApprox( - this.state.cellsAroundViewport.last, - this.props, - ); - const windowLen = frameLast.offset + frameLast.length - windowTop; - const visTop = this._scrollMetrics.offset; - const visLen = this._scrollMetrics.visibleLength; - - return ( - - {framesInLayout.map((f, ii) => ( - - ))} - - - - ); - } - - _selectLength( - metrics: $ReadOnly<{ - height: number, - width: number, - ... - }>, - ): number { - return !horizontalOrDefault(this.props.horizontal) - ? metrics.height - : metrics.width; - } - - _selectOffset( - metrics: $ReadOnly<{ - x: number, - y: number, - ... - }>, - ): number { - return !horizontalOrDefault(this.props.horizontal) ? metrics.y : metrics.x; - } - - _maybeCallOnEndReached() { - const {data, getItemCount, onEndReached, onEndReachedThreshold} = - this.props; - const {contentLength, visibleLength, offset} = this._scrollMetrics; - let distanceFromEnd = contentLength - visibleLength - offset; - - // Especially when oERT is zero it's necessary to 'floor' very small distanceFromEnd values to be 0 - // since debouncing causes us to not fire this event for every single "pixel" we scroll and can thus - // be at the "end" of the list with a distanceFromEnd approximating 0 but not quite there. - if (distanceFromEnd < ON_END_REACHED_EPSILON) { - distanceFromEnd = 0; - } - - // TODO: T121172172 Look into why we're "defaulting" to a threshold of 2 when oERT is not present - const threshold = - onEndReachedThreshold != null ? onEndReachedThreshold * visibleLength : 2; - if ( - onEndReached && - this.state.cellsAroundViewport.last === getItemCount(data) - 1 && - distanceFromEnd <= threshold && - this._scrollMetrics.contentLength !== this._sentEndForContentLength - ) { - // Only call onEndReached once for a given content length - this._sentEndForContentLength = this._scrollMetrics.contentLength; - onEndReached({distanceFromEnd}); - } else if (distanceFromEnd > threshold) { - // If the user scrolls away from the end and back again cause - // an onEndReached to be triggered again - this._sentEndForContentLength = 0; - } - } - - _onContentSizeChange = (width: number, height: number) => { - if ( - width > 0 && - height > 0 && - this.props.initialScrollIndex != null && - this.props.initialScrollIndex > 0 && - !this._hasTriggeredInitialScrollToIndex - ) { - if (this.props.contentOffset == null) { - this.scrollToIndex({ - animated: false, - index: this.props.initialScrollIndex, - }); - } - this._hasTriggeredInitialScrollToIndex = true; - } - if (this.props.onContentSizeChange) { - this.props.onContentSizeChange(width, height); - } - this._scrollMetrics.contentLength = this._selectLength({height, width}); - this._scheduleCellsToRenderUpdate(); - this._maybeCallOnEndReached(); - }; - - /* Translates metrics from a scroll event in a parent VirtualizedList into - * coordinates relative to the child list. - */ - _convertParentScrollMetrics = (metrics: { - visibleLength: number, - offset: number, - ... - }): $FlowFixMe => { - // Offset of the top of the nested list relative to the top of its parent's viewport - const offset = metrics.offset - this._offsetFromParentVirtualizedList; - // Child's visible length is the same as its parent's - const visibleLength = metrics.visibleLength; - const dOffset = offset - this._scrollMetrics.offset; - const contentLength = this._scrollMetrics.contentLength; - - return { - visibleLength, - contentLength, - offset, - dOffset, - }; - }; - - _onScroll = (e: Object) => { - this._nestedChildLists.forEach(childList => { - childList._onScroll(e); - }); - if (this.props.onScroll) { - this.props.onScroll(e); - } - const timestamp = e.timeStamp; - let visibleLength = this._selectLength(e.nativeEvent.layoutMeasurement); - let contentLength = this._selectLength(e.nativeEvent.contentSize); - let offset = this._selectOffset(e.nativeEvent.contentOffset); - let dOffset = offset - this._scrollMetrics.offset; - - if (this._isNestedWithSameOrientation()) { - if (this._scrollMetrics.contentLength === 0) { - // Ignore scroll events until onLayout has been called and we - // know our offset from our offset from our parent - return; - } - ({visibleLength, contentLength, offset, dOffset} = - this._convertParentScrollMetrics({ - visibleLength, - offset, - })); - } - - const dt = this._scrollMetrics.timestamp - ? Math.max(1, timestamp - this._scrollMetrics.timestamp) - : 1; - const velocity = dOffset / dt; - - if ( - dt > 500 && - this._scrollMetrics.dt > 500 && - contentLength > 5 * visibleLength && - !this._hasWarned.perf - ) { - infoLog( - 'VirtualizedList: You have a large list that is slow to update - make sure your ' + - 'renderItem function renders components that follow React performance best practices ' + - 'like PureComponent, shouldComponentUpdate, etc.', - {dt, prevDt: this._scrollMetrics.dt, contentLength}, - ); - this._hasWarned.perf = true; - } - - // For invalid negative values (w/ RTL), set this to 1. - const zoomScale = e.nativeEvent.zoomScale < 0 ? 1 : e.nativeEvent.zoomScale; - this._scrollMetrics = { - contentLength, - dt, - dOffset, - offset, - timestamp, - velocity, - visibleLength, - zoomScale, - }; - this._updateViewableItems(this.props, this.state.cellsAroundViewport); - if (!this.props) { - return; - } - this._maybeCallOnEndReached(); - if (velocity !== 0) { - this._fillRateHelper.activate(); - } - this._computeBlankness(); - this._scheduleCellsToRenderUpdate(); - }; - - _scheduleCellsToRenderUpdate() { - const {first, last} = this.state.cellsAroundViewport; - const {offset, visibleLength, velocity} = this._scrollMetrics; - const itemCount = this.props.getItemCount(this.props.data); - let hiPri = false; - const onEndReachedThreshold = onEndReachedThresholdOrDefault( - this.props.onEndReachedThreshold, - ); - const scrollingThreshold = (onEndReachedThreshold * visibleLength) / 2; - // Mark as high priority if we're close to the start of the first item - // But only if there are items before the first rendered item - if (first > 0) { - const distTop = - offset - this.__getFrameMetricsApprox(first, this.props).offset; - hiPri = - hiPri || distTop < 0 || (velocity < -2 && distTop < scrollingThreshold); - } - // Mark as high priority if we're close to the end of the last item - // But only if there are items after the last rendered item - if (last >= 0 && last < itemCount - 1) { - const distBottom = - this.__getFrameMetricsApprox(last, this.props).offset - - (offset + visibleLength); - hiPri = - hiPri || - distBottom < 0 || - (velocity > 2 && distBottom < scrollingThreshold); - } - // Only trigger high-priority updates if we've actually rendered cells, - // and with that size estimate, accurately compute how many cells we should render. - // Otherwise, it would just render as many cells as it can (of zero dimension), - // each time through attempting to render more (limited by maxToRenderPerBatch), - // starving the renderer from actually laying out the objects and computing _averageCellLength. - // If this is triggered in an `componentDidUpdate` followed by a hiPri cellToRenderUpdate - // We shouldn't do another hipri cellToRenderUpdate - if ( - hiPri && - (this._averageCellLength || this.props.getItemLayout) && - !this._hiPriInProgress - ) { - this._hiPriInProgress = true; - // Don't worry about interactions when scrolling quickly; focus on filling content as fast - // as possible. - this._updateCellsToRenderBatcher.dispose({abort: true}); - this._updateCellsToRender(); - return; - } else { - this._updateCellsToRenderBatcher.schedule(); - } - } - - _onScrollBeginDrag = (e: ScrollEvent): void => { - this._nestedChildLists.forEach(childList => { - childList._onScrollBeginDrag(e); - }); - this._viewabilityTuples.forEach(tuple => { - tuple.viewabilityHelper.recordInteraction(); - }); - this._hasInteracted = true; - this.props.onScrollBeginDrag && this.props.onScrollBeginDrag(e); - }; - - _onScrollEndDrag = (e: ScrollEvent): void => { - this._nestedChildLists.forEach(childList => { - childList._onScrollEndDrag(e); - }); - const {velocity} = e.nativeEvent; - if (velocity) { - this._scrollMetrics.velocity = this._selectOffset(velocity); - } - this._computeBlankness(); - this.props.onScrollEndDrag && this.props.onScrollEndDrag(e); - }; - - _onMomentumScrollBegin = (e: ScrollEvent): void => { - this._nestedChildLists.forEach(childList => { - childList._onMomentumScrollBegin(e); - }); - this.props.onMomentumScrollBegin && this.props.onMomentumScrollBegin(e); - }; - - _onMomentumScrollEnd = (e: ScrollEvent): void => { - this._nestedChildLists.forEach(childList => { - childList._onMomentumScrollEnd(e); - }); - this._scrollMetrics.velocity = 0; - this._computeBlankness(); - this.props.onMomentumScrollEnd && this.props.onMomentumScrollEnd(e); - }; - - _updateCellsToRender = () => { - this.setState((state, props) => { - const cellsAroundViewport = this._adjustCellsAroundViewport( - props, - state.cellsAroundViewport, - ); - const renderMask = VirtualizedList._createRenderMask( - props, - cellsAroundViewport, - this._getNonViewportRenderRegions(props), - ); - - if ( - cellsAroundViewport.first === state.cellsAroundViewport.first && - cellsAroundViewport.last === state.cellsAroundViewport.last && - renderMask.equals(state.renderMask) - ) { - return null; - } - - return {cellsAroundViewport, renderMask}; - }); - }; - - _createViewToken = ( - index: number, - isViewable: boolean, - props: FrameMetricProps, - // $FlowFixMe[missing-local-annot] - ) => { - const {data, getItem} = props; - const item = getItem(data, index); - return { - index, - item, - key: this._keyExtractor(item, index, props), - isViewable, - }; - }; - - /** - * 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, - ) => { - length: number, - offset: number, - ... - } = (index, props) => { - const frame = this._getFrameMetrics(index, props); - if (frame && frame.index === index) { - // check for invalid frames due to row re-ordering - return frame; - } else { - const {data, getItemCount, getItemLayout} = props; - invariant( - index >= 0 && index < getItemCount(data), - 'Tried to get frame for out of range index ' + index, - ); - invariant( - !getItemLayout, - 'Should not have to estimate frames when a measurement metrics function is provided', - ); - return { - length: this._averageCellLength, - offset: this._averageCellLength * index, - }; - } - }; - - _getFrameMetrics = ( - index: number, - props: FrameMetricProps, - ): ?{ - length: number, - offset: number, - index: number, - inLayout?: boolean, - ... - } => { - const {data, getItem, getItemCount, getItemLayout} = props; - invariant( - index >= 0 && index < getItemCount(data), - 'Tried to get frame for out of range index ' + index, - ); - const item = getItem(data, index); - const frame = item && this._frames[this._keyExtractor(item, index, props)]; - if (!frame || frame.index !== index) { - if (getItemLayout) { - /* $FlowFixMe[prop-missing] (>=0.63.0 site=react_native_fb) This comment - * suppresses an error found when Flow v0.63 was deployed. To see the error - * delete this comment and run Flow. */ - return getItemLayout(data, index); - } - } - return frame; - }; - - _getNonViewportRenderRegions = ( - props: FrameMetricProps, - ): $ReadOnlyArray<{ - first: number, - last: number, - }> => { - // Keep a viewport's worth of content around the last focused cell to allow - // random navigation around it without any blanking. E.g. tabbing from one - // focused item out of viewport to another. - if ( - !(this._lastFocusedCellKey && this._cellRefs[this._lastFocusedCellKey]) - ) { - return []; - } - - const lastFocusedCellRenderer = this._cellRefs[this._lastFocusedCellKey]; - const focusedCellIndex = lastFocusedCellRenderer.props.index; - const itemCount = props.getItemCount(props.data); - - // The cell may have been unmounted and have a stale index - if ( - focusedCellIndex >= itemCount || - this._indicesToKeys.get(focusedCellIndex) !== this._lastFocusedCellKey - ) { - return []; - } - - let first = focusedCellIndex; - let heightOfCellsBeforeFocused = 0; - for ( - let i = first - 1; - i >= 0 && heightOfCellsBeforeFocused < this._scrollMetrics.visibleLength; - i-- - ) { - first--; - heightOfCellsBeforeFocused += this.__getFrameMetricsApprox( - i, - props, - ).length; - } - - let last = focusedCellIndex; - let heightOfCellsAfterFocused = 0; - for ( - let i = last + 1; - i < itemCount && - heightOfCellsAfterFocused < this._scrollMetrics.visibleLength; - i++ - ) { - last++; - heightOfCellsAfterFocused += this.__getFrameMetricsApprox( - i, - props, - ).length; - } - - return [{first, last}]; - }; - - _updateViewableItems( - props: FrameMetricProps, - cellsAroundViewport: {first: number, last: number}, - ) { - this._viewabilityTuples.forEach(tuple => { - tuple.viewabilityHelper.onUpdate( - props, - this._scrollMetrics.offset, - this._scrollMetrics.visibleLength, - this._getFrameMetrics, - this._createViewToken, - tuple.onViewableItemsChanged, - cellsAroundViewport, - ); - }); - } -} - -VirtualizedList.displayName = 'VirtualizedList_EXPERIMENTAL'; - -const styles = StyleSheet.create({ - verticallyInverted: { - transform: [{scaleY: -1}], - }, - horizontallyInverted: { - transform: [{scaleX: -1}], - }, - debug: { - flex: 1, - }, - debugOverlayBase: { - position: 'absolute', - top: 0, - right: 0, - }, - debugOverlay: { - bottom: 0, - width: 20, - borderColor: 'blue', - borderWidth: 1, - }, - debugOverlayFrame: { - left: 0, - backgroundColor: 'orange', - }, - debugOverlayFrameLast: { - left: 0, - borderColor: 'green', - borderWidth: 2, - }, - debugOverlayFrameVis: { - left: 0, - borderColor: 'red', - borderWidth: 2, - }, -}); diff --git a/Libraries/Lists/__tests__/VirtualizedList-test.js b/Libraries/Lists/__tests__/VirtualizedList-test.js index c45786924aa1a0..969d72b9014239 100644 --- a/Libraries/Lists/__tests__/VirtualizedList-test.js +++ b/Libraries/Lists/__tests__/VirtualizedList-test.js @@ -10,19 +10,10 @@ 'use strict'; -import VirtualizedList_EXPERIMENTAL from '../VirtualizedList_EXPERIMENTAL'; -import * as VirtualizedListInjection from '../VirtualizedListInjection'; +import VirtualizedList from '../VirtualizedList'; import React from 'react'; import ReactTestRenderer from 'react-test-renderer'; -const VirtualizedList = require('../VirtualizedList'); - -const useExperimentalList = - process.env.USE_EXPERIMENTAL_VIRTUALIZEDLIST === 'true'; -if (useExperimentalList) { - VirtualizedListInjection.inject(VirtualizedList_EXPERIMENTAL); -} - describe('VirtualizedList', () => { it('renders simple list', () => { const component = ReactTestRenderer.create( @@ -785,12 +776,7 @@ it('scrolls after content sizing with fractional initialScrollIndex (getItemLayo performAllBatches(); }); - if (useExperimentalList) { - expect(scrollTo).toHaveBeenLastCalledWith({y: 2.0, animated: false}); - } else { - // Legacy incorrect results - expect(scrollTo).toHaveBeenLastCalledWith({y: Number.NaN, animated: false}); - } + expect(scrollTo).toHaveBeenLastCalledWith({y: 2.0, animated: false}); }); it('scrolls after content sizing with fractional initialScrollIndex (cached layout)', () => { @@ -828,12 +814,7 @@ it('scrolls after content sizing with fractional initialScrollIndex (cached layo performAllBatches(); }); - if (useExperimentalList) { - expect(scrollTo).toHaveBeenLastCalledWith({y: 2.0, animated: false}); - } else { - // Legacy incorrect results - expect(scrollTo).toHaveBeenLastCalledWith({y: 8.25, animated: false}); - } + expect(scrollTo).toHaveBeenLastCalledWith({y: 2.0, animated: false}); }); it('scrolls after content sizing with fractional initialScrollIndex (layout estimation)', () => { @@ -1754,187 +1735,183 @@ it('calls _onCellLayout properly', () => { expect(mock).not.toHaveBeenCalledWith(event, 'i3', 2); }); -if (useExperimentalList) { - describe('VirtualizedList (Experimental functionality)', () => { - it('keeps viewport below last focused rendered', () => { - const items = generateItems(20); - const ITEM_HEIGHT = 10; - - let component; - ReactTestRenderer.act(() => { - component = ReactTestRenderer.create( - , - ); - }); +it('keeps viewport below last focused rendered', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; - ReactTestRenderer.act(() => { - simulateLayout(component, { - viewport: {width: 10, height: 50}, - content: {width: 10, height: 200}, - }); + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); - performAllBatches(); - }); + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); - ReactTestRenderer.act(() => { - component.getInstance()._onCellFocusCapture(3); - }); + performAllBatches(); + }); - ReactTestRenderer.act(() => { - simulateScroll(component, {x: 0, y: 150}); - performAllBatches(); - }); + ReactTestRenderer.act(() => { + component.getInstance()._onCellFocusCapture(3); + }); - // Cells 1-8 should remain rendered after scrolling to the bottom of the list - expect(component).toMatchSnapshot(); - }); + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 150}); + performAllBatches(); + }); - it('virtualizes away last focused item if focus changes to a new cell', () => { - const items = generateItems(20); - const ITEM_HEIGHT = 10; + // Cells 1-8 should remain rendered after scrolling to the bottom of the list + expect(component).toMatchSnapshot(); +}); - let component; - ReactTestRenderer.act(() => { - component = ReactTestRenderer.create( - , - ); - }); +it('virtualizes away last focused item if focus changes to a new cell', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; - ReactTestRenderer.act(() => { - simulateLayout(component, { - viewport: {width: 10, height: 50}, - content: {width: 10, height: 200}, - }); + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); - performAllBatches(); - }); + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); - ReactTestRenderer.act(() => { - component.getInstance()._onCellFocusCapture(3); - }); + performAllBatches(); + }); - ReactTestRenderer.act(() => { - simulateScroll(component, {x: 0, y: 150}); - performAllBatches(); - }); + ReactTestRenderer.act(() => { + component.getInstance()._onCellFocusCapture(3); + }); - ReactTestRenderer.act(() => { - component.getInstance()._onCellFocusCapture(17); - }); + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 150}); + performAllBatches(); + }); - // Cells 1-8 should no longer be rendered after focus is moved to the end of - // the list - expect(component).toMatchSnapshot(); - }); + ReactTestRenderer.act(() => { + component.getInstance()._onCellFocusCapture(17); + }); - it('keeps viewport above last focused rendered', () => { - const items = generateItems(20); - const ITEM_HEIGHT = 10; + // Cells 1-8 should no longer be rendered after focus is moved to the end of + // the list + expect(component).toMatchSnapshot(); +}); - let component; - ReactTestRenderer.act(() => { - component = ReactTestRenderer.create( - , - ); - }); +it('keeps viewport above last focused rendered', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; - ReactTestRenderer.act(() => { - simulateLayout(component, { - viewport: {width: 10, height: 50}, - content: {width: 10, height: 200}, - }); + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); - performAllBatches(); - }); + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); - ReactTestRenderer.act(() => { - component.getInstance()._onCellFocusCapture(3); - }); + performAllBatches(); + }); - ReactTestRenderer.act(() => { - simulateScroll(component, {x: 0, y: 150}); - performAllBatches(); - }); + ReactTestRenderer.act(() => { + component.getInstance()._onCellFocusCapture(3); + }); - ReactTestRenderer.act(() => { - component.getInstance()._onCellFocusCapture(17); - }); + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 150}); + performAllBatches(); + }); - ReactTestRenderer.act(() => { - simulateScroll(component, {x: 0, y: 0}); - performAllBatches(); - }); + ReactTestRenderer.act(() => { + component.getInstance()._onCellFocusCapture(17); + }); - // Cells 12-19 should remain rendered after scrolling to the top of the list - expect(component).toMatchSnapshot(); - }); + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 0}); + performAllBatches(); + }); - it('virtualizes away last focused index if item removed', () => { - const items = generateItems(20); - const ITEM_HEIGHT = 10; + // Cells 12-19 should remain rendered after scrolling to the top of the list + expect(component).toMatchSnapshot(); +}); - let component; - ReactTestRenderer.act(() => { - component = ReactTestRenderer.create( - , - ); - }); +it('virtualizes away last focused index if item removed', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; - ReactTestRenderer.act(() => { - simulateLayout(component, { - viewport: {width: 10, height: 50}, - content: {width: 10, height: 200}, - }); + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); - performAllBatches(); - }); + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: 200}, + }); - ReactTestRenderer.act(() => { - component.getInstance()._onCellFocusCapture(3); - }); + performAllBatches(); + }); - ReactTestRenderer.act(() => { - simulateScroll(component, {x: 0, y: 150}); - performAllBatches(); - }); + ReactTestRenderer.act(() => { + component.getInstance()._onCellFocusCapture(3); + }); - const itemsWithoutFocused = [...items.slice(0, 3), ...items.slice(4)]; - ReactTestRenderer.act(() => { - component.update( - , - ); - }); + ReactTestRenderer.act(() => { + simulateScroll(component, {x: 0, y: 150}); + performAllBatches(); + }); - // Cells 1-8 should no longer be rendered - expect(component).toMatchSnapshot(); - }); + const itemsWithoutFocused = [...items.slice(0, 3), ...items.slice(4)]; + ReactTestRenderer.act(() => { + component.update( + , + ); }); -} + + // Cells 1-8 should no longer be rendered + expect(component).toMatchSnapshot(); +}); function generateItems(count) { return Array(count) diff --git a/Libraries/Lists/__tests__/VirtualizedList_EXPERIMENTAL-test.js b/Libraries/Lists/__tests__/VirtualizedList_EXPERIMENTAL-test.js deleted file mode 100644 index 1a83f36274e20e..00000000000000 --- a/Libraries/Lists/__tests__/VirtualizedList_EXPERIMENTAL-test.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * 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. - * - * @format - * @oncall react_native - */ - -'use strict'; - -process.env.USE_EXPERIMENTAL_VIRTUALIZEDLIST = 'true'; -require('./VirtualizedList-test'); diff --git a/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap b/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap index c8b33bb98df2de..03e7c9533385bb 100644 --- a/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap +++ b/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap @@ -58,6 +58,7 @@ exports[`FlatList renders all the bells and whistles 1`] = `
@@ -216,6 +220,7 @@ exports[`FlatList renders simple list (multiple columns) 1`] = ` @@ -268,6 +273,7 @@ exports[`FlatList renders simple list 1`] = ` > @@ -276,6 +282,7 @@ exports[`FlatList renders simple list 1`] = ` /> @@ -284,6 +291,7 @@ exports[`FlatList renders simple list 1`] = ` /> @@ -328,6 +336,7 @@ exports[`FlatList renders simple list using ListItemComponent (multiple columns) > @@ -347,6 +356,7 @@ exports[`FlatList renders simple list using ListItemComponent (multiple columns) @@ -399,6 +409,7 @@ exports[`FlatList renders simple list using ListItemComponent 1`] = ` > @@ -407,6 +418,7 @@ exports[`FlatList renders simple list using ListItemComponent 1`] = ` /> @@ -415,6 +427,7 @@ exports[`FlatList renders simple list using ListItemComponent 1`] = ` /> diff --git a/Libraries/Lists/__tests__/__snapshots__/SectionList-test.js.snap b/Libraries/Lists/__tests__/__snapshots__/SectionList-test.js.snap index 8e63455db938e0..ea1021883c1d92 100644 --- a/Libraries/Lists/__tests__/__snapshots__/SectionList-test.js.snap +++ b/Libraries/Lists/__tests__/__snapshots__/SectionList-test.js.snap @@ -37,10 +37,12 @@ exports[`SectionList rendering empty section headers is fine 1`] = ` > @@ -49,6 +51,7 @@ exports[`SectionList rendering empty section headers is fine 1`] = ` /> @@ -57,6 +60,7 @@ exports[`SectionList rendering empty section headers is fine 1`] = ` /> @@ -94,6 +98,7 @@ exports[`SectionList renders a footer when there is no data 1`] = ` > @@ -102,6 +107,7 @@ exports[`SectionList renders a footer when there is no data 1`] = ` /> @@ -143,10 +149,12 @@ exports[`SectionList renders a footer when there is no data and no header 1`] = > @@ -242,6 +250,7 @@ exports[`SectionList renders all the bells and whistles 1`] = ` /> @@ -250,6 +259,7 @@ exports[`SectionList renders all the bells and whistles 1`] = ` /> @@ -266,6 +276,7 @@ exports[`SectionList renders all the bells and whistles 1`] = ` @@ -279,6 +290,7 @@ exports[`SectionList renders all the bells and whistles 1`] = ` @@ -287,6 +299,7 @@ exports[`SectionList renders all the bells and whistles 1`] = ` /> @@ -295,6 +308,7 @@ exports[`SectionList renders all the bells and whistles 1`] = ` /> @@ -311,6 +325,7 @@ exports[`SectionList renders all the bells and whistles 1`] = ` @@ -324,6 +339,7 @@ exports[`SectionList renders all the bells and whistles 1`] = ` @@ -332,6 +348,7 @@ exports[`SectionList renders all the bells and whistles 1`] = ` /> @@ -340,6 +357,7 @@ exports[`SectionList renders all the bells and whistles 1`] = ` /> @@ -356,6 +374,7 @@ exports[`SectionList renders all the bells and whistles 1`] = ` @@ -369,6 +388,7 @@ exports[`SectionList renders all the bells and whistles 1`] = ` diff --git a/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap b/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap index 8013c149b8d84d..0ff96454a0ddc6 100644 --- a/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap +++ b/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap @@ -70,6 +70,7 @@ exports[`VirtualizedList forwards correct stickyHeaderIndices when ListHeaderCom
@@ -453,6 +479,7 @@ exports[`VirtualizedList handles nested lists 1`] = ` stickyHeaderIndices={Array []} > @@ -461,6 +488,7 @@ exports[`VirtualizedList handles nested lists 1`] = ` /> @@ -471,6 +499,7 @@ exports[`VirtualizedList handles nested lists 1`] = ` @@ -501,6 +530,7 @@ exports[`VirtualizedList handles nested lists 1`] = ` > @@ -584,6 +616,7 @@ exports[`VirtualizedList handles separators correctly 1`] = ` /> @@ -600,6 +633,7 @@ exports[`VirtualizedList handles separators correctly 1`] = ` /> @@ -642,6 +676,7 @@ exports[`VirtualizedList handles separators correctly 2`] = ` > @@ -658,6 +693,7 @@ exports[`VirtualizedList handles separators correctly 2`] = ` /> @@ -674,6 +710,7 @@ exports[`VirtualizedList handles separators correctly 2`] = ` /> @@ -716,6 +753,7 @@ exports[`VirtualizedList handles separators correctly 3`] = ` > @@ -732,6 +770,7 @@ exports[`VirtualizedList handles separators correctly 3`] = ` /> @@ -749,6 +788,7 @@ exports[`VirtualizedList handles separators correctly 3`] = ` /> @@ -850,16 +890,15 @@ exports[`VirtualizedList keeps sticky headers above viewport visualized 1`] = ` Array [ 0, 2, - 4, - 7, - 10, - 13, + 5, + 8, ] } windowSize={1} > - - - - - - - - - - - - - - - @@ -1334,6 +1349,7 @@ exports[`VirtualizedList renders simple list 1`] = ` > @@ -1342,6 +1358,7 @@ exports[`VirtualizedList renders simple list 1`] = ` /> @@ -1350,6 +1367,7 @@ exports[`VirtualizedList renders simple list 1`] = ` /> @@ -1391,6 +1409,7 @@ exports[`VirtualizedList renders simple list using ListItemComponent 1`] = ` > @@ -1399,6 +1418,7 @@ exports[`VirtualizedList renders simple list using ListItemComponent 1`] = ` /> @@ -1407,6 +1427,7 @@ exports[`VirtualizedList renders simple list using ListItemComponent 1`] = ` /> @@ -1481,6 +1502,7 @@ exports[`VirtualizedList renders sticky headers in viewport on batched render 1` > @@ -1588,6 +1615,7 @@ exports[`VirtualizedList warns if both renderItem or ListItemComponent are speci > @@ -1686,6 +1714,7 @@ exports[`adjusts render area with non-zero initialScrollIndex 1`] = ` > 0 and sc } /> 0 and sc /> 0 and sc /> 0 and sc /> 0 and sc /> 0 and sc /> 0 and sc /> 0 and sc /> 0 and sc /> 0 and sc /> 0 and sc /> - - - - - - - - - - - - - - - 0 and offset not } /> 0 and offset not /> 0 and offset not /> 0 and offset not /> 0 and offset not /> - - - - - - - - - - - + style={ + Object { + "height": 40, + } + } + /> - - - - - - - - - - - + style={ + Object { + "height": 40, + } + } + /> + + + @@ -3145,17 +3184,10 @@ exports[`initially renders nothing when initialNumToRender is 0 1`] = ` stickyHeaderIndices={Array []} > - - - @@ -3163,7 +3195,7 @@ exports[`initially renders nothing when initialNumToRender is 0 1`] = ` `; -exports[`renders a zero-height tail spacer on initial render if getItemLayout not defined 1`] = ` +exports[`keeps viewport above last focused rendered 1`] = ` - - - - - - - - - - - - - -`; - -exports[`renders full tail spacer if all cells measured 1`] = ` - - - -`; - -exports[`renders initialNumToRender cells when virtualization disabled 1`] = ` - + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`keeps viewport below last focused rendered 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`renders a zero-height tail spacer on initial render if getItemLayout not defined 1`] = ` + + + + + + + + + + + + + + +`; + +exports[`renders full tail spacer if all cells measured 1`] = ` + + + + + + + + + + + + + + + +`; + +exports[`renders initialNumToRender cells when virtualization disabled 1`] = ` + + + + + + + + + + +`; + +exports[`renders new items when data is updated with non-zero initialScrollIndex 1`] = ` + + + + + + + + + + + + + + + + +`; + +exports[`renders no spacers up to initialScrollIndex on first render when virtualization disabled 1`] = ` + + + `; -exports[`renders new items when data is updated with non-zero initialScrollIndex 1`] = ` +exports[`renders offset cells in initial render when initialScrollIndex set 1`] = ` + + `; -exports[`renders no spacers up to initialScrollIndex on first render when virtualization disabled 1`] = ` +exports[`renders tail spacer up to last measured index if getItemLayout not defined 1`] = ` + + + + + + + + + + `; -exports[`renders offset cells in initial render when initialScrollIndex set 1`] = ` +exports[`renders tail spacer up to last measured with irregular layout when getItemLayout undefined 1`] = ` - - - - - - - - - - - - @@ -3823,7 +4478,7 @@ exports[`renders offset cells in initial render when initialScrollIndex set 1`] `; -exports[`renders tail spacer up to last measured index if getItemLayout not defined 1`] = ` +exports[`renders windowSize derived region at bottom 1`] = ` + + onFocusCapture={[Function]} + style={null} + > + + `; -exports[`renders tail spacer up to last measured with irregular layout when getItemLayout undefined 1`] = ` +exports[`renders windowSize derived region at top 1`] = ` @@ -4023,7 +4688,7 @@ exports[`renders tail spacer up to last measured with irregular layout when getI `; -exports[`renders windowSize derived region at bottom 1`] = ` +exports[`renders windowSize derived region in middle 1`] = ` + + + + + + + `; -exports[`renders windowSize derived region at top 1`] = ` +exports[`renders zero-height tail spacer on batch render if cells not yet measured and getItemLayout not defined 1`] = ` - - - @@ -4223,7 +4915,7 @@ exports[`renders windowSize derived region at top 1`] = ` `; -exports[`renders windowSize derived region in middle 1`] = ` +exports[`retains batch render region when an item is appended 1`] = ` - + + + + + + `; -exports[`renders zero-height tail spacer on batch render if cells not yet measured and getItemLayout not defined 1`] = ` +exports[`retains initial render region when an item is appended 1`] = ` @@ -4439,7 +5152,7 @@ exports[`renders zero-height tail spacer on batch render if cells not yet measur `; -exports[`retains batch render region when an item is appended 1`] = ` +exports[`retains intitial render if initialScrollIndex == 0 1`] = ` + + onFocusCapture={[Function]} + style={null} + > + + `; -exports[`retains initial render region when an item is appended 1`] = ` +exports[`unmounts sticky headers moved below viewport 1`] = ` + + + + + + @@ -4663,7 +5483,7 @@ exports[`retains initial render region when an item is appended 1`] = ` `; -exports[`retains intitial render if initialScrollIndex == 0 1`] = ` +exports[`virtualizes away last focused index if item removed 1`] = ` - - - - - - - - - - - - `; -exports[`unmounts sticky headers moved below viewport 1`] = ` +exports[`virtualizes away last focused item if focus changes to a new cell 1`] = ` + + onFocusCapture={[Function]} + style={null} + > + + + + + + + + + + + `; diff --git a/Libraries/Lists/__tests__/__snapshots__/VirtualizedList_EXPERIMENTAL-test.js.snap b/Libraries/Lists/__tests__/__snapshots__/VirtualizedList_EXPERIMENTAL-test.js.snap deleted file mode 100644 index 95a87af4a76b90..00000000000000 --- a/Libraries/Lists/__tests__/__snapshots__/VirtualizedList_EXPERIMENTAL-test.js.snap +++ /dev/null @@ -1,5837 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`VirtualizedList (Experimental functionality) keeps viewport above last focused rendered 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`VirtualizedList (Experimental functionality) keeps viewport below last focused rendered 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`VirtualizedList (Experimental functionality) virtualizes away last focused index if item removed 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`VirtualizedList (Experimental functionality) virtualizes away last focused item if focus changes to a new cell 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`VirtualizedList forwards correct stickyHeaderIndices when ListHeaderComponent present 1`] = ` - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`VirtualizedList forwards correct stickyHeaderIndices when all in initial render window 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`VirtualizedList forwards correct stickyHeaderIndices when partially in initial render window 1`] = ` - - - - - - - - - - - - - - - - - - - - -`; - -exports[`VirtualizedList handles nested lists 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`VirtualizedList handles separators correctly 1`] = ` - - - - - - - - - - - - - - - -`; - -exports[`VirtualizedList handles separators correctly 2`] = ` - - - - - - - - - - - - - - - -`; - -exports[`VirtualizedList handles separators correctly 3`] = ` - - - - - - - - - - - - - - - -`; - -exports[`VirtualizedList keeps sticky headers above viewport visualized 1`] = ` - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -`; - -exports[`VirtualizedList renders all the bells and whistles 1`] = ` - - } - refreshing={false} - renderItem={[Function]} - scrollEventThrottle={50} - stickyHeaderIndices={Array []} - style={ - Array [ - Object { - "transform": Array [ - Object { - "scaleY": -1, - }, - ], - }, - undefined, - ] - } -> - - - -
- - - - - - - - - - - - - - - - - - - - - -