From ebaa00e327404ce11f0d35bbcbab13e65dfb4db7 Mon Sep 17 00:00:00 2001 From: Nick Gerleman Date: Mon, 6 Feb 2023 20:00:19 -0800 Subject: [PATCH] Reconnect VirtualizedList Source History 1/2 (Revert D41745930) Summary: This change reverts D41745930 (https://github.com/facebook/react-native/commit/2e3dbe9c2fbff52448e2d5a7c1e4c96b1016cf25) as part of a stack to splice back source history which was lost (Git registered the file moves as additions). It is expected this diff will individually fail. The entire stack should be applied at once. Changelog: [Internal] Reviewed By: hoxyq Differential Revision: D43068113 fbshipit-source-id: c8398629fe5dcc1ca4bf02f550adc00c78a8487a --- Libraries/Inspector/NetworkOverlay.js | 2 +- .../Interaction/Batchinator.js | 2 +- .../Interaction/__tests__/Batchinator-test.js | 4 + .../Lists/CellRenderMask.js | 0 .../Lists/ChildListCollection.js | 0 Libraries/Lists/FillRateHelper.js | 242 +- Libraries/Lists/FlatList.d.ts | 2 +- Libraries/Lists/FlatList.js | 11 +- Libraries/Lists/FlatList.js.flow | 9 +- Libraries/Lists/SectionList.d.ts | 2 +- Libraries/Lists/SectionList.js | 6 +- Libraries/Lists/SectionListModern.js | 6 +- .../Lists/StateSafePureComponent.js | 0 Libraries/Lists/ViewabilityHelper.js | 352 ++- Libraries/Lists/VirtualizeUtils.js | 248 ++- .../Lists/VirtualizedList.d.ts | 12 +- Libraries/Lists/VirtualizedList.js | 1949 +++++++++++++++- .../Lists/VirtualizedListCellRenderer.js | 10 +- Libraries/Lists/VirtualizedListContext.js | 110 +- .../Lists/VirtualizedListProps.js | 4 +- Libraries/Lists/VirtualizedSectionList.js | 613 +++++- .../Lists/__tests__/CellRenderMask-test.js | 0 .../Lists/__tests__/FillRateHelper-test.js | 0 .../Lists/__tests__/ViewabilityHelper-test.js | 0 .../Lists/__tests__/VirtualizeUtils-test.js | 0 .../Lists/__tests__/VirtualizedList-test.js | 0 .../__tests__/VirtualizedSectionList-test.js | 0 .../VirtualizedList-test.js.snap | 0 .../VirtualizedSectionList-test.js.snap | 0 Libraries/Modal/Modal.js | 2 +- Libraries/Utilities/ReactNativeTestTools.js | 2 +- .../Utilities/__tests__/clamp-test.js | 0 .../Utilities/clamp.js | 0 index.js | 2 +- package.json | 1 - .../js/examples/FlatList/FlatList-basic.js | 2 +- .../js/examples/FlatList/FlatList-nested.js | 4 +- .../FlatList-onViewableItemsChanged.js | 2 +- .../virtualized-lists/Lists/FillRateHelper.js | 253 --- .../Lists/ViewabilityHelper.js | 360 --- .../Lists/VirtualizeUtils.js | 258 --- .../Lists/VirtualizedList.js | 1955 ----------------- .../Lists/VirtualizedListContext.js | 116 - .../Lists/VirtualizedSectionList.js | 617 ------ .../virtualized-lists/Utilities/infoLog.js | 20 - packages/virtualized-lists/index.d.ts | 10 - packages/virtualized-lists/index.js | 57 - packages/virtualized-lists/package.json | 21 - types/index.d.ts | 2 +- 49 files changed, 3513 insertions(+), 3755 deletions(-) rename {packages/virtualized-lists => Libraries}/Interaction/Batchinator.js (97%) rename {packages/virtualized-lists => Libraries}/Interaction/__tests__/Batchinator-test.js (95%) rename {packages/virtualized-lists => Libraries}/Lists/CellRenderMask.js (100%) rename {packages/virtualized-lists => Libraries}/Lists/ChildListCollection.js (100%) rename {packages/virtualized-lists => Libraries}/Lists/StateSafePureComponent.js (100%) rename {packages/virtualized-lists => Libraries}/Lists/VirtualizedList.d.ts (97%) rename {packages/virtualized-lists => Libraries}/Lists/VirtualizedListCellRenderer.js (96%) rename {packages/virtualized-lists => Libraries}/Lists/VirtualizedListProps.js (98%) rename {packages/virtualized-lists => Libraries}/Lists/__tests__/CellRenderMask-test.js (100%) rename {packages/virtualized-lists => Libraries}/Lists/__tests__/FillRateHelper-test.js (100%) rename {packages/virtualized-lists => Libraries}/Lists/__tests__/ViewabilityHelper-test.js (100%) rename {packages/virtualized-lists => Libraries}/Lists/__tests__/VirtualizeUtils-test.js (100%) rename {packages/virtualized-lists => Libraries}/Lists/__tests__/VirtualizedList-test.js (100%) rename {packages/virtualized-lists => Libraries}/Lists/__tests__/VirtualizedSectionList-test.js (100%) rename {packages/virtualized-lists => Libraries}/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap (100%) rename {packages/virtualized-lists => Libraries}/Lists/__tests__/__snapshots__/VirtualizedSectionList-test.js.snap (100%) rename {packages/virtualized-lists => Libraries}/Utilities/__tests__/clamp-test.js (100%) rename {packages/virtualized-lists => Libraries}/Utilities/clamp.js (100%) delete mode 100644 packages/virtualized-lists/Lists/FillRateHelper.js delete mode 100644 packages/virtualized-lists/Lists/ViewabilityHelper.js delete mode 100644 packages/virtualized-lists/Lists/VirtualizeUtils.js delete mode 100644 packages/virtualized-lists/Lists/VirtualizedList.js delete mode 100644 packages/virtualized-lists/Lists/VirtualizedListContext.js delete mode 100644 packages/virtualized-lists/Lists/VirtualizedSectionList.js delete mode 100644 packages/virtualized-lists/Utilities/infoLog.js delete mode 100644 packages/virtualized-lists/index.d.ts delete mode 100644 packages/virtualized-lists/index.js delete mode 100644 packages/virtualized-lists/package.json diff --git a/Libraries/Inspector/NetworkOverlay.js b/Libraries/Inspector/NetworkOverlay.js index c6d1ec33907..169318cea27 100644 --- a/Libraries/Inspector/NetworkOverlay.js +++ b/Libraries/Inspector/NetworkOverlay.js @@ -10,7 +10,7 @@ 'use strict'; -import type {RenderItemProps} from '@react-native/virtualized-lists'; +import type {RenderItemProps} from '../Lists/VirtualizedList'; const ScrollView = require('../Components/ScrollView/ScrollView'); const TouchableHighlight = require('../Components/Touchable/TouchableHighlight'); diff --git a/packages/virtualized-lists/Interaction/Batchinator.js b/Libraries/Interaction/Batchinator.js similarity index 97% rename from packages/virtualized-lists/Interaction/Batchinator.js rename to Libraries/Interaction/Batchinator.js index 4fbc1931ca5..2ca2d7986d1 100644 --- a/packages/virtualized-lists/Interaction/Batchinator.js +++ b/Libraries/Interaction/Batchinator.js @@ -10,7 +10,7 @@ 'use strict'; -const {InteractionManager} = require('react-native'); +const InteractionManager = require('./InteractionManager'); /** * A simple class for batching up invocations of a low-pri callback. A timeout is set to run the diff --git a/packages/virtualized-lists/Interaction/__tests__/Batchinator-test.js b/Libraries/Interaction/__tests__/Batchinator-test.js similarity index 95% rename from packages/virtualized-lists/Interaction/__tests__/Batchinator-test.js rename to Libraries/Interaction/__tests__/Batchinator-test.js index b680e98c507..e8261b3515e 100644 --- a/packages/virtualized-lists/Interaction/__tests__/Batchinator-test.js +++ b/Libraries/Interaction/__tests__/Batchinator-test.js @@ -10,6 +10,10 @@ 'use strict'; +jest + .mock('../../vendor/core/ErrorUtils') + .mock('../../BatchedBridge/BatchedBridge'); + function expectToBeCalledOnce(fn) { expect(fn.mock.calls.length).toBe(1); } diff --git a/packages/virtualized-lists/Lists/CellRenderMask.js b/Libraries/Lists/CellRenderMask.js similarity index 100% rename from packages/virtualized-lists/Lists/CellRenderMask.js rename to Libraries/Lists/CellRenderMask.js diff --git a/packages/virtualized-lists/Lists/ChildListCollection.js b/Libraries/Lists/ChildListCollection.js similarity index 100% rename from packages/virtualized-lists/Lists/ChildListCollection.js rename to Libraries/Lists/ChildListCollection.js diff --git a/Libraries/Lists/FillRateHelper.js b/Libraries/Lists/FillRateHelper.js index 141fe98eb06..87482e73f6b 100644 --- a/Libraries/Lists/FillRateHelper.js +++ b/Libraries/Lists/FillRateHelper.js @@ -10,10 +10,244 @@ 'use strict'; -import {typeof FillRateHelper as FillRateHelperType} from '@react-native/virtualized-lists'; +import type {FrameMetricProps} from './VirtualizedListProps'; -const FillRateHelper: FillRateHelperType = - require('@react-native/virtualized-lists').FillRateHelper; +export type FillRateInfo = Info; + +class Info { + any_blank_count: number = 0; + any_blank_ms: number = 0; + any_blank_speed_sum: number = 0; + mostly_blank_count: number = 0; + mostly_blank_ms: number = 0; + pixels_blank: number = 0; + pixels_sampled: number = 0; + pixels_scrolled: number = 0; + total_time_spent: number = 0; + sample_count: number = 0; +} + +type FrameMetrics = { + inLayout?: boolean, + length: number, + offset: number, + ... +}; + +const DEBUG = false; + +let _listeners: Array<(Info) => void> = []; +let _minSampleCount = 10; +let _sampleRate = DEBUG ? 1 : null; + +/** + * A helper class for detecting when the maximem fill rate of `VirtualizedList` is exceeded. + * By default the sampling rate is set to zero and this will do nothing. If you want to collect + * samples (e.g. to log them), make sure to call `FillRateHelper.setSampleRate(0.0-1.0)`. + * + * Listeners and sample rate are global for all `VirtualizedList`s - typical usage will combine with + * `SceneTracker.getActiveScene` to determine the context of the events. + */ +class FillRateHelper { + _anyBlankStartTime: ?number = null; + _enabled = false; + _getFrameMetrics: (index: number, props: FrameMetricProps) => ?FrameMetrics; + _info: Info = new Info(); + _mostlyBlankStartTime: ?number = null; + _samplesStartTime: ?number = null; + + static addListener(callback: FillRateInfo => void): { + remove: () => void, + ... + } { + if (_sampleRate === null) { + console.warn('Call `FillRateHelper.setSampleRate` before `addListener`.'); + } + _listeners.push(callback); + return { + remove: () => { + _listeners = _listeners.filter(listener => callback !== listener); + }, + }; + } + + static setSampleRate(sampleRate: number) { + _sampleRate = sampleRate; + } + + static setMinSampleCount(minSampleCount: number) { + _minSampleCount = minSampleCount; + } + + constructor( + getFrameMetrics: (index: number, props: FrameMetricProps) => ?FrameMetrics, + ) { + this._getFrameMetrics = getFrameMetrics; + this._enabled = (_sampleRate || 0) > Math.random(); + this._resetData(); + } + + activate() { + if (this._enabled && this._samplesStartTime == null) { + DEBUG && console.debug('FillRateHelper: activate'); + this._samplesStartTime = global.performance.now(); + } + } + + deactivateAndFlush() { + if (!this._enabled) { + return; + } + const start = this._samplesStartTime; // const for flow + if (start == null) { + DEBUG && + console.debug('FillRateHelper: bail on deactivate with no start time'); + return; + } + if (this._info.sample_count < _minSampleCount) { + // Don't bother with under-sampled events. + this._resetData(); + return; + } + const total_time_spent = global.performance.now() - start; + const info: any = { + ...this._info, + total_time_spent, + }; + if (DEBUG) { + const derived = { + avg_blankness: this._info.pixels_blank / this._info.pixels_sampled, + avg_speed: this._info.pixels_scrolled / (total_time_spent / 1000), + avg_speed_when_any_blank: + this._info.any_blank_speed_sum / this._info.any_blank_count, + any_blank_per_min: + this._info.any_blank_count / (total_time_spent / 1000 / 60), + any_blank_time_frac: this._info.any_blank_ms / total_time_spent, + mostly_blank_per_min: + this._info.mostly_blank_count / (total_time_spent / 1000 / 60), + mostly_blank_time_frac: this._info.mostly_blank_ms / total_time_spent, + }; + for (const key in derived) { + // $FlowFixMe[prop-missing] + derived[key] = Math.round(1000 * derived[key]) / 1000; + } + console.debug('FillRateHelper deactivateAndFlush: ', {derived, info}); + } + _listeners.forEach(listener => listener(info)); + this._resetData(); + } + + computeBlankness( + props: { + ...FrameMetricProps, + initialNumToRender?: ?number, + ... + }, + cellsAroundViewport: { + first: number, + last: number, + ... + }, + scrollMetrics: { + dOffset: number, + offset: number, + velocity: number, + visibleLength: number, + ... + }, + ): number { + if ( + !this._enabled || + props.getItemCount(props.data) === 0 || + cellsAroundViewport.last < cellsAroundViewport.first || + this._samplesStartTime == null + ) { + return 0; + } + const {dOffset, offset, velocity, visibleLength} = scrollMetrics; + + // Denominator metrics that we track for all events - most of the time there is no blankness and + // we want to capture that. + this._info.sample_count++; + this._info.pixels_sampled += Math.round(visibleLength); + this._info.pixels_scrolled += Math.round(Math.abs(dOffset)); + const scrollSpeed = Math.round(Math.abs(velocity) * 1000); // px / sec + + // Whether blank now or not, record the elapsed time blank if we were blank last time. + const now = global.performance.now(); + if (this._anyBlankStartTime != null) { + this._info.any_blank_ms += now - this._anyBlankStartTime; + } + this._anyBlankStartTime = null; + if (this._mostlyBlankStartTime != null) { + this._info.mostly_blank_ms += now - this._mostlyBlankStartTime; + } + this._mostlyBlankStartTime = null; + + let blankTop = 0; + let first = cellsAroundViewport.first; + let firstFrame = this._getFrameMetrics(first, props); + while ( + first <= cellsAroundViewport.last && + (!firstFrame || !firstFrame.inLayout) + ) { + firstFrame = this._getFrameMetrics(first, props); + first++; + } + // Only count blankTop if we aren't rendering the first item, otherwise we will count the header + // as blank. + if (firstFrame && first > 0) { + blankTop = Math.min( + visibleLength, + Math.max(0, firstFrame.offset - offset), + ); + } + let blankBottom = 0; + let last = cellsAroundViewport.last; + let lastFrame = this._getFrameMetrics(last, props); + while ( + last >= cellsAroundViewport.first && + (!lastFrame || !lastFrame.inLayout) + ) { + lastFrame = this._getFrameMetrics(last, props); + last--; + } + // Only count blankBottom if we aren't rendering the last item, otherwise we will count the + // footer as blank. + if (lastFrame && last < props.getItemCount(props.data) - 1) { + const bottomEdge = lastFrame.offset + lastFrame.length; + blankBottom = Math.min( + visibleLength, + Math.max(0, offset + visibleLength - bottomEdge), + ); + } + const pixels_blank = Math.round(blankTop + blankBottom); + const blankness = pixels_blank / visibleLength; + if (blankness > 0) { + this._anyBlankStartTime = now; + this._info.any_blank_speed_sum += scrollSpeed; + this._info.any_blank_count++; + this._info.pixels_blank += pixels_blank; + if (blankness > 0.5) { + this._mostlyBlankStartTime = now; + this._info.mostly_blank_count++; + } + } else if (scrollSpeed < 0.01 || Math.abs(dOffset) < 1) { + this.deactivateAndFlush(); + } + return blankness; + } + + enabled(): boolean { + return this._enabled; + } + + _resetData() { + this._anyBlankStartTime = null; + this._info = new Info(); + this._mostlyBlankStartTime = null; + this._samplesStartTime = null; + } +} -export type {FillRateInfo} from '@react-native/virtualized-lists'; module.exports = FillRateHelper; diff --git a/Libraries/Lists/FlatList.d.ts b/Libraries/Lists/FlatList.d.ts index 6ac7f57fdcd..344d5671359 100644 --- a/Libraries/Lists/FlatList.d.ts +++ b/Libraries/Lists/FlatList.d.ts @@ -12,7 +12,7 @@ import type { ListRenderItem, ViewToken, VirtualizedListProps, -} from '@react-native/virtualized-lists'; +} from './VirtualizedList'; import type {ScrollViewComponent} from '../Components/ScrollView/ScrollView'; import {StyleProp} from '../StyleSheet/StyleSheet'; import {ViewStyle} from '../StyleSheet/StyleSheetTypes'; diff --git a/Libraries/Lists/FlatList.js b/Libraries/Lists/FlatList.js index 40372bc70dd..56748eaf752 100644 --- a/Libraries/Lists/FlatList.js +++ b/Libraries/Lists/FlatList.js @@ -11,17 +11,14 @@ import typeof ScrollViewNativeComponent from '../Components/ScrollView/ScrollViewNativeComponent'; import type {ViewStyleProp} from '../StyleSheet/StyleSheet'; import type { - RenderItemProps, - RenderItemType, ViewabilityConfigCallbackPair, ViewToken, -} from '@react-native/virtualized-lists'; +} from './ViewabilityHelper'; +import type {RenderItemProps, RenderItemType} from './VirtualizedList'; import {type ScrollResponderType} from '../Components/ScrollView/ScrollView'; -import { - VirtualizedList, - keyExtractor as defaultKeyExtractor, -} from '@react-native/virtualized-lists'; +import VirtualizedList from './VirtualizedList'; +import {keyExtractor as defaultKeyExtractor} from './VirtualizeUtils'; import memoizeOne from 'memoize-one'; const View = require('../Components/View/View'); diff --git a/Libraries/Lists/FlatList.js.flow b/Libraries/Lists/FlatList.js.flow index 304a1006182..10a7c907325 100644 --- a/Libraries/Lists/FlatList.js.flow +++ b/Libraries/Lists/FlatList.js.flow @@ -14,13 +14,8 @@ const View = require('../Components/View/View'); import typeof ScrollViewNativeComponent from '../Components/ScrollView/ScrollViewNativeComponent'; import {type ScrollResponderType} from '../Components/ScrollView/ScrollView'; import type {ViewStyleProp} from '../StyleSheet/StyleSheet'; -import type { - RenderItemType, - RenderItemProps, - ViewToken, - ViewabilityConfigCallbackPair, -} from '@react-native/virtualized-lists'; -import {typeof VirtualizedList} from '@react-native/virtualized-lists'; +import type {RenderItemType} from './VirtualizedList'; +import typeof VirtualizedList from './VirtualizedList'; type RequiredProps = {| /** diff --git a/Libraries/Lists/SectionList.d.ts b/Libraries/Lists/SectionList.d.ts index 7ff5bbb0a3c..ae1b10df46a 100644 --- a/Libraries/Lists/SectionList.d.ts +++ b/Libraries/Lists/SectionList.d.ts @@ -11,7 +11,7 @@ import type * as React from 'react'; import type { ListRenderItemInfo, VirtualizedListWithoutRenderItemProps, -} from '@react-native/virtualized-lists'; +} from './VirtualizedList'; import type { ScrollView, ScrollViewProps, diff --git a/Libraries/Lists/SectionList.js b/Libraries/Lists/SectionList.js index 0f199487b92..d452ee2b7f6 100644 --- a/Libraries/Lists/SectionList.js +++ b/Libraries/Lists/SectionList.js @@ -12,13 +12,13 @@ import type {ScrollResponderType} from '../Components/ScrollView/ScrollView'; import type { + Props as VirtualizedSectionListProps, ScrollToLocationParamsType, SectionBase as _SectionBase, - VirtualizedSectionListProps, -} from '@react-native/virtualized-lists'; +} from './VirtualizedSectionList'; import Platform from '../Utilities/Platform'; -import {VirtualizedSectionList} from '@react-native/virtualized-lists'; +import VirtualizedSectionList from './VirtualizedSectionList'; import * as React from 'react'; type Item = any; diff --git a/Libraries/Lists/SectionListModern.js b/Libraries/Lists/SectionListModern.js index d9676f106f8..c7856e13d9a 100644 --- a/Libraries/Lists/SectionListModern.js +++ b/Libraries/Lists/SectionListModern.js @@ -12,14 +12,14 @@ import type {ScrollResponderType} from '../Components/ScrollView/ScrollView'; import type { + Props as VirtualizedSectionListProps, ScrollToLocationParamsType, SectionBase as _SectionBase, - VirtualizedSectionListProps, -} from '@react-native/virtualized-lists'; +} from './VirtualizedSectionList'; import type {AbstractComponent, Element, ElementRef} from 'react'; import Platform from '../Utilities/Platform'; -import {VirtualizedSectionList} from '@react-native/virtualized-lists'; +import VirtualizedSectionList from './VirtualizedSectionList'; import React, {forwardRef, useImperativeHandle, useRef} from 'react'; type Item = any; diff --git a/packages/virtualized-lists/Lists/StateSafePureComponent.js b/Libraries/Lists/StateSafePureComponent.js similarity index 100% rename from packages/virtualized-lists/Lists/StateSafePureComponent.js rename to Libraries/Lists/StateSafePureComponent.js diff --git a/Libraries/Lists/ViewabilityHelper.js b/Libraries/Lists/ViewabilityHelper.js index c7dedfdf496..33a9811825a 100644 --- a/Libraries/Lists/ViewabilityHelper.js +++ b/Libraries/Lists/ViewabilityHelper.js @@ -10,15 +10,351 @@ 'use strict'; -export type { - ViewToken, - ViewabilityConfig, - ViewabilityConfigCallbackPair, -} from '@react-native/virtualized-lists'; +import type {FrameMetricProps} from './VirtualizedListProps'; -import {typeof ViewabilityHelper as ViewabilityHelperType} from '@react-native/virtualized-lists'; +const invariant = require('invariant'); -const ViewabilityHelper: ViewabilityHelperType = - require('@react-native/virtualized-lists').ViewabilityHelper; +export type ViewToken = { + item: any, + key: string, + index: ?number, + isViewable: boolean, + section?: any, + ... +}; + +export type ViewabilityConfigCallbackPair = { + viewabilityConfig: ViewabilityConfig, + onViewableItemsChanged: (info: { + viewableItems: Array, + changed: Array, + ... + }) => void, + ... +}; + +export type ViewabilityConfig = {| + /** + * Minimum amount of time (in milliseconds) that an item must be physically viewable before the + * viewability callback will be fired. A high number means that scrolling through content without + * stopping will not mark the content as viewable. + */ + minimumViewTime?: number, + + /** + * Percent of viewport that must be covered for a partially occluded item to count as + * "viewable", 0-100. Fully visible items are always considered viewable. A value of 0 means + * that a single pixel in the viewport makes the item viewable, and a value of 100 means that + * an item must be either entirely visible or cover the entire viewport to count as viewable. + */ + viewAreaCoveragePercentThreshold?: number, + + /** + * Similar to `viewAreaPercentThreshold`, but considers the percent of the item that is visible, + * rather than the fraction of the viewable area it covers. + */ + itemVisiblePercentThreshold?: number, + + /** + * Nothing is considered viewable until the user scrolls or `recordInteraction` is called after + * render. + */ + waitForInteraction?: boolean, +|}; + +/** + * A Utility class for calculating viewable items based on current metrics like scroll position and + * layout. + * + * An item is said to be in a "viewable" state when any of the following + * is true for longer than `minimumViewTime` milliseconds (after an interaction if `waitForInteraction` + * is true): + * + * - Occupying >= `viewAreaCoveragePercentThreshold` of the view area XOR fraction of the item + * visible in the view area >= `itemVisiblePercentThreshold`. + * - Entirely visible on screen + */ +class ViewabilityHelper { + _config: ViewabilityConfig; + _hasInteracted: boolean = false; + _timers: Set = new Set(); + _viewableIndices: Array = []; + _viewableItems: Map = new Map(); + + constructor( + config: ViewabilityConfig = {viewAreaCoveragePercentThreshold: 0}, + ) { + this._config = config; + } + + /** + * Cleanup, e.g. on unmount. Clears any pending timers. + */ + dispose() { + /* $FlowFixMe[incompatible-call] (>=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. */ + this._timers.forEach(clearTimeout); + } + + /** + * Determines which items are viewable based on the current metrics and config. + */ + computeViewableItems( + props: FrameMetricProps, + scrollOffset: number, + viewportHeight: number, + getFrameMetrics: ( + index: number, + props: FrameMetricProps, + ) => ?{ + length: number, + offset: number, + ... + }, + // Optional optimization to reduce the scan size + renderRange?: { + first: number, + last: number, + ... + }, + ): Array { + const itemCount = props.getItemCount(props.data); + const {itemVisiblePercentThreshold, viewAreaCoveragePercentThreshold} = + this._config; + const viewAreaMode = viewAreaCoveragePercentThreshold != null; + const viewablePercentThreshold = viewAreaMode + ? viewAreaCoveragePercentThreshold + : itemVisiblePercentThreshold; + invariant( + viewablePercentThreshold != null && + (itemVisiblePercentThreshold != null) !== + (viewAreaCoveragePercentThreshold != null), + 'Must set exactly one of itemVisiblePercentThreshold or viewAreaCoveragePercentThreshold', + ); + const viewableIndices = []; + if (itemCount === 0) { + return viewableIndices; + } + let firstVisible = -1; + const {first, last} = renderRange || {first: 0, last: itemCount - 1}; + if (last >= itemCount) { + console.warn( + 'Invalid render range computing viewability ' + + JSON.stringify({renderRange, itemCount}), + ); + return []; + } + for (let idx = first; idx <= last; idx++) { + const metrics = getFrameMetrics(idx, props); + if (!metrics) { + continue; + } + const top = metrics.offset - scrollOffset; + const bottom = top + metrics.length; + if (top < viewportHeight && bottom > 0) { + firstVisible = idx; + if ( + _isViewable( + viewAreaMode, + viewablePercentThreshold, + top, + bottom, + viewportHeight, + metrics.length, + ) + ) { + viewableIndices.push(idx); + } + } else if (firstVisible >= 0) { + break; + } + } + return viewableIndices; + } + + /** + * Figures out which items are viewable and how that has changed from before and calls + * `onViewableItemsChanged` as appropriate. + */ + onUpdate( + props: FrameMetricProps, + scrollOffset: number, + viewportHeight: number, + getFrameMetrics: ( + index: number, + props: FrameMetricProps, + ) => ?{ + length: number, + offset: number, + ... + }, + createViewToken: ( + index: number, + isViewable: boolean, + props: FrameMetricProps, + ) => ViewToken, + onViewableItemsChanged: ({ + viewableItems: Array, + changed: Array, + ... + }) => void, + // Optional optimization to reduce the scan size + renderRange?: { + first: number, + last: number, + ... + }, + ): void { + const itemCount = props.getItemCount(props.data); + if ( + (this._config.waitForInteraction && !this._hasInteracted) || + itemCount === 0 || + !getFrameMetrics(0, props) + ) { + return; + } + let viewableIndices: Array = []; + if (itemCount) { + viewableIndices = this.computeViewableItems( + props, + scrollOffset, + viewportHeight, + getFrameMetrics, + renderRange, + ); + } + if ( + this._viewableIndices.length === viewableIndices.length && + this._viewableIndices.every((v, ii) => v === viewableIndices[ii]) + ) { + // We might get a lot of scroll events where visibility doesn't change and we don't want to do + // extra work in those cases. + return; + } + this._viewableIndices = viewableIndices; + if (this._config.minimumViewTime) { + const handle: TimeoutID = setTimeout(() => { + /* $FlowFixMe[incompatible-call] (>=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. */ + this._timers.delete(handle); + this._onUpdateSync( + props, + viewableIndices, + onViewableItemsChanged, + createViewToken, + ); + }, this._config.minimumViewTime); + /* $FlowFixMe[incompatible-call] (>=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. */ + this._timers.add(handle); + } else { + this._onUpdateSync( + props, + viewableIndices, + onViewableItemsChanged, + createViewToken, + ); + } + } + + /** + * clean-up cached _viewableIndices to evaluate changed items on next update + */ + resetViewableIndices() { + this._viewableIndices = []; + } + + /** + * Records that an interaction has happened even if there has been no scroll. + */ + recordInteraction() { + this._hasInteracted = true; + } + + _onUpdateSync( + props: FrameMetricProps, + viewableIndicesToCheck: Array, + onViewableItemsChanged: ({ + changed: Array, + viewableItems: Array, + ... + }) => void, + createViewToken: ( + index: number, + isViewable: boolean, + props: FrameMetricProps, + ) => ViewToken, + ) { + // Filter out indices that have gone out of view since this call was scheduled. + viewableIndicesToCheck = viewableIndicesToCheck.filter(ii => + this._viewableIndices.includes(ii), + ); + const prevItems = this._viewableItems; + const nextItems = new Map( + viewableIndicesToCheck.map(ii => { + const viewable = createViewToken(ii, true, props); + return [viewable.key, viewable]; + }), + ); + + const changed = []; + for (const [key, viewable] of nextItems) { + if (!prevItems.has(key)) { + changed.push(viewable); + } + } + for (const [key, viewable] of prevItems) { + if (!nextItems.has(key)) { + changed.push({...viewable, isViewable: false}); + } + } + if (changed.length > 0) { + this._viewableItems = nextItems; + onViewableItemsChanged({ + viewableItems: Array.from(nextItems.values()), + changed, + viewabilityConfig: this._config, + }); + } + } +} + +function _isViewable( + viewAreaMode: boolean, + viewablePercentThreshold: number, + top: number, + bottom: number, + viewportHeight: number, + itemLength: number, +): boolean { + if (_isEntirelyVisible(top, bottom, viewportHeight)) { + return true; + } else { + const pixels = _getPixelsVisible(top, bottom, viewportHeight); + const percent = + 100 * (viewAreaMode ? pixels / viewportHeight : pixels / itemLength); + return percent >= viewablePercentThreshold; + } +} + +function _getPixelsVisible( + top: number, + bottom: number, + viewportHeight: number, +): number { + const visibleHeight = Math.min(bottom, viewportHeight) - Math.max(top, 0); + return Math.max(0, visibleHeight); +} + +function _isEntirelyVisible( + top: number, + bottom: number, + viewportHeight: number, +): boolean { + return top >= 0 && bottom <= viewportHeight && bottom > top; +} module.exports = ViewabilityHelper; diff --git a/Libraries/Lists/VirtualizeUtils.js b/Libraries/Lists/VirtualizeUtils.js index 535d25b3abc..3a70d9f6830 100644 --- a/Libraries/Lists/VirtualizeUtils.js +++ b/Libraries/Lists/VirtualizeUtils.js @@ -10,9 +10,249 @@ 'use strict'; -import {typeof keyExtractor as KeyExtractorType} from '@react-native/virtualized-lists'; +import type {FrameMetricProps} from './VirtualizedListProps'; -const keyExtractor: KeyExtractorType = - require('@react-native/virtualized-lists').keyExtractor; +/** + * Used to find the indices of the frames that overlap the given offsets. Useful for finding the + * items that bound different windows of content, such as the visible area or the buffered overscan + * area. + */ +export function elementsThatOverlapOffsets( + offsets: Array, + props: FrameMetricProps, + getFrameMetrics: ( + index: number, + props: FrameMetricProps, + ) => { + length: number, + offset: number, + ... + }, + zoomScale: number = 1, +): Array { + const itemCount = props.getItemCount(props.data); + const result = []; + for (let offsetIndex = 0; offsetIndex < offsets.length; offsetIndex++) { + const currentOffset = offsets[offsetIndex]; + let left = 0; + let right = itemCount - 1; -module.exports = {keyExtractor}; + while (left <= right) { + // eslint-disable-next-line no-bitwise + const mid = left + ((right - left) >>> 1); + const frame = getFrameMetrics(mid, props); + const scaledOffsetStart = frame.offset * zoomScale; + const scaledOffsetEnd = (frame.offset + frame.length) * zoomScale; + + // We want the first frame that contains the offset, with inclusive bounds. Thus, for the + // first frame the scaledOffsetStart is inclusive, while for other frames it is exclusive. + if ( + (mid === 0 && currentOffset < scaledOffsetStart) || + (mid !== 0 && currentOffset <= scaledOffsetStart) + ) { + right = mid - 1; + } else if (currentOffset > scaledOffsetEnd) { + left = mid + 1; + } else { + result[offsetIndex] = mid; + break; + } + } + } + + return result; +} + +/** + * Computes the number of elements in the `next` range that are new compared to the `prev` range. + * Handy for calculating how many new items will be rendered when the render window changes so we + * can restrict the number of new items render at once so that content can appear on the screen + * faster. + */ +export function newRangeCount( + prev: { + first: number, + last: number, + ... + }, + next: { + first: number, + last: number, + ... + }, +): number { + return ( + next.last - + next.first + + 1 - + Math.max( + 0, + 1 + Math.min(next.last, prev.last) - Math.max(next.first, prev.first), + ) + ); +} + +/** + * Custom logic for determining which items should be rendered given the current frame and scroll + * metrics, as well as the previous render state. The algorithm may evolve over time, but generally + * prioritizes the visible area first, then expands that with overscan regions ahead and behind, + * biased in the direction of scroll. + */ +export function computeWindowedRenderLimits( + props: FrameMetricProps, + maxToRenderPerBatch: number, + windowSize: number, + prev: { + first: number, + last: number, + }, + getFrameMetricsApprox: ( + index: number, + props: FrameMetricProps, + ) => { + length: number, + offset: number, + ... + }, + scrollMetrics: { + dt: number, + offset: number, + velocity: number, + visibleLength: number, + zoomScale: number, + ... + }, +): { + first: number, + last: number, +} { + const itemCount = props.getItemCount(props.data); + if (itemCount === 0) { + return {first: 0, last: -1}; + } + const {offset, velocity, visibleLength, zoomScale = 1} = scrollMetrics; + + // Start with visible area, then compute maximum overscan region by expanding from there, biased + // in the direction of scroll. Total overscan area is capped, which should cap memory consumption + // too. + const visibleBegin = Math.max(0, offset); + const visibleEnd = visibleBegin + visibleLength; + const overscanLength = (windowSize - 1) * visibleLength; + + // Considering velocity seems to introduce more churn than it's worth. + const leadFactor = 0.5; // Math.max(0, Math.min(1, velocity / 25 + 0.5)); + + const fillPreference = + velocity > 1 ? 'after' : velocity < -1 ? 'before' : 'none'; + + const overscanBegin = Math.max( + 0, + visibleBegin - (1 - leadFactor) * overscanLength, + ); + const overscanEnd = Math.max(0, visibleEnd + leadFactor * overscanLength); + + const lastItemOffset = + getFrameMetricsApprox(itemCount - 1, props).offset * zoomScale; + if (lastItemOffset < overscanBegin) { + // Entire list is before our overscan window + return { + first: Math.max(0, itemCount - 1 - maxToRenderPerBatch), + last: itemCount - 1, + }; + } + + // Find the indices that correspond to the items at the render boundaries we're targeting. + let [overscanFirst, first, last, overscanLast] = elementsThatOverlapOffsets( + [overscanBegin, visibleBegin, visibleEnd, overscanEnd], + props, + getFrameMetricsApprox, + zoomScale, + ); + overscanFirst = overscanFirst == null ? 0 : overscanFirst; + first = first == null ? Math.max(0, overscanFirst) : first; + overscanLast = overscanLast == null ? itemCount - 1 : overscanLast; + last = + last == null + ? Math.min(overscanLast, first + maxToRenderPerBatch - 1) + : last; + const visible = {first, last}; + + // We want to limit the number of new cells we're rendering per batch so that we can fill the + // content on the screen quickly. If we rendered the entire overscan window at once, the user + // could be staring at white space for a long time waiting for a bunch of offscreen content to + // render. + let newCellCount = newRangeCount(prev, visible); + + while (true) { + if (first <= overscanFirst && last >= overscanLast) { + // If we fill the entire overscan range, we're done. + break; + } + const maxNewCells = newCellCount >= maxToRenderPerBatch; + const firstWillAddMore = first <= prev.first || first > prev.last; + const firstShouldIncrement = + first > overscanFirst && (!maxNewCells || !firstWillAddMore); + const lastWillAddMore = last >= prev.last || last < prev.first; + const lastShouldIncrement = + last < overscanLast && (!maxNewCells || !lastWillAddMore); + if (maxNewCells && !firstShouldIncrement && !lastShouldIncrement) { + // We only want to stop if we've hit maxNewCells AND we cannot increment first or last + // without rendering new items. This let's us preserve as many already rendered items as + // possible, reducing render churn and keeping the rendered overscan range as large as + // possible. + break; + } + if ( + firstShouldIncrement && + !(fillPreference === 'after' && lastShouldIncrement && lastWillAddMore) + ) { + if (firstWillAddMore) { + newCellCount++; + } + first--; + } + if ( + lastShouldIncrement && + !(fillPreference === 'before' && firstShouldIncrement && firstWillAddMore) + ) { + if (lastWillAddMore) { + newCellCount++; + } + last++; + } + } + if ( + !( + last >= first && + first >= 0 && + last < itemCount && + first >= overscanFirst && + last <= overscanLast && + first <= visible.first && + last >= visible.last + ) + ) { + throw new Error( + 'Bad window calculation ' + + JSON.stringify({ + first, + last, + itemCount, + overscanFirst, + overscanLast, + visible, + }), + ); + } + return {first, last}; +} + +export function keyExtractor(item: any, index: number): string { + if (typeof item === 'object' && item?.key != null) { + return item.key; + } + if (typeof item === 'object' && item?.id != null) { + return item.id; + } + return String(index); +} diff --git a/packages/virtualized-lists/Lists/VirtualizedList.d.ts b/Libraries/Lists/VirtualizedList.d.ts similarity index 97% rename from packages/virtualized-lists/Lists/VirtualizedList.d.ts rename to Libraries/Lists/VirtualizedList.d.ts index a2702d9101e..d874dab4d10 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.d.ts +++ b/Libraries/Lists/VirtualizedList.d.ts @@ -8,15 +8,15 @@ */ import type * as React from 'react'; +import type {LayoutChangeEvent} from '../../types'; +import {StyleProp} from '../StyleSheet/StyleSheet'; +import {ViewStyle} from '../StyleSheet/StyleSheetTypes'; import type { - StyleProp, - ViewStyle, - ScrollViewProps, - LayoutChangeEvent, - View, ScrollResponderMixin, ScrollView, -} from 'react-native'; + ScrollViewProps, +} from '../Components/ScrollView/ScrollView'; +import type {View} from '../Components/View/View'; export interface ViewToken { item: any; diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index 2488b1e5e37..efce2bed49b 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -8,16 +8,1945 @@ * @format */ -'use strict'; - -import {typeof VirtualizedList as VirtualizedListType} from '@react-native/virtualized-lists'; - -const VirtualizedList: VirtualizedListType = - require('@react-native/virtualized-lists').VirtualizedList; - -export type { +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 '@react-native/virtualized-lists'; -module.exports = VirtualizedList; +} 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_EDGE_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; +} + +// onStartReachedThresholdOrDefault(this.props.onStartReachedThreshold) +function onStartReachedThresholdOrDefault(onStartReachedThreshold: ?number) { + return onStartReachedThreshold ?? 2; +} + +// onEndReachedThresholdOrDefault(this.props.onEndReachedThreshold) +function onEndReachedThresholdOrDefault(onEndReachedThreshold: ?number) { + return onEndReachedThreshold ?? 2; +} + +// getScrollingThreshold(visibleLength, onEndReachedThreshold) +function getScrollingThreshold(threshold: number, visibleLength: number) { + return (threshold * visibleLength) / 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; + if (veryLast < 0) { + return; + } + 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, + viewOffset?: number, + 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 => { + this._nestedChildLists.add(childList.ref, childList.cellKey); + if (this._hasInteracted) { + childList.ref.recordInteraction(); + } + }; + + _unregisterAsNestedChild = (childList: { + ref: React.ElementRef, + }): void => { + this._nestedChildLists.remove(childList.ref); + }; + + 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__) { + // $FlowFixMe[underconstrained-implicit-instantiation] + 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: Array = []; + 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, { + onLayout: (event: LayoutEvent) => { + 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; + _sentStartForContentLength = 0; + _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._maybeCallOnEdgeReached(); + }; + + _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; + } + + _maybeCallOnEdgeReached() { + const { + data, + getItemCount, + onStartReached, + onStartReachedThreshold, + onEndReached, + onEndReachedThreshold, + initialScrollIndex, + } = this.props; + const {contentLength, visibleLength, offset} = this._scrollMetrics; + let distanceFromStart = offset; + let distanceFromEnd = contentLength - visibleLength - offset; + + // Especially when oERT is zero it's necessary to 'floor' very small distance 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 edge of the list with a distance approximating 0 but not quite there. + if (distanceFromStart < ON_EDGE_REACHED_EPSILON) { + distanceFromStart = 0; + } + if (distanceFromEnd < ON_EDGE_REACHED_EPSILON) { + distanceFromEnd = 0; + } + + // TODO: T121172172 Look into why we're "defaulting" to a threshold of 2px + // when oERT is not present (different from 2 viewports used elsewhere) + const DEFAULT_THRESHOLD_PX = 2; + + const startThreshold = + onStartReachedThreshold != null + ? onStartReachedThreshold * visibleLength + : DEFAULT_THRESHOLD_PX; + const endThreshold = + onEndReachedThreshold != null + ? onEndReachedThreshold * visibleLength + : DEFAULT_THRESHOLD_PX; + const isWithinStartThreshold = distanceFromStart <= startThreshold; + const isWithinEndThreshold = distanceFromEnd <= endThreshold; + + // First check if the user just scrolled within the end threshold + // and call onEndReached only once for a given content length, + // and only if onStartReached is not being executed + if ( + onEndReached && + this.state.cellsAroundViewport.last === getItemCount(data) - 1 && + isWithinEndThreshold && + this._scrollMetrics.contentLength !== this._sentEndForContentLength + ) { + this._sentEndForContentLength = this._scrollMetrics.contentLength; + onEndReached({distanceFromEnd}); + } + + // Next check if the user just scrolled within the start threshold + // and call onStartReached only once for a given content length, + // and only if onEndReached is not being executed + else if ( + onStartReached != null && + this.state.cellsAroundViewport.first === 0 && + isWithinStartThreshold && + this._scrollMetrics.contentLength !== this._sentStartForContentLength + ) { + // On initial mount when using initialScrollIndex the offset will be 0 initially + // and will trigger an unexpected onStartReached. To avoid this we can use + // timestamp to differentiate between the initial scroll metrics and when we actually + // received the first scroll event. + if (!initialScrollIndex || this._scrollMetrics.timestamp !== 0) { + this._sentStartForContentLength = this._scrollMetrics.contentLength; + onStartReached({distanceFromStart}); + } + } + + // If the user scrolls away from the start or end and back again, + // cause onStartReached or onEndReached to be triggered again + else { + this._sentStartForContentLength = isWithinStartThreshold + ? this._sentStartForContentLength + : 0; + this._sentEndForContentLength = isWithinEndThreshold + ? 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._maybeCallOnEdgeReached(); + }; + + /* 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._maybeCallOnEdgeReached(); + 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 onStartReachedThreshold = onStartReachedThresholdOrDefault( + this.props.onStartReachedThreshold, + ); + const onEndReachedThreshold = onEndReachedThresholdOrDefault( + this.props.onEndReachedThreshold, + ); + // 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 = + distTop < 0 || + (velocity < -2 && + distTop < + getScrollingThreshold(onStartReachedThreshold, visibleLength)); + } + // 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 (!hiPri && last >= 0 && last < itemCount - 1) { + const distBottom = + this.__getFrameMetricsApprox(last, this.props).offset - + (offset + visibleLength); + hiPri = + distBottom < 0 || + (velocity > 2 && + distBottom < + getScrollingThreshold(onEndReachedThreshold, visibleLength)); + } + // 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 = 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, + ); + }); + } +} + +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/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js b/Libraries/Lists/VirtualizedListCellRenderer.js similarity index 96% rename from packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js rename to Libraries/Lists/VirtualizedListCellRenderer.js index 0064f788b8a..b16900d63b7 100644 --- a/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js +++ b/Libraries/Lists/VirtualizedListCellRenderer.js @@ -8,15 +8,13 @@ * @format */ -import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet'; -import type { - FocusEvent, - LayoutEvent, -} from 'react-native/Libraries/Types/CoreEventTypes'; +import type {ViewStyleProp} from '../StyleSheet/StyleSheet'; +import type {FocusEvent, LayoutEvent} from '../Types/CoreEventTypes'; import type FillRateHelper from './FillRateHelper'; import type {RenderItemType} from './VirtualizedListProps'; -import {View, StyleSheet} from 'react-native'; +import View from '../Components/View/View'; +import StyleSheet from '../StyleSheet/StyleSheet'; import {VirtualizedListCellContextProvider} from './VirtualizedListContext.js'; import invariant from 'invariant'; import * as React from 'react'; diff --git a/Libraries/Lists/VirtualizedListContext.js b/Libraries/Lists/VirtualizedListContext.js index 5686ccf3722..bca5724498a 100644 --- a/Libraries/Lists/VirtualizedListContext.js +++ b/Libraries/Lists/VirtualizedListContext.js @@ -4,15 +4,113 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @flow + * @flow strict-local * @format */ -'use strict'; +import typeof VirtualizedList from './VirtualizedList'; -import {typeof VirtualizedListContextResetter as VirtualizedListContextResetterType} from '@react-native/virtualized-lists'; +import * as React from 'react'; +import {useContext, useMemo} from 'react'; -const VirtualizedListContextResetter: VirtualizedListContextResetterType = - require('@react-native/virtualized-lists').VirtualizedListContextResetter; +type Context = $ReadOnly<{ + cellKey: ?string, + getScrollMetrics: () => { + contentLength: number, + dOffset: number, + dt: number, + offset: number, + timestamp: number, + velocity: number, + visibleLength: number, + zoomScale: number, + }, + horizontal: ?boolean, + getOutermostParentListRef: () => React.ElementRef, + registerAsNestedChild: ({ + cellKey: string, + ref: React.ElementRef, + }) => void, + unregisterAsNestedChild: ({ + ref: React.ElementRef, + }) => void, +}>; -module.exports = {VirtualizedListContextResetter}; +export const VirtualizedListContext: React.Context = + React.createContext(null); +if (__DEV__) { + VirtualizedListContext.displayName = 'VirtualizedListContext'; +} + +/** + * Resets the context. Intended for use by portal-like components (e.g. Modal). + */ +export function VirtualizedListContextResetter({ + children, +}: { + children: React.Node, +}): React.Node { + return ( + + {children} + + ); +} + +/** + * Sets the context with memoization. Intended to be used by `VirtualizedList`. + */ +export function VirtualizedListContextProvider({ + children, + value, +}: { + children: React.Node, + value: Context, +}): React.Node { + // Avoid setting a newly created context object if the values are identical. + const context = useMemo( + () => ({ + cellKey: null, + getScrollMetrics: value.getScrollMetrics, + horizontal: value.horizontal, + getOutermostParentListRef: value.getOutermostParentListRef, + registerAsNestedChild: value.registerAsNestedChild, + unregisterAsNestedChild: value.unregisterAsNestedChild, + }), + [ + value.getScrollMetrics, + value.horizontal, + value.getOutermostParentListRef, + value.registerAsNestedChild, + value.unregisterAsNestedChild, + ], + ); + return ( + + {children} + + ); +} + +/** + * Sets the `cellKey`. Intended to be used by `VirtualizedList` for each cell. + */ +export function VirtualizedListCellContextProvider({ + cellKey, + children, +}: { + cellKey: string, + children: React.Node, +}): React.Node { + // Avoid setting a newly created context object if the values are identical. + const currContext = useContext(VirtualizedListContext); + const context = useMemo( + () => (currContext == null ? null : {...currContext, cellKey}), + [currContext, cellKey], + ); + return ( + + {children} + + ); +} diff --git a/packages/virtualized-lists/Lists/VirtualizedListProps.js b/Libraries/Lists/VirtualizedListProps.js similarity index 98% rename from packages/virtualized-lists/Lists/VirtualizedListProps.js rename to Libraries/Lists/VirtualizedListProps.js index bfc59673270..f4d497b1d46 100644 --- a/packages/virtualized-lists/Lists/VirtualizedListProps.js +++ b/Libraries/Lists/VirtualizedListProps.js @@ -8,8 +8,8 @@ * @format */ -import {typeof ScrollView} from 'react-native'; -import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet'; +import typeof ScrollView from '../Components/ScrollView/ScrollView'; +import type {ViewStyleProp} from '../StyleSheet/StyleSheet'; import type { ViewabilityConfig, ViewabilityConfigCallbackPair, diff --git a/Libraries/Lists/VirtualizedSectionList.js b/Libraries/Lists/VirtualizedSectionList.js index 242dfe34c6b..e4bf2fe58a0 100644 --- a/Libraries/Lists/VirtualizedSectionList.js +++ b/Libraries/Lists/VirtualizedSectionList.js @@ -8,15 +8,610 @@ * @format */ -'use strict'; +import type {ViewToken} from './ViewabilityHelper'; -import {typeof VirtualizedSectionList as VirtualizedSectionListType} from '@react-native/virtualized-lists'; +import View from '../Components/View/View'; +import VirtualizedList from './VirtualizedList'; +import {keyExtractor as defaultKeyExtractor} from './VirtualizeUtils'; +import invariant from 'invariant'; +import * as React from 'react'; -const VirtualizedSectionList: VirtualizedSectionListType = - require('@react-native/virtualized-lists').VirtualizedSectionList; +type Item = any; -export type { - SectionBase, - ScrollToLocationParamsType, -} from '@react-native/virtualized-lists'; -module.exports = VirtualizedSectionList; +export type SectionBase = { + /** + * The data for rendering items in this section. + */ + data: $ReadOnlyArray, + /** + * Optional key to keep track of section re-ordering. If you don't plan on re-ordering sections, + * the array index will be used by default. + */ + key?: string, + // Optional props will override list-wide props just for this section. + renderItem?: ?(info: { + item: SectionItemT, + index: number, + section: SectionBase, + separators: { + highlight: () => void, + unhighlight: () => void, + updateProps: (select: 'leading' | 'trailing', newProps: Object) => void, + ... + }, + ... + }) => null | React.Element, + ItemSeparatorComponent?: ?React.ComponentType, + keyExtractor?: (item: SectionItemT, index?: ?number) => string, + ... +}; + +type RequiredProps> = {| + sections: $ReadOnlyArray, +|}; + +type OptionalProps> = {| + /** + * Default renderer for every item in every section. + */ + renderItem?: (info: { + item: Item, + index: number, + section: SectionT, + separators: { + highlight: () => void, + unhighlight: () => void, + updateProps: (select: 'leading' | 'trailing', newProps: Object) => void, + ... + }, + ... + }) => null | React.Element, + /** + * Rendered at the top of each section. These stick to the top of the `ScrollView` by default on + * iOS. See `stickySectionHeadersEnabled`. + */ + renderSectionHeader?: ?(info: { + section: SectionT, + ... + }) => null | React.Element, + /** + * Rendered at the bottom of each section. + */ + renderSectionFooter?: ?(info: { + section: SectionT, + ... + }) => null | React.Element, + /** + * Rendered at the top and bottom of each section (note this is different from + * `ItemSeparatorComponent` which is only rendered between items). These are intended to separate + * sections from the headers above and below and typically have the same highlight response as + * `ItemSeparatorComponent`. Also receives `highlighted`, `[leading/trailing][Item/Separator]`, + * and any custom props from `separators.updateProps`. + */ + SectionSeparatorComponent?: ?React.ComponentType, + /** + * Makes section headers stick to the top of the screen until the next one pushes it off. Only + * enabled by default on iOS because that is the platform standard there. + */ + stickySectionHeadersEnabled?: boolean, + onEndReached?: ?({distanceFromEnd: number, ...}) => void, +|}; + +type VirtualizedListProps = React.ElementConfig; + +export type Props = {| + ...RequiredProps, + ...OptionalProps, + ...$Diff< + VirtualizedListProps, + { + renderItem: $PropertyType, + data: $PropertyType, + ... + }, + >, +|}; +export type ScrollToLocationParamsType = {| + animated?: ?boolean, + itemIndex: number, + sectionIndex: number, + viewOffset?: number, + viewPosition?: number, +|}; + +type State = {childProps: VirtualizedListProps, ...}; + +/** + * Right now this just flattens everything into one list and uses VirtualizedList under the + * hood. The only operation that might not scale well is concatting the data arrays of all the + * sections when new props are received, which should be plenty fast for up to ~10,000 items. + */ +class VirtualizedSectionList< + SectionT: SectionBase, +> extends React.PureComponent, State> { + scrollToLocation(params: ScrollToLocationParamsType) { + let index = params.itemIndex; + for (let i = 0; i < params.sectionIndex; i++) { + index += this.props.getItemCount(this.props.sections[i].data) + 2; + } + let viewOffset = params.viewOffset || 0; + if (this._listRef == null) { + return; + } + if (params.itemIndex > 0 && this.props.stickySectionHeadersEnabled) { + const frame = this._listRef.__getFrameMetricsApprox( + index - params.itemIndex, + this._listRef.props, + ); + viewOffset += frame.length; + } + const toIndexParams = { + ...params, + viewOffset, + index, + }; + // $FlowFixMe[incompatible-use] + this._listRef.scrollToIndex(toIndexParams); + } + + getListRef(): ?React.ElementRef { + return this._listRef; + } + + render(): React.Node { + const { + ItemSeparatorComponent, // don't pass through, rendered with renderItem + SectionSeparatorComponent, + renderItem: _renderItem, + renderSectionFooter, + renderSectionHeader, + sections: _sections, + stickySectionHeadersEnabled, + ...passThroughProps + } = this.props; + + const listHeaderOffset = this.props.ListHeaderComponent ? 1 : 0; + + const stickyHeaderIndices = this.props.stickySectionHeadersEnabled + ? ([]: Array) + : undefined; + + let itemCount = 0; + for (const section of this.props.sections) { + // Track the section header indices + if (stickyHeaderIndices != null) { + stickyHeaderIndices.push(itemCount + listHeaderOffset); + } + + // Add two for the section header and footer. + itemCount += 2; + itemCount += this.props.getItemCount(section.data); + } + const renderItem = this._renderItem(itemCount); + + return ( + + this._getItem(this.props, sections, index) + } + getItemCount={() => itemCount} + onViewableItemsChanged={ + this.props.onViewableItemsChanged + ? this._onViewableItemsChanged + : undefined + } + ref={this._captureRef} + /> + ); + } + + _getItem( + props: Props, + sections: ?$ReadOnlyArray, + index: number, + ): ?Item { + if (!sections) { + return null; + } + let itemIdx = index - 1; + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + const sectionData = section.data; + const itemCount = props.getItemCount(sectionData); + if (itemIdx === -1 || itemIdx === itemCount) { + // We intend for there to be overflow by one on both ends of the list. + // This will be for headers and footers. When returning a header or footer + // item the section itself is the item. + return section; + } else if (itemIdx < itemCount) { + // If we are in the bounds of the list's data then return the item. + return props.getItem(sectionData, itemIdx); + } else { + itemIdx -= itemCount + 2; // Add two for the header and footer + } + } + return null; + } + + // $FlowFixMe[missing-local-annot] + _keyExtractor = (item: Item, index: number) => { + const info = this._subExtractor(index); + return (info && info.key) || String(index); + }; + + _subExtractor(index: number): ?{ + section: SectionT, + // Key of the section or combined key for section + item + key: string, + // Relative index within the section + index: ?number, + // True if this is the section header + header?: ?boolean, + leadingItem?: ?Item, + leadingSection?: ?SectionT, + trailingItem?: ?Item, + trailingSection?: ?SectionT, + ... + } { + let itemIndex = index; + const {getItem, getItemCount, keyExtractor, sections} = this.props; + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + const sectionData = section.data; + const key = section.key || String(i); + itemIndex -= 1; // The section adds an item for the header + if (itemIndex >= getItemCount(sectionData) + 1) { + itemIndex -= getItemCount(sectionData) + 1; // The section adds an item for the footer. + } else if (itemIndex === -1) { + return { + section, + key: key + ':header', + index: null, + header: true, + trailingSection: sections[i + 1], + }; + } else if (itemIndex === getItemCount(sectionData)) { + return { + section, + key: key + ':footer', + index: null, + header: false, + trailingSection: sections[i + 1], + }; + } else { + const extractor = + section.keyExtractor || keyExtractor || defaultKeyExtractor; + return { + section, + key: + key + ':' + extractor(getItem(sectionData, itemIndex), itemIndex), + index: itemIndex, + leadingItem: getItem(sectionData, itemIndex - 1), + leadingSection: sections[i - 1], + trailingItem: getItem(sectionData, itemIndex + 1), + trailingSection: sections[i + 1], + }; + } + } + } + + _convertViewable = (viewable: ViewToken): ?ViewToken => { + invariant(viewable.index != null, 'Received a broken ViewToken'); + const info = this._subExtractor(viewable.index); + if (!info) { + return null; + } + const keyExtractorWithNullableIndex = info.section.keyExtractor; + const keyExtractorWithNonNullableIndex = + this.props.keyExtractor || defaultKeyExtractor; + const key = + keyExtractorWithNullableIndex != null + ? keyExtractorWithNullableIndex(viewable.item, info.index) + : keyExtractorWithNonNullableIndex(viewable.item, info.index ?? 0); + + return { + ...viewable, + index: info.index, + key, + section: info.section, + }; + }; + + _onViewableItemsChanged = ({ + viewableItems, + changed, + }: { + viewableItems: Array, + changed: Array, + ... + }) => { + const onViewableItemsChanged = this.props.onViewableItemsChanged; + if (onViewableItemsChanged != null) { + onViewableItemsChanged({ + viewableItems: viewableItems + .map(this._convertViewable, this) + .filter(Boolean), + changed: changed.map(this._convertViewable, this).filter(Boolean), + }); + } + }; + + _renderItem = + (listItemCount: number): $FlowFixMe => + // eslint-disable-next-line react/no-unstable-nested-components + ({item, index}: {item: Item, index: number, ...}) => { + const info = this._subExtractor(index); + if (!info) { + return null; + } + const infoIndex = info.index; + if (infoIndex == null) { + const {section} = info; + if (info.header === true) { + const {renderSectionHeader} = this.props; + return renderSectionHeader ? renderSectionHeader({section}) : null; + } else { + const {renderSectionFooter} = this.props; + return renderSectionFooter ? renderSectionFooter({section}) : null; + } + } else { + const renderItem = info.section.renderItem || this.props.renderItem; + const SeparatorComponent = this._getSeparatorComponent( + index, + info, + listItemCount, + ); + invariant(renderItem, 'no renderItem!'); + return ( + + ); + } + }; + + _updatePropsFor = (cellKey: string, value: any) => { + const updateProps = this._updatePropsMap[cellKey]; + if (updateProps != null) { + updateProps(value); + } + }; + + _updateHighlightFor = (cellKey: string, value: boolean) => { + const updateHighlight = this._updateHighlightMap[cellKey]; + if (updateHighlight != null) { + updateHighlight(value); + } + }; + + _setUpdateHighlightFor = ( + cellKey: string, + updateHighlightFn: ?(boolean) => void, + ) => { + if (updateHighlightFn != null) { + this._updateHighlightMap[cellKey] = updateHighlightFn; + } else { + // $FlowFixMe[prop-missing] + delete this._updateHighlightFor[cellKey]; + } + }; + + _setUpdatePropsFor = (cellKey: string, updatePropsFn: ?(boolean) => void) => { + if (updatePropsFn != null) { + this._updatePropsMap[cellKey] = updatePropsFn; + } else { + delete this._updatePropsMap[cellKey]; + } + }; + + _getSeparatorComponent( + index: number, + info?: ?Object, + listItemCount: number, + ): ?React.ComponentType { + info = info || this._subExtractor(index); + if (!info) { + return null; + } + const ItemSeparatorComponent = + info.section.ItemSeparatorComponent || this.props.ItemSeparatorComponent; + const {SectionSeparatorComponent} = this.props; + const isLastItemInList = index === listItemCount - 1; + const isLastItemInSection = + info.index === this.props.getItemCount(info.section.data) - 1; + if (SectionSeparatorComponent && isLastItemInSection) { + return SectionSeparatorComponent; + } + if (ItemSeparatorComponent && !isLastItemInSection && !isLastItemInList) { + return ItemSeparatorComponent; + } + return null; + } + + _updateHighlightMap: {[string]: (boolean) => void} = {}; + _updatePropsMap: {[string]: void | (boolean => void)} = {}; + _listRef: ?React.ElementRef; + _captureRef = (ref: null | React$ElementRef>) => { + this._listRef = ref; + }; +} + +type ItemWithSeparatorCommonProps = $ReadOnly<{| + leadingItem: ?Item, + leadingSection: ?Object, + section: Object, + trailingItem: ?Item, + trailingSection: ?Object, +|}>; + +type ItemWithSeparatorProps = $ReadOnly<{| + ...ItemWithSeparatorCommonProps, + LeadingSeparatorComponent: ?React.ComponentType, + SeparatorComponent: ?React.ComponentType, + cellKey: string, + index: number, + item: Item, + setSelfHighlightCallback: ( + cellKey: string, + updateFn: ?(boolean) => void, + ) => void, + setSelfUpdatePropsCallback: ( + cellKey: string, + updateFn: ?(boolean) => void, + ) => void, + prevCellKey?: ?string, + updateHighlightFor: (prevCellKey: string, value: boolean) => void, + updatePropsFor: (prevCellKey: string, value: Object) => void, + renderItem: Function, + inverted: boolean, +|}>; + +function ItemWithSeparator(props: ItemWithSeparatorProps): React.Node { + const { + LeadingSeparatorComponent, + // this is the trailing separator and is associated with this item + SeparatorComponent, + cellKey, + prevCellKey, + setSelfHighlightCallback, + updateHighlightFor, + setSelfUpdatePropsCallback, + updatePropsFor, + item, + index, + section, + inverted, + } = props; + + const [leadingSeparatorHiglighted, setLeadingSeparatorHighlighted] = + React.useState(false); + + const [separatorHighlighted, setSeparatorHighlighted] = React.useState(false); + + const [leadingSeparatorProps, setLeadingSeparatorProps] = React.useState({ + leadingItem: props.leadingItem, + leadingSection: props.leadingSection, + section: props.section, + trailingItem: props.item, + trailingSection: props.trailingSection, + }); + const [separatorProps, setSeparatorProps] = React.useState({ + leadingItem: props.item, + leadingSection: props.leadingSection, + section: props.section, + trailingItem: props.trailingItem, + trailingSection: props.trailingSection, + }); + + React.useEffect(() => { + setSelfHighlightCallback(cellKey, setSeparatorHighlighted); + // $FlowFixMe[incompatible-call] + setSelfUpdatePropsCallback(cellKey, setSeparatorProps); + + return () => { + setSelfUpdatePropsCallback(cellKey, null); + setSelfHighlightCallback(cellKey, null); + }; + }, [ + cellKey, + setSelfHighlightCallback, + setSeparatorProps, + setSelfUpdatePropsCallback, + ]); + + const separators = { + highlight: () => { + setLeadingSeparatorHighlighted(true); + setSeparatorHighlighted(true); + if (prevCellKey != null) { + updateHighlightFor(prevCellKey, true); + } + }, + unhighlight: () => { + setLeadingSeparatorHighlighted(false); + setSeparatorHighlighted(false); + if (prevCellKey != null) { + updateHighlightFor(prevCellKey, false); + } + }, + updateProps: ( + select: 'leading' | 'trailing', + newProps: $Shape, + ) => { + if (select === 'leading') { + if (LeadingSeparatorComponent != null) { + setLeadingSeparatorProps({...leadingSeparatorProps, ...newProps}); + } else if (prevCellKey != null) { + // update the previous item's separator + updatePropsFor(prevCellKey, {...leadingSeparatorProps, ...newProps}); + } + } else if (select === 'trailing' && SeparatorComponent != null) { + setSeparatorProps({...separatorProps, ...newProps}); + } + }, + }; + const element = props.renderItem({ + item, + index, + section, + separators, + }); + const leadingSeparator = LeadingSeparatorComponent != null && ( + + ); + const separator = SeparatorComponent != null && ( + + ); + return leadingSeparator || separator ? ( + + {inverted === false ? leadingSeparator : separator} + {element} + {inverted === false ? separator : leadingSeparator} + + ) : ( + element + ); +} + +/* $FlowFixMe[class-object-subtyping] added when improving typing for this + * parameters */ +// $FlowFixMe[method-unbinding] +module.exports = (VirtualizedSectionList: React.AbstractComponent< + React.ElementConfig, + $ReadOnly<{ + getListRef: () => ?React.ElementRef, + scrollToLocation: (params: ScrollToLocationParamsType) => void, + ... + }>, +>); diff --git a/packages/virtualized-lists/Lists/__tests__/CellRenderMask-test.js b/Libraries/Lists/__tests__/CellRenderMask-test.js similarity index 100% rename from packages/virtualized-lists/Lists/__tests__/CellRenderMask-test.js rename to Libraries/Lists/__tests__/CellRenderMask-test.js diff --git a/packages/virtualized-lists/Lists/__tests__/FillRateHelper-test.js b/Libraries/Lists/__tests__/FillRateHelper-test.js similarity index 100% rename from packages/virtualized-lists/Lists/__tests__/FillRateHelper-test.js rename to Libraries/Lists/__tests__/FillRateHelper-test.js diff --git a/packages/virtualized-lists/Lists/__tests__/ViewabilityHelper-test.js b/Libraries/Lists/__tests__/ViewabilityHelper-test.js similarity index 100% rename from packages/virtualized-lists/Lists/__tests__/ViewabilityHelper-test.js rename to Libraries/Lists/__tests__/ViewabilityHelper-test.js diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js b/Libraries/Lists/__tests__/VirtualizeUtils-test.js similarity index 100% rename from packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js rename to Libraries/Lists/__tests__/VirtualizeUtils-test.js diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js b/Libraries/Lists/__tests__/VirtualizedList-test.js similarity index 100% rename from packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js rename to Libraries/Lists/__tests__/VirtualizedList-test.js diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js b/Libraries/Lists/__tests__/VirtualizedSectionList-test.js similarity index 100% rename from packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js rename to Libraries/Lists/__tests__/VirtualizedSectionList-test.js diff --git a/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap b/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap similarity index 100% rename from packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap rename to Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap diff --git a/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedSectionList-test.js.snap b/Libraries/Lists/__tests__/__snapshots__/VirtualizedSectionList-test.js.snap similarity index 100% rename from packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedSectionList-test.js.snap rename to Libraries/Lists/__tests__/__snapshots__/VirtualizedSectionList-test.js.snap diff --git a/Libraries/Modal/Modal.js b/Libraries/Modal/Modal.js index 9750d2e5be3..46e9e714d29 100644 --- a/Libraries/Modal/Modal.js +++ b/Libraries/Modal/Modal.js @@ -13,11 +13,11 @@ import type {RootTag} from '../ReactNative/RootTag'; import type {DirectEventHandler} from '../Types/CodegenTypes'; import NativeEventEmitter from '../EventEmitter/NativeEventEmitter'; +import {VirtualizedListContextResetter} from '../Lists/VirtualizedListContext.js'; import {type EventSubscription} from '../vendor/emitter/EventEmitter'; import ModalInjection from './ModalInjection'; import NativeModalManager from './NativeModalManager'; import RCTModalHostView from './RCTModalHostViewNativeComponent'; -import {VirtualizedListContextResetter} from '@react-native/virtualized-lists'; const ScrollView = require('../Components/ScrollView/ScrollView'); const View = require('../Components/View/View'); diff --git a/Libraries/Utilities/ReactNativeTestTools.js b/Libraries/Utilities/ReactNativeTestTools.js index 86715750541..5dc2221528d 100644 --- a/Libraries/Utilities/ReactNativeTestTools.js +++ b/Libraries/Utilities/ReactNativeTestTools.js @@ -15,8 +15,8 @@ import type {ReactTestRenderer as ReactTestRendererType} from 'react-test-render const Switch = require('../Components/Switch/Switch').default; const TextInput = require('../Components/TextInput/TextInput'); const View = require('../Components/View/View'); +const VirtualizedList = require('../Lists/VirtualizedList').default; const Text = require('../Text/Text'); -const {VirtualizedList} = require('@react-native/virtualized-lists'); const React = require('react'); const ShallowRenderer = require('react-shallow-renderer'); const ReactTestRenderer = require('react-test-renderer'); diff --git a/packages/virtualized-lists/Utilities/__tests__/clamp-test.js b/Libraries/Utilities/__tests__/clamp-test.js similarity index 100% rename from packages/virtualized-lists/Utilities/__tests__/clamp-test.js rename to Libraries/Utilities/__tests__/clamp-test.js diff --git a/packages/virtualized-lists/Utilities/clamp.js b/Libraries/Utilities/clamp.js similarity index 100% rename from packages/virtualized-lists/Utilities/clamp.js rename to Libraries/Utilities/clamp.js diff --git a/index.js b/index.js index 7149c6463b5..0b6e97c0522 100644 --- a/index.js +++ b/index.js @@ -191,7 +191,7 @@ module.exports = { return require('./Libraries/Components/View/View'); }, get VirtualizedList(): VirtualizedList { - return require('./Libraries/Lists/VirtualizedList'); + return require('./Libraries/Lists/VirtualizedList').default; }, get VirtualizedSectionList(): VirtualizedSectionList { return require('./Libraries/Lists/VirtualizedSectionList'); diff --git a/package.json b/package.json index 225346a4a1d..109d1dd68e3 100644 --- a/package.json +++ b/package.json @@ -118,7 +118,6 @@ "@react-native/gradle-plugin": "^0.72.2", "@react-native/js-polyfills": "^0.72.0", "@react-native/normalize-colors": "^0.72.0", - "@react-native/virtualized-lists": "0.72.0", "abort-controller": "^3.0.0", "anser": "^1.4.9", "base64-js": "^1.1.2", diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js index 97f991265de..25c511e570a 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js @@ -12,7 +12,7 @@ import type {AnimatedComponentType} from 'react-native/Libraries/Animated/createAnimatedComponent'; import typeof FlatListType from 'react-native/Libraries/Lists/FlatList'; -import type {RenderItemProps} from 'react-native/Libraries/Lists/VirtualizedList'; +import type {RenderItemProps} from 'react-native/Libraries/Lists/VirtualizedListProps'; import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; import * as React from 'react'; diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-nested.js b/packages/rn-tester/js/examples/FlatList/FlatList-nested.js index cd1f96ded9d..bb53258da9c 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-nested.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-nested.js @@ -9,8 +9,8 @@ */ 'use strict'; -import type {ViewToken} from 'react-native/Libraries/Lists/ViewabilityHelper'; -import type {RenderItemProps} from 'react-native/Libraries/Lists/VirtualizedList'; +import type {ViewToken} from '../../../../../Libraries/Lists/ViewabilityHelper'; +import type {RenderItemProps} from '../../../../../Libraries/Lists/VirtualizedListProps'; import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; import RNTesterPage from '../../components/RNTesterPage'; diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-onViewableItemsChanged.js b/packages/rn-tester/js/examples/FlatList/FlatList-onViewableItemsChanged.js index f65111e1659..76e29fac105 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-onViewableItemsChanged.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-onViewableItemsChanged.js @@ -10,7 +10,7 @@ 'use strict'; -import type {ViewToken} from 'react-native/Libraries/Lists/ViewabilityHelper'; +import type {ViewToken} from '../../../../../Libraries/Lists/ViewabilityHelper'; import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; import BaseFlatListExample from './BaseFlatListExample'; diff --git a/packages/virtualized-lists/Lists/FillRateHelper.js b/packages/virtualized-lists/Lists/FillRateHelper.js deleted file mode 100644 index 87482e73f6b..00000000000 --- a/packages/virtualized-lists/Lists/FillRateHelper.js +++ /dev/null @@ -1,253 +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 - */ - -'use strict'; - -import type {FrameMetricProps} from './VirtualizedListProps'; - -export type FillRateInfo = Info; - -class Info { - any_blank_count: number = 0; - any_blank_ms: number = 0; - any_blank_speed_sum: number = 0; - mostly_blank_count: number = 0; - mostly_blank_ms: number = 0; - pixels_blank: number = 0; - pixels_sampled: number = 0; - pixels_scrolled: number = 0; - total_time_spent: number = 0; - sample_count: number = 0; -} - -type FrameMetrics = { - inLayout?: boolean, - length: number, - offset: number, - ... -}; - -const DEBUG = false; - -let _listeners: Array<(Info) => void> = []; -let _minSampleCount = 10; -let _sampleRate = DEBUG ? 1 : null; - -/** - * A helper class for detecting when the maximem fill rate of `VirtualizedList` is exceeded. - * By default the sampling rate is set to zero and this will do nothing. If you want to collect - * samples (e.g. to log them), make sure to call `FillRateHelper.setSampleRate(0.0-1.0)`. - * - * Listeners and sample rate are global for all `VirtualizedList`s - typical usage will combine with - * `SceneTracker.getActiveScene` to determine the context of the events. - */ -class FillRateHelper { - _anyBlankStartTime: ?number = null; - _enabled = false; - _getFrameMetrics: (index: number, props: FrameMetricProps) => ?FrameMetrics; - _info: Info = new Info(); - _mostlyBlankStartTime: ?number = null; - _samplesStartTime: ?number = null; - - static addListener(callback: FillRateInfo => void): { - remove: () => void, - ... - } { - if (_sampleRate === null) { - console.warn('Call `FillRateHelper.setSampleRate` before `addListener`.'); - } - _listeners.push(callback); - return { - remove: () => { - _listeners = _listeners.filter(listener => callback !== listener); - }, - }; - } - - static setSampleRate(sampleRate: number) { - _sampleRate = sampleRate; - } - - static setMinSampleCount(minSampleCount: number) { - _minSampleCount = minSampleCount; - } - - constructor( - getFrameMetrics: (index: number, props: FrameMetricProps) => ?FrameMetrics, - ) { - this._getFrameMetrics = getFrameMetrics; - this._enabled = (_sampleRate || 0) > Math.random(); - this._resetData(); - } - - activate() { - if (this._enabled && this._samplesStartTime == null) { - DEBUG && console.debug('FillRateHelper: activate'); - this._samplesStartTime = global.performance.now(); - } - } - - deactivateAndFlush() { - if (!this._enabled) { - return; - } - const start = this._samplesStartTime; // const for flow - if (start == null) { - DEBUG && - console.debug('FillRateHelper: bail on deactivate with no start time'); - return; - } - if (this._info.sample_count < _minSampleCount) { - // Don't bother with under-sampled events. - this._resetData(); - return; - } - const total_time_spent = global.performance.now() - start; - const info: any = { - ...this._info, - total_time_spent, - }; - if (DEBUG) { - const derived = { - avg_blankness: this._info.pixels_blank / this._info.pixels_sampled, - avg_speed: this._info.pixels_scrolled / (total_time_spent / 1000), - avg_speed_when_any_blank: - this._info.any_blank_speed_sum / this._info.any_blank_count, - any_blank_per_min: - this._info.any_blank_count / (total_time_spent / 1000 / 60), - any_blank_time_frac: this._info.any_blank_ms / total_time_spent, - mostly_blank_per_min: - this._info.mostly_blank_count / (total_time_spent / 1000 / 60), - mostly_blank_time_frac: this._info.mostly_blank_ms / total_time_spent, - }; - for (const key in derived) { - // $FlowFixMe[prop-missing] - derived[key] = Math.round(1000 * derived[key]) / 1000; - } - console.debug('FillRateHelper deactivateAndFlush: ', {derived, info}); - } - _listeners.forEach(listener => listener(info)); - this._resetData(); - } - - computeBlankness( - props: { - ...FrameMetricProps, - initialNumToRender?: ?number, - ... - }, - cellsAroundViewport: { - first: number, - last: number, - ... - }, - scrollMetrics: { - dOffset: number, - offset: number, - velocity: number, - visibleLength: number, - ... - }, - ): number { - if ( - !this._enabled || - props.getItemCount(props.data) === 0 || - cellsAroundViewport.last < cellsAroundViewport.first || - this._samplesStartTime == null - ) { - return 0; - } - const {dOffset, offset, velocity, visibleLength} = scrollMetrics; - - // Denominator metrics that we track for all events - most of the time there is no blankness and - // we want to capture that. - this._info.sample_count++; - this._info.pixels_sampled += Math.round(visibleLength); - this._info.pixels_scrolled += Math.round(Math.abs(dOffset)); - const scrollSpeed = Math.round(Math.abs(velocity) * 1000); // px / sec - - // Whether blank now or not, record the elapsed time blank if we were blank last time. - const now = global.performance.now(); - if (this._anyBlankStartTime != null) { - this._info.any_blank_ms += now - this._anyBlankStartTime; - } - this._anyBlankStartTime = null; - if (this._mostlyBlankStartTime != null) { - this._info.mostly_blank_ms += now - this._mostlyBlankStartTime; - } - this._mostlyBlankStartTime = null; - - let blankTop = 0; - let first = cellsAroundViewport.first; - let firstFrame = this._getFrameMetrics(first, props); - while ( - first <= cellsAroundViewport.last && - (!firstFrame || !firstFrame.inLayout) - ) { - firstFrame = this._getFrameMetrics(first, props); - first++; - } - // Only count blankTop if we aren't rendering the first item, otherwise we will count the header - // as blank. - if (firstFrame && first > 0) { - blankTop = Math.min( - visibleLength, - Math.max(0, firstFrame.offset - offset), - ); - } - let blankBottom = 0; - let last = cellsAroundViewport.last; - let lastFrame = this._getFrameMetrics(last, props); - while ( - last >= cellsAroundViewport.first && - (!lastFrame || !lastFrame.inLayout) - ) { - lastFrame = this._getFrameMetrics(last, props); - last--; - } - // Only count blankBottom if we aren't rendering the last item, otherwise we will count the - // footer as blank. - if (lastFrame && last < props.getItemCount(props.data) - 1) { - const bottomEdge = lastFrame.offset + lastFrame.length; - blankBottom = Math.min( - visibleLength, - Math.max(0, offset + visibleLength - bottomEdge), - ); - } - const pixels_blank = Math.round(blankTop + blankBottom); - const blankness = pixels_blank / visibleLength; - if (blankness > 0) { - this._anyBlankStartTime = now; - this._info.any_blank_speed_sum += scrollSpeed; - this._info.any_blank_count++; - this._info.pixels_blank += pixels_blank; - if (blankness > 0.5) { - this._mostlyBlankStartTime = now; - this._info.mostly_blank_count++; - } - } else if (scrollSpeed < 0.01 || Math.abs(dOffset) < 1) { - this.deactivateAndFlush(); - } - return blankness; - } - - enabled(): boolean { - return this._enabled; - } - - _resetData() { - this._anyBlankStartTime = null; - this._info = new Info(); - this._mostlyBlankStartTime = null; - this._samplesStartTime = null; - } -} - -module.exports = FillRateHelper; diff --git a/packages/virtualized-lists/Lists/ViewabilityHelper.js b/packages/virtualized-lists/Lists/ViewabilityHelper.js deleted file mode 100644 index 33a9811825a..00000000000 --- a/packages/virtualized-lists/Lists/ViewabilityHelper.js +++ /dev/null @@ -1,360 +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 - */ - -'use strict'; - -import type {FrameMetricProps} from './VirtualizedListProps'; - -const invariant = require('invariant'); - -export type ViewToken = { - item: any, - key: string, - index: ?number, - isViewable: boolean, - section?: any, - ... -}; - -export type ViewabilityConfigCallbackPair = { - viewabilityConfig: ViewabilityConfig, - onViewableItemsChanged: (info: { - viewableItems: Array, - changed: Array, - ... - }) => void, - ... -}; - -export type ViewabilityConfig = {| - /** - * Minimum amount of time (in milliseconds) that an item must be physically viewable before the - * viewability callback will be fired. A high number means that scrolling through content without - * stopping will not mark the content as viewable. - */ - minimumViewTime?: number, - - /** - * Percent of viewport that must be covered for a partially occluded item to count as - * "viewable", 0-100. Fully visible items are always considered viewable. A value of 0 means - * that a single pixel in the viewport makes the item viewable, and a value of 100 means that - * an item must be either entirely visible or cover the entire viewport to count as viewable. - */ - viewAreaCoveragePercentThreshold?: number, - - /** - * Similar to `viewAreaPercentThreshold`, but considers the percent of the item that is visible, - * rather than the fraction of the viewable area it covers. - */ - itemVisiblePercentThreshold?: number, - - /** - * Nothing is considered viewable until the user scrolls or `recordInteraction` is called after - * render. - */ - waitForInteraction?: boolean, -|}; - -/** - * A Utility class for calculating viewable items based on current metrics like scroll position and - * layout. - * - * An item is said to be in a "viewable" state when any of the following - * is true for longer than `minimumViewTime` milliseconds (after an interaction if `waitForInteraction` - * is true): - * - * - Occupying >= `viewAreaCoveragePercentThreshold` of the view area XOR fraction of the item - * visible in the view area >= `itemVisiblePercentThreshold`. - * - Entirely visible on screen - */ -class ViewabilityHelper { - _config: ViewabilityConfig; - _hasInteracted: boolean = false; - _timers: Set = new Set(); - _viewableIndices: Array = []; - _viewableItems: Map = new Map(); - - constructor( - config: ViewabilityConfig = {viewAreaCoveragePercentThreshold: 0}, - ) { - this._config = config; - } - - /** - * Cleanup, e.g. on unmount. Clears any pending timers. - */ - dispose() { - /* $FlowFixMe[incompatible-call] (>=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. */ - this._timers.forEach(clearTimeout); - } - - /** - * Determines which items are viewable based on the current metrics and config. - */ - computeViewableItems( - props: FrameMetricProps, - scrollOffset: number, - viewportHeight: number, - getFrameMetrics: ( - index: number, - props: FrameMetricProps, - ) => ?{ - length: number, - offset: number, - ... - }, - // Optional optimization to reduce the scan size - renderRange?: { - first: number, - last: number, - ... - }, - ): Array { - const itemCount = props.getItemCount(props.data); - const {itemVisiblePercentThreshold, viewAreaCoveragePercentThreshold} = - this._config; - const viewAreaMode = viewAreaCoveragePercentThreshold != null; - const viewablePercentThreshold = viewAreaMode - ? viewAreaCoveragePercentThreshold - : itemVisiblePercentThreshold; - invariant( - viewablePercentThreshold != null && - (itemVisiblePercentThreshold != null) !== - (viewAreaCoveragePercentThreshold != null), - 'Must set exactly one of itemVisiblePercentThreshold or viewAreaCoveragePercentThreshold', - ); - const viewableIndices = []; - if (itemCount === 0) { - return viewableIndices; - } - let firstVisible = -1; - const {first, last} = renderRange || {first: 0, last: itemCount - 1}; - if (last >= itemCount) { - console.warn( - 'Invalid render range computing viewability ' + - JSON.stringify({renderRange, itemCount}), - ); - return []; - } - for (let idx = first; idx <= last; idx++) { - const metrics = getFrameMetrics(idx, props); - if (!metrics) { - continue; - } - const top = metrics.offset - scrollOffset; - const bottom = top + metrics.length; - if (top < viewportHeight && bottom > 0) { - firstVisible = idx; - if ( - _isViewable( - viewAreaMode, - viewablePercentThreshold, - top, - bottom, - viewportHeight, - metrics.length, - ) - ) { - viewableIndices.push(idx); - } - } else if (firstVisible >= 0) { - break; - } - } - return viewableIndices; - } - - /** - * Figures out which items are viewable and how that has changed from before and calls - * `onViewableItemsChanged` as appropriate. - */ - onUpdate( - props: FrameMetricProps, - scrollOffset: number, - viewportHeight: number, - getFrameMetrics: ( - index: number, - props: FrameMetricProps, - ) => ?{ - length: number, - offset: number, - ... - }, - createViewToken: ( - index: number, - isViewable: boolean, - props: FrameMetricProps, - ) => ViewToken, - onViewableItemsChanged: ({ - viewableItems: Array, - changed: Array, - ... - }) => void, - // Optional optimization to reduce the scan size - renderRange?: { - first: number, - last: number, - ... - }, - ): void { - const itemCount = props.getItemCount(props.data); - if ( - (this._config.waitForInteraction && !this._hasInteracted) || - itemCount === 0 || - !getFrameMetrics(0, props) - ) { - return; - } - let viewableIndices: Array = []; - if (itemCount) { - viewableIndices = this.computeViewableItems( - props, - scrollOffset, - viewportHeight, - getFrameMetrics, - renderRange, - ); - } - if ( - this._viewableIndices.length === viewableIndices.length && - this._viewableIndices.every((v, ii) => v === viewableIndices[ii]) - ) { - // We might get a lot of scroll events where visibility doesn't change and we don't want to do - // extra work in those cases. - return; - } - this._viewableIndices = viewableIndices; - if (this._config.minimumViewTime) { - const handle: TimeoutID = setTimeout(() => { - /* $FlowFixMe[incompatible-call] (>=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. */ - this._timers.delete(handle); - this._onUpdateSync( - props, - viewableIndices, - onViewableItemsChanged, - createViewToken, - ); - }, this._config.minimumViewTime); - /* $FlowFixMe[incompatible-call] (>=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. */ - this._timers.add(handle); - } else { - this._onUpdateSync( - props, - viewableIndices, - onViewableItemsChanged, - createViewToken, - ); - } - } - - /** - * clean-up cached _viewableIndices to evaluate changed items on next update - */ - resetViewableIndices() { - this._viewableIndices = []; - } - - /** - * Records that an interaction has happened even if there has been no scroll. - */ - recordInteraction() { - this._hasInteracted = true; - } - - _onUpdateSync( - props: FrameMetricProps, - viewableIndicesToCheck: Array, - onViewableItemsChanged: ({ - changed: Array, - viewableItems: Array, - ... - }) => void, - createViewToken: ( - index: number, - isViewable: boolean, - props: FrameMetricProps, - ) => ViewToken, - ) { - // Filter out indices that have gone out of view since this call was scheduled. - viewableIndicesToCheck = viewableIndicesToCheck.filter(ii => - this._viewableIndices.includes(ii), - ); - const prevItems = this._viewableItems; - const nextItems = new Map( - viewableIndicesToCheck.map(ii => { - const viewable = createViewToken(ii, true, props); - return [viewable.key, viewable]; - }), - ); - - const changed = []; - for (const [key, viewable] of nextItems) { - if (!prevItems.has(key)) { - changed.push(viewable); - } - } - for (const [key, viewable] of prevItems) { - if (!nextItems.has(key)) { - changed.push({...viewable, isViewable: false}); - } - } - if (changed.length > 0) { - this._viewableItems = nextItems; - onViewableItemsChanged({ - viewableItems: Array.from(nextItems.values()), - changed, - viewabilityConfig: this._config, - }); - } - } -} - -function _isViewable( - viewAreaMode: boolean, - viewablePercentThreshold: number, - top: number, - bottom: number, - viewportHeight: number, - itemLength: number, -): boolean { - if (_isEntirelyVisible(top, bottom, viewportHeight)) { - return true; - } else { - const pixels = _getPixelsVisible(top, bottom, viewportHeight); - const percent = - 100 * (viewAreaMode ? pixels / viewportHeight : pixels / itemLength); - return percent >= viewablePercentThreshold; - } -} - -function _getPixelsVisible( - top: number, - bottom: number, - viewportHeight: number, -): number { - const visibleHeight = Math.min(bottom, viewportHeight) - Math.max(top, 0); - return Math.max(0, visibleHeight); -} - -function _isEntirelyVisible( - top: number, - bottom: number, - viewportHeight: number, -): boolean { - return top >= 0 && bottom <= viewportHeight && bottom > top; -} - -module.exports = ViewabilityHelper; diff --git a/packages/virtualized-lists/Lists/VirtualizeUtils.js b/packages/virtualized-lists/Lists/VirtualizeUtils.js deleted file mode 100644 index 3a70d9f6830..00000000000 --- a/packages/virtualized-lists/Lists/VirtualizeUtils.js +++ /dev/null @@ -1,258 +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 - */ - -'use strict'; - -import type {FrameMetricProps} from './VirtualizedListProps'; - -/** - * Used to find the indices of the frames that overlap the given offsets. Useful for finding the - * items that bound different windows of content, such as the visible area or the buffered overscan - * area. - */ -export function elementsThatOverlapOffsets( - offsets: Array, - props: FrameMetricProps, - getFrameMetrics: ( - index: number, - props: FrameMetricProps, - ) => { - length: number, - offset: number, - ... - }, - zoomScale: number = 1, -): Array { - const itemCount = props.getItemCount(props.data); - const result = []; - for (let offsetIndex = 0; offsetIndex < offsets.length; offsetIndex++) { - const currentOffset = offsets[offsetIndex]; - let left = 0; - let right = itemCount - 1; - - while (left <= right) { - // eslint-disable-next-line no-bitwise - const mid = left + ((right - left) >>> 1); - const frame = getFrameMetrics(mid, props); - const scaledOffsetStart = frame.offset * zoomScale; - const scaledOffsetEnd = (frame.offset + frame.length) * zoomScale; - - // We want the first frame that contains the offset, with inclusive bounds. Thus, for the - // first frame the scaledOffsetStart is inclusive, while for other frames it is exclusive. - if ( - (mid === 0 && currentOffset < scaledOffsetStart) || - (mid !== 0 && currentOffset <= scaledOffsetStart) - ) { - right = mid - 1; - } else if (currentOffset > scaledOffsetEnd) { - left = mid + 1; - } else { - result[offsetIndex] = mid; - break; - } - } - } - - return result; -} - -/** - * Computes the number of elements in the `next` range that are new compared to the `prev` range. - * Handy for calculating how many new items will be rendered when the render window changes so we - * can restrict the number of new items render at once so that content can appear on the screen - * faster. - */ -export function newRangeCount( - prev: { - first: number, - last: number, - ... - }, - next: { - first: number, - last: number, - ... - }, -): number { - return ( - next.last - - next.first + - 1 - - Math.max( - 0, - 1 + Math.min(next.last, prev.last) - Math.max(next.first, prev.first), - ) - ); -} - -/** - * Custom logic for determining which items should be rendered given the current frame and scroll - * metrics, as well as the previous render state. The algorithm may evolve over time, but generally - * prioritizes the visible area first, then expands that with overscan regions ahead and behind, - * biased in the direction of scroll. - */ -export function computeWindowedRenderLimits( - props: FrameMetricProps, - maxToRenderPerBatch: number, - windowSize: number, - prev: { - first: number, - last: number, - }, - getFrameMetricsApprox: ( - index: number, - props: FrameMetricProps, - ) => { - length: number, - offset: number, - ... - }, - scrollMetrics: { - dt: number, - offset: number, - velocity: number, - visibleLength: number, - zoomScale: number, - ... - }, -): { - first: number, - last: number, -} { - const itemCount = props.getItemCount(props.data); - if (itemCount === 0) { - return {first: 0, last: -1}; - } - const {offset, velocity, visibleLength, zoomScale = 1} = scrollMetrics; - - // Start with visible area, then compute maximum overscan region by expanding from there, biased - // in the direction of scroll. Total overscan area is capped, which should cap memory consumption - // too. - const visibleBegin = Math.max(0, offset); - const visibleEnd = visibleBegin + visibleLength; - const overscanLength = (windowSize - 1) * visibleLength; - - // Considering velocity seems to introduce more churn than it's worth. - const leadFactor = 0.5; // Math.max(0, Math.min(1, velocity / 25 + 0.5)); - - const fillPreference = - velocity > 1 ? 'after' : velocity < -1 ? 'before' : 'none'; - - const overscanBegin = Math.max( - 0, - visibleBegin - (1 - leadFactor) * overscanLength, - ); - const overscanEnd = Math.max(0, visibleEnd + leadFactor * overscanLength); - - const lastItemOffset = - getFrameMetricsApprox(itemCount - 1, props).offset * zoomScale; - if (lastItemOffset < overscanBegin) { - // Entire list is before our overscan window - return { - first: Math.max(0, itemCount - 1 - maxToRenderPerBatch), - last: itemCount - 1, - }; - } - - // Find the indices that correspond to the items at the render boundaries we're targeting. - let [overscanFirst, first, last, overscanLast] = elementsThatOverlapOffsets( - [overscanBegin, visibleBegin, visibleEnd, overscanEnd], - props, - getFrameMetricsApprox, - zoomScale, - ); - overscanFirst = overscanFirst == null ? 0 : overscanFirst; - first = first == null ? Math.max(0, overscanFirst) : first; - overscanLast = overscanLast == null ? itemCount - 1 : overscanLast; - last = - last == null - ? Math.min(overscanLast, first + maxToRenderPerBatch - 1) - : last; - const visible = {first, last}; - - // We want to limit the number of new cells we're rendering per batch so that we can fill the - // content on the screen quickly. If we rendered the entire overscan window at once, the user - // could be staring at white space for a long time waiting for a bunch of offscreen content to - // render. - let newCellCount = newRangeCount(prev, visible); - - while (true) { - if (first <= overscanFirst && last >= overscanLast) { - // If we fill the entire overscan range, we're done. - break; - } - const maxNewCells = newCellCount >= maxToRenderPerBatch; - const firstWillAddMore = first <= prev.first || first > prev.last; - const firstShouldIncrement = - first > overscanFirst && (!maxNewCells || !firstWillAddMore); - const lastWillAddMore = last >= prev.last || last < prev.first; - const lastShouldIncrement = - last < overscanLast && (!maxNewCells || !lastWillAddMore); - if (maxNewCells && !firstShouldIncrement && !lastShouldIncrement) { - // We only want to stop if we've hit maxNewCells AND we cannot increment first or last - // without rendering new items. This let's us preserve as many already rendered items as - // possible, reducing render churn and keeping the rendered overscan range as large as - // possible. - break; - } - if ( - firstShouldIncrement && - !(fillPreference === 'after' && lastShouldIncrement && lastWillAddMore) - ) { - if (firstWillAddMore) { - newCellCount++; - } - first--; - } - if ( - lastShouldIncrement && - !(fillPreference === 'before' && firstShouldIncrement && firstWillAddMore) - ) { - if (lastWillAddMore) { - newCellCount++; - } - last++; - } - } - if ( - !( - last >= first && - first >= 0 && - last < itemCount && - first >= overscanFirst && - last <= overscanLast && - first <= visible.first && - last >= visible.last - ) - ) { - throw new Error( - 'Bad window calculation ' + - JSON.stringify({ - first, - last, - itemCount, - overscanFirst, - overscanLast, - visible, - }), - ); - } - return {first, last}; -} - -export function keyExtractor(item: any, index: number): string { - if (typeof item === 'object' && item?.key != null) { - return item.key; - } - if (typeof item === 'object' && item?.id != null) { - return item.id; - } - return String(index); -} diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js deleted file mode 100644 index b93878c5658..00000000000 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ /dev/null @@ -1,1955 +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 'react-native/Libraries/Components/ScrollView/ScrollView'; -import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet'; -import type { - LayoutEvent, - ScrollEvent, -} from 'react-native/Libraries/Types/CoreEventTypes'; -import type {ViewToken} from './ViewabilityHelper'; -import type { - FrameMetricProps, - Item, - Props, - RenderItemProps, - RenderItemType, - Separators, -} from './VirtualizedListProps'; - -import { - RefreshControl, - ScrollView, - View, - StyleSheet, - findNodeHandle, -} from 'react-native'; -import Batchinator from '../Interaction/Batchinator'; -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_EDGE_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; -} - -// onStartReachedThresholdOrDefault(this.props.onStartReachedThreshold) -function onStartReachedThresholdOrDefault(onStartReachedThreshold: ?number) { - return onStartReachedThreshold ?? 2; -} - -// onEndReachedThresholdOrDefault(this.props.onEndReachedThreshold) -function onEndReachedThresholdOrDefault(onEndReachedThreshold: ?number) { - return onEndReachedThreshold ?? 2; -} - -// getScrollingThreshold(visibleLength, onEndReachedThreshold) -function getScrollingThreshold(threshold: number, visibleLength: number) { - return (threshold * visibleLength) / 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 - * - */ -class VirtualizedList extends StateSafePureComponent { - 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; - if (veryLast < 0) { - return; - } - 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, - viewOffset?: number, - 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 => { - this._nestedChildLists.add(childList.ref, childList.cellKey); - if (this._hasInteracted) { - childList.ref.recordInteraction(); - } - }; - - _unregisterAsNestedChild = (childList: { - ref: React.ElementRef, - }): void => { - this._nestedChildLists.remove(childList.ref); - }; - - 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__) { - // $FlowFixMe[underconstrained-implicit-instantiation] - const flatStyles = StyleSheet.flatten(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: Array = []; - 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, { - onLayout: (event: LayoutEvent) => { - 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; - _sentStartForContentLength = 0; - _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._maybeCallOnEdgeReached(); - }; - - _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; - } - - _maybeCallOnEdgeReached() { - const { - data, - getItemCount, - onStartReached, - onStartReachedThreshold, - onEndReached, - onEndReachedThreshold, - initialScrollIndex, - } = this.props; - const {contentLength, visibleLength, offset} = this._scrollMetrics; - let distanceFromStart = offset; - let distanceFromEnd = contentLength - visibleLength - offset; - - // Especially when oERT is zero it's necessary to 'floor' very small distance 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 edge of the list with a distance approximating 0 but not quite there. - if (distanceFromStart < ON_EDGE_REACHED_EPSILON) { - distanceFromStart = 0; - } - if (distanceFromEnd < ON_EDGE_REACHED_EPSILON) { - distanceFromEnd = 0; - } - - // TODO: T121172172 Look into why we're "defaulting" to a threshold of 2px - // when oERT is not present (different from 2 viewports used elsewhere) - const DEFAULT_THRESHOLD_PX = 2; - - const startThreshold = - onStartReachedThreshold != null - ? onStartReachedThreshold * visibleLength - : DEFAULT_THRESHOLD_PX; - const endThreshold = - onEndReachedThreshold != null - ? onEndReachedThreshold * visibleLength - : DEFAULT_THRESHOLD_PX; - const isWithinStartThreshold = distanceFromStart <= startThreshold; - const isWithinEndThreshold = distanceFromEnd <= endThreshold; - - // First check if the user just scrolled within the end threshold - // and call onEndReached only once for a given content length, - // and only if onStartReached is not being executed - if ( - onEndReached && - this.state.cellsAroundViewport.last === getItemCount(data) - 1 && - isWithinEndThreshold && - this._scrollMetrics.contentLength !== this._sentEndForContentLength - ) { - this._sentEndForContentLength = this._scrollMetrics.contentLength; - onEndReached({distanceFromEnd}); - } - - // Next check if the user just scrolled within the start threshold - // and call onStartReached only once for a given content length, - // and only if onEndReached is not being executed - else if ( - onStartReached != null && - this.state.cellsAroundViewport.first === 0 && - isWithinStartThreshold && - this._scrollMetrics.contentLength !== this._sentStartForContentLength - ) { - // On initial mount when using initialScrollIndex the offset will be 0 initially - // and will trigger an unexpected onStartReached. To avoid this we can use - // timestamp to differentiate between the initial scroll metrics and when we actually - // received the first scroll event. - if (!initialScrollIndex || this._scrollMetrics.timestamp !== 0) { - this._sentStartForContentLength = this._scrollMetrics.contentLength; - onStartReached({distanceFromStart}); - } - } - - // If the user scrolls away from the start or end and back again, - // cause onStartReached or onEndReached to be triggered again - else { - this._sentStartForContentLength = isWithinStartThreshold - ? this._sentStartForContentLength - : 0; - this._sentEndForContentLength = isWithinEndThreshold - ? 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._maybeCallOnEdgeReached(); - }; - - /* 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._maybeCallOnEdgeReached(); - 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 onStartReachedThreshold = onStartReachedThresholdOrDefault( - this.props.onStartReachedThreshold, - ); - const onEndReachedThreshold = onEndReachedThresholdOrDefault( - this.props.onEndReachedThreshold, - ); - // 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 = - distTop < 0 || - (velocity < -2 && - distTop < - getScrollingThreshold(onStartReachedThreshold, visibleLength)); - } - // 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 (!hiPri && last >= 0 && last < itemCount - 1) { - const distBottom = - this.__getFrameMetricsApprox(last, this.props).offset - - (offset + visibleLength); - hiPri = - distBottom < 0 || - (velocity > 2 && - distBottom < - getScrollingThreshold(onEndReachedThreshold, visibleLength)); - } - // 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 = 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, - ); - }); - } -} - -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, - }, -}); - -module.exports = VirtualizedList; diff --git a/packages/virtualized-lists/Lists/VirtualizedListContext.js b/packages/virtualized-lists/Lists/VirtualizedListContext.js deleted file mode 100644 index bca5724498a..00000000000 --- a/packages/virtualized-lists/Lists/VirtualizedListContext.js +++ /dev/null @@ -1,116 +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 - */ - -import typeof VirtualizedList from './VirtualizedList'; - -import * as React from 'react'; -import {useContext, useMemo} from 'react'; - -type Context = $ReadOnly<{ - cellKey: ?string, - getScrollMetrics: () => { - contentLength: number, - dOffset: number, - dt: number, - offset: number, - timestamp: number, - velocity: number, - visibleLength: number, - zoomScale: number, - }, - horizontal: ?boolean, - getOutermostParentListRef: () => React.ElementRef, - registerAsNestedChild: ({ - cellKey: string, - ref: React.ElementRef, - }) => void, - unregisterAsNestedChild: ({ - ref: React.ElementRef, - }) => void, -}>; - -export const VirtualizedListContext: React.Context = - React.createContext(null); -if (__DEV__) { - VirtualizedListContext.displayName = 'VirtualizedListContext'; -} - -/** - * Resets the context. Intended for use by portal-like components (e.g. Modal). - */ -export function VirtualizedListContextResetter({ - children, -}: { - children: React.Node, -}): React.Node { - return ( - - {children} - - ); -} - -/** - * Sets the context with memoization. Intended to be used by `VirtualizedList`. - */ -export function VirtualizedListContextProvider({ - children, - value, -}: { - children: React.Node, - value: Context, -}): React.Node { - // Avoid setting a newly created context object if the values are identical. - const context = useMemo( - () => ({ - cellKey: null, - getScrollMetrics: value.getScrollMetrics, - horizontal: value.horizontal, - getOutermostParentListRef: value.getOutermostParentListRef, - registerAsNestedChild: value.registerAsNestedChild, - unregisterAsNestedChild: value.unregisterAsNestedChild, - }), - [ - value.getScrollMetrics, - value.horizontal, - value.getOutermostParentListRef, - value.registerAsNestedChild, - value.unregisterAsNestedChild, - ], - ); - return ( - - {children} - - ); -} - -/** - * Sets the `cellKey`. Intended to be used by `VirtualizedList` for each cell. - */ -export function VirtualizedListCellContextProvider({ - cellKey, - children, -}: { - cellKey: string, - children: React.Node, -}): React.Node { - // Avoid setting a newly created context object if the values are identical. - const currContext = useContext(VirtualizedListContext); - const context = useMemo( - () => (currContext == null ? null : {...currContext, cellKey}), - [currContext, cellKey], - ); - return ( - - {children} - - ); -} diff --git a/packages/virtualized-lists/Lists/VirtualizedSectionList.js b/packages/virtualized-lists/Lists/VirtualizedSectionList.js deleted file mode 100644 index 61519bca4dc..00000000000 --- a/packages/virtualized-lists/Lists/VirtualizedSectionList.js +++ /dev/null @@ -1,617 +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 {ViewToken} from './ViewabilityHelper'; - -import {View} from 'react-native'; -import VirtualizedList from './VirtualizedList'; -import {keyExtractor as defaultKeyExtractor} from './VirtualizeUtils'; -import invariant from 'invariant'; -import * as React from 'react'; - -type Item = any; - -export type SectionBase = { - /** - * The data for rendering items in this section. - */ - data: $ReadOnlyArray, - /** - * Optional key to keep track of section re-ordering. If you don't plan on re-ordering sections, - * the array index will be used by default. - */ - key?: string, - // Optional props will override list-wide props just for this section. - renderItem?: ?(info: { - item: SectionItemT, - index: number, - section: SectionBase, - separators: { - highlight: () => void, - unhighlight: () => void, - updateProps: (select: 'leading' | 'trailing', newProps: Object) => void, - ... - }, - ... - }) => null | React.Element, - ItemSeparatorComponent?: ?React.ComponentType, - keyExtractor?: (item: SectionItemT, index?: ?number) => string, - ... -}; - -type RequiredProps> = {| - sections: $ReadOnlyArray, -|}; - -type OptionalProps> = {| - /** - * Default renderer for every item in every section. - */ - renderItem?: (info: { - item: Item, - index: number, - section: SectionT, - separators: { - highlight: () => void, - unhighlight: () => void, - updateProps: (select: 'leading' | 'trailing', newProps: Object) => void, - ... - }, - ... - }) => null | React.Element, - /** - * Rendered at the top of each section. These stick to the top of the `ScrollView` by default on - * iOS. See `stickySectionHeadersEnabled`. - */ - renderSectionHeader?: ?(info: { - section: SectionT, - ... - }) => null | React.Element, - /** - * Rendered at the bottom of each section. - */ - renderSectionFooter?: ?(info: { - section: SectionT, - ... - }) => null | React.Element, - /** - * Rendered at the top and bottom of each section (note this is different from - * `ItemSeparatorComponent` which is only rendered between items). These are intended to separate - * sections from the headers above and below and typically have the same highlight response as - * `ItemSeparatorComponent`. Also receives `highlighted`, `[leading/trailing][Item/Separator]`, - * and any custom props from `separators.updateProps`. - */ - SectionSeparatorComponent?: ?React.ComponentType, - /** - * Makes section headers stick to the top of the screen until the next one pushes it off. Only - * enabled by default on iOS because that is the platform standard there. - */ - stickySectionHeadersEnabled?: boolean, - onEndReached?: ?({distanceFromEnd: number, ...}) => void, -|}; - -type VirtualizedListProps = React.ElementConfig; - -export type Props = {| - ...RequiredProps, - ...OptionalProps, - ...$Diff< - VirtualizedListProps, - { - renderItem: $PropertyType, - data: $PropertyType, - ... - }, - >, -|}; -export type ScrollToLocationParamsType = {| - animated?: ?boolean, - itemIndex: number, - sectionIndex: number, - viewOffset?: number, - viewPosition?: number, -|}; - -type State = {childProps: VirtualizedListProps, ...}; - -/** - * Right now this just flattens everything into one list and uses VirtualizedList under the - * hood. The only operation that might not scale well is concatting the data arrays of all the - * sections when new props are received, which should be plenty fast for up to ~10,000 items. - */ -class VirtualizedSectionList< - SectionT: SectionBase, -> extends React.PureComponent, State> { - scrollToLocation(params: ScrollToLocationParamsType) { - let index = params.itemIndex; - for (let i = 0; i < params.sectionIndex; i++) { - index += this.props.getItemCount(this.props.sections[i].data) + 2; - } - let viewOffset = params.viewOffset || 0; - if (this._listRef == null) { - return; - } - if (params.itemIndex > 0 && this.props.stickySectionHeadersEnabled) { - const frame = this._listRef.__getFrameMetricsApprox( - index - params.itemIndex, - this._listRef.props, - ); - viewOffset += frame.length; - } - const toIndexParams = { - ...params, - viewOffset, - index, - }; - // $FlowFixMe[incompatible-use] - this._listRef.scrollToIndex(toIndexParams); - } - - getListRef(): ?React.ElementRef { - return this._listRef; - } - - render(): React.Node { - const { - ItemSeparatorComponent, // don't pass through, rendered with renderItem - SectionSeparatorComponent, - renderItem: _renderItem, - renderSectionFooter, - renderSectionHeader, - sections: _sections, - stickySectionHeadersEnabled, - ...passThroughProps - } = this.props; - - const listHeaderOffset = this.props.ListHeaderComponent ? 1 : 0; - - const stickyHeaderIndices = this.props.stickySectionHeadersEnabled - ? ([]: Array) - : undefined; - - let itemCount = 0; - for (const section of this.props.sections) { - // Track the section header indices - if (stickyHeaderIndices != null) { - stickyHeaderIndices.push(itemCount + listHeaderOffset); - } - - // Add two for the section header and footer. - itemCount += 2; - itemCount += this.props.getItemCount(section.data); - } - const renderItem = this._renderItem(itemCount); - - return ( - - this._getItem(this.props, sections, index) - } - getItemCount={() => itemCount} - onViewableItemsChanged={ - this.props.onViewableItemsChanged - ? this._onViewableItemsChanged - : undefined - } - ref={this._captureRef} - /> - ); - } - - _getItem( - props: Props, - sections: ?$ReadOnlyArray, - index: number, - ): ?Item { - if (!sections) { - return null; - } - let itemIdx = index - 1; - for (let i = 0; i < sections.length; i++) { - const section = sections[i]; - const sectionData = section.data; - const itemCount = props.getItemCount(sectionData); - if (itemIdx === -1 || itemIdx === itemCount) { - // We intend for there to be overflow by one on both ends of the list. - // This will be for headers and footers. When returning a header or footer - // item the section itself is the item. - return section; - } else if (itemIdx < itemCount) { - // If we are in the bounds of the list's data then return the item. - return props.getItem(sectionData, itemIdx); - } else { - itemIdx -= itemCount + 2; // Add two for the header and footer - } - } - return null; - } - - // $FlowFixMe[missing-local-annot] - _keyExtractor = (item: Item, index: number) => { - const info = this._subExtractor(index); - return (info && info.key) || String(index); - }; - - _subExtractor(index: number): ?{ - section: SectionT, - // Key of the section or combined key for section + item - key: string, - // Relative index within the section - index: ?number, - // True if this is the section header - header?: ?boolean, - leadingItem?: ?Item, - leadingSection?: ?SectionT, - trailingItem?: ?Item, - trailingSection?: ?SectionT, - ... - } { - let itemIndex = index; - const {getItem, getItemCount, keyExtractor, sections} = this.props; - for (let i = 0; i < sections.length; i++) { - const section = sections[i]; - const sectionData = section.data; - const key = section.key || String(i); - itemIndex -= 1; // The section adds an item for the header - if (itemIndex >= getItemCount(sectionData) + 1) { - itemIndex -= getItemCount(sectionData) + 1; // The section adds an item for the footer. - } else if (itemIndex === -1) { - return { - section, - key: key + ':header', - index: null, - header: true, - trailingSection: sections[i + 1], - }; - } else if (itemIndex === getItemCount(sectionData)) { - return { - section, - key: key + ':footer', - index: null, - header: false, - trailingSection: sections[i + 1], - }; - } else { - const extractor = - section.keyExtractor || keyExtractor || defaultKeyExtractor; - return { - section, - key: - key + ':' + extractor(getItem(sectionData, itemIndex), itemIndex), - index: itemIndex, - leadingItem: getItem(sectionData, itemIndex - 1), - leadingSection: sections[i - 1], - trailingItem: getItem(sectionData, itemIndex + 1), - trailingSection: sections[i + 1], - }; - } - } - } - - _convertViewable = (viewable: ViewToken): ?ViewToken => { - invariant(viewable.index != null, 'Received a broken ViewToken'); - const info = this._subExtractor(viewable.index); - if (!info) { - return null; - } - const keyExtractorWithNullableIndex = info.section.keyExtractor; - const keyExtractorWithNonNullableIndex = - this.props.keyExtractor || defaultKeyExtractor; - const key = - keyExtractorWithNullableIndex != null - ? keyExtractorWithNullableIndex(viewable.item, info.index) - : keyExtractorWithNonNullableIndex(viewable.item, info.index ?? 0); - - return { - ...viewable, - index: info.index, - key, - section: info.section, - }; - }; - - _onViewableItemsChanged = ({ - viewableItems, - changed, - }: { - viewableItems: Array, - changed: Array, - ... - }) => { - const onViewableItemsChanged = this.props.onViewableItemsChanged; - if (onViewableItemsChanged != null) { - onViewableItemsChanged({ - viewableItems: viewableItems - .map(this._convertViewable, this) - .filter(Boolean), - changed: changed.map(this._convertViewable, this).filter(Boolean), - }); - } - }; - - _renderItem = - (listItemCount: number): $FlowFixMe => - // eslint-disable-next-line react/no-unstable-nested-components - ({item, index}: {item: Item, index: number, ...}) => { - const info = this._subExtractor(index); - if (!info) { - return null; - } - const infoIndex = info.index; - if (infoIndex == null) { - const {section} = info; - if (info.header === true) { - const {renderSectionHeader} = this.props; - return renderSectionHeader ? renderSectionHeader({section}) : null; - } else { - const {renderSectionFooter} = this.props; - return renderSectionFooter ? renderSectionFooter({section}) : null; - } - } else { - const renderItem = info.section.renderItem || this.props.renderItem; - const SeparatorComponent = this._getSeparatorComponent( - index, - info, - listItemCount, - ); - invariant(renderItem, 'no renderItem!'); - return ( - - ); - } - }; - - _updatePropsFor = (cellKey: string, value: any) => { - const updateProps = this._updatePropsMap[cellKey]; - if (updateProps != null) { - updateProps(value); - } - }; - - _updateHighlightFor = (cellKey: string, value: boolean) => { - const updateHighlight = this._updateHighlightMap[cellKey]; - if (updateHighlight != null) { - updateHighlight(value); - } - }; - - _setUpdateHighlightFor = ( - cellKey: string, - updateHighlightFn: ?(boolean) => void, - ) => { - if (updateHighlightFn != null) { - this._updateHighlightMap[cellKey] = updateHighlightFn; - } else { - // $FlowFixMe[prop-missing] - delete this._updateHighlightFor[cellKey]; - } - }; - - _setUpdatePropsFor = (cellKey: string, updatePropsFn: ?(boolean) => void) => { - if (updatePropsFn != null) { - this._updatePropsMap[cellKey] = updatePropsFn; - } else { - delete this._updatePropsMap[cellKey]; - } - }; - - _getSeparatorComponent( - index: number, - info?: ?Object, - listItemCount: number, - ): ?React.ComponentType { - info = info || this._subExtractor(index); - if (!info) { - return null; - } - const ItemSeparatorComponent = - info.section.ItemSeparatorComponent || this.props.ItemSeparatorComponent; - const {SectionSeparatorComponent} = this.props; - const isLastItemInList = index === listItemCount - 1; - const isLastItemInSection = - info.index === this.props.getItemCount(info.section.data) - 1; - if (SectionSeparatorComponent && isLastItemInSection) { - return SectionSeparatorComponent; - } - if (ItemSeparatorComponent && !isLastItemInSection && !isLastItemInList) { - return ItemSeparatorComponent; - } - return null; - } - - _updateHighlightMap: {[string]: (boolean) => void} = {}; - _updatePropsMap: {[string]: void | (boolean => void)} = {}; - _listRef: ?React.ElementRef; - _captureRef = (ref: null | React$ElementRef>) => { - this._listRef = ref; - }; -} - -type ItemWithSeparatorCommonProps = $ReadOnly<{| - leadingItem: ?Item, - leadingSection: ?Object, - section: Object, - trailingItem: ?Item, - trailingSection: ?Object, -|}>; - -type ItemWithSeparatorProps = $ReadOnly<{| - ...ItemWithSeparatorCommonProps, - LeadingSeparatorComponent: ?React.ComponentType, - SeparatorComponent: ?React.ComponentType, - cellKey: string, - index: number, - item: Item, - setSelfHighlightCallback: ( - cellKey: string, - updateFn: ?(boolean) => void, - ) => void, - setSelfUpdatePropsCallback: ( - cellKey: string, - updateFn: ?(boolean) => void, - ) => void, - prevCellKey?: ?string, - updateHighlightFor: (prevCellKey: string, value: boolean) => void, - updatePropsFor: (prevCellKey: string, value: Object) => void, - renderItem: Function, - inverted: boolean, -|}>; - -function ItemWithSeparator(props: ItemWithSeparatorProps): React.Node { - const { - LeadingSeparatorComponent, - // this is the trailing separator and is associated with this item - SeparatorComponent, - cellKey, - prevCellKey, - setSelfHighlightCallback, - updateHighlightFor, - setSelfUpdatePropsCallback, - updatePropsFor, - item, - index, - section, - inverted, - } = props; - - const [leadingSeparatorHiglighted, setLeadingSeparatorHighlighted] = - React.useState(false); - - const [separatorHighlighted, setSeparatorHighlighted] = React.useState(false); - - const [leadingSeparatorProps, setLeadingSeparatorProps] = React.useState({ - leadingItem: props.leadingItem, - leadingSection: props.leadingSection, - section: props.section, - trailingItem: props.item, - trailingSection: props.trailingSection, - }); - const [separatorProps, setSeparatorProps] = React.useState({ - leadingItem: props.item, - leadingSection: props.leadingSection, - section: props.section, - trailingItem: props.trailingItem, - trailingSection: props.trailingSection, - }); - - React.useEffect(() => { - setSelfHighlightCallback(cellKey, setSeparatorHighlighted); - // $FlowFixMe[incompatible-call] - setSelfUpdatePropsCallback(cellKey, setSeparatorProps); - - return () => { - setSelfUpdatePropsCallback(cellKey, null); - setSelfHighlightCallback(cellKey, null); - }; - }, [ - cellKey, - setSelfHighlightCallback, - setSeparatorProps, - setSelfUpdatePropsCallback, - ]); - - const separators = { - highlight: () => { - setLeadingSeparatorHighlighted(true); - setSeparatorHighlighted(true); - if (prevCellKey != null) { - updateHighlightFor(prevCellKey, true); - } - }, - unhighlight: () => { - setLeadingSeparatorHighlighted(false); - setSeparatorHighlighted(false); - if (prevCellKey != null) { - updateHighlightFor(prevCellKey, false); - } - }, - updateProps: ( - select: 'leading' | 'trailing', - newProps: $Shape, - ) => { - if (select === 'leading') { - if (LeadingSeparatorComponent != null) { - setLeadingSeparatorProps({...leadingSeparatorProps, ...newProps}); - } else if (prevCellKey != null) { - // update the previous item's separator - updatePropsFor(prevCellKey, {...leadingSeparatorProps, ...newProps}); - } - } else if (select === 'trailing' && SeparatorComponent != null) { - setSeparatorProps({...separatorProps, ...newProps}); - } - }, - }; - const element = props.renderItem({ - item, - index, - section, - separators, - }); - const leadingSeparator = LeadingSeparatorComponent != null && ( - - ); - const separator = SeparatorComponent != null && ( - - ); - return leadingSeparator || separator ? ( - - {inverted === false ? leadingSeparator : separator} - {element} - {inverted === false ? separator : leadingSeparator} - - ) : ( - element - ); -} - -/* $FlowFixMe[class-object-subtyping] added when improving typing for this - * parameters */ -// $FlowFixMe[method-unbinding] -module.exports = (VirtualizedSectionList: React.AbstractComponent< - React.ElementConfig, - $ReadOnly<{ - getListRef: () => ?React.ElementRef, - scrollToLocation: (params: ScrollToLocationParamsType) => void, - ... - }>, ->); diff --git a/packages/virtualized-lists/Utilities/infoLog.js b/packages/virtualized-lists/Utilities/infoLog.js deleted file mode 100644 index 6cb6df8d414..00000000000 --- a/packages/virtualized-lists/Utilities/infoLog.js +++ /dev/null @@ -1,20 +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 - * @flow strict - */ - -'use strict'; - -/** - * Intentional info-level logging for clear separation from ad-hoc console debug logging. - */ -function infoLog(...args: Array): void { - return console.log(...args); -} - -module.exports = infoLog; diff --git a/packages/virtualized-lists/index.d.ts b/packages/virtualized-lists/index.d.ts deleted file mode 100644 index c66fc20521e..00000000000 --- a/packages/virtualized-lists/index.d.ts +++ /dev/null @@ -1,10 +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 - */ - -export * from './Lists/VirtualizedList'; diff --git a/packages/virtualized-lists/index.js b/packages/virtualized-lists/index.js deleted file mode 100644 index 8516f7ccf73..00000000000 --- a/packages/virtualized-lists/index.js +++ /dev/null @@ -1,57 +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 - * @flow - */ - -'use strict'; - -import {keyExtractor} from './Lists/VirtualizeUtils'; - -import typeof VirtualizedList from './Lists/VirtualizedList'; -import typeof VirtualizedSectionList from './Lists/VirtualizedSectionList'; -import {typeof VirtualizedListContextResetter} from './Lists/VirtualizedListContext'; -import typeof ViewabilityHelper from './Lists/ViewabilityHelper'; -import typeof FillRateHelper from './Lists/FillRateHelper'; - -export type { - ViewToken, - ViewabilityConfig, - ViewabilityConfigCallbackPair, -} from './Lists/ViewabilityHelper'; -export type { - RenderItemProps, - RenderItemType, - Separators, -} from './Lists/VirtualizedListProps'; -export type { - Props as VirtualizedSectionListProps, - ScrollToLocationParamsType, - SectionBase, -} from './Lists/VirtualizedSectionList'; -export type {FillRateInfo} from './Lists/FillRateHelper'; - -module.exports = { - keyExtractor, - - get VirtualizedList(): VirtualizedList { - return require('./Lists/VirtualizedList'); - }, - get VirtualizedSectionList(): VirtualizedSectionList { - return require('./Lists/VirtualizedSectionList'); - }, - get VirtualizedListContextResetter(): VirtualizedListContextResetter { - const VirtualizedListContext = require('./Lists/VirtualizedListContext'); - return VirtualizedListContext.VirtualizedListContextResetter; - }, - get ViewabilityHelper(): ViewabilityHelper { - return require('./Lists/ViewabilityHelper'); - }, - get FillRateHelper(): FillRateHelper { - return require('./Lists/FillRateHelper'); - }, -}; diff --git a/packages/virtualized-lists/package.json b/packages/virtualized-lists/package.json deleted file mode 100644 index 8b19cd2c199..00000000000 --- a/packages/virtualized-lists/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@react-native/virtualized-lists", - "version": "0.72.0", - "description": "Virtualized lists for React Native.", - "repository": { - "type": "git", - "url": "git@github.com:facebook/react-native.git", - "directory": "packages/virtualized-lists" - }, - "license": "MIT", - "dependencies": { - "invariant": "^2.2.4" - }, - "devDependencies": { - "react-test-renderer": "18.2.0" - }, - "peerDependencies": { - "react-native": "*", - "react-test-renderer": "18.2.0" - } -} diff --git a/types/index.d.ts b/types/index.d.ts index 349efc65a9f..97b34d5d232 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -115,7 +115,7 @@ export * from '../Libraries/LayoutAnimation/LayoutAnimation'; export * from '../Libraries/Linking/Linking'; export * from '../Libraries/Lists/FlatList'; export * from '../Libraries/Lists/SectionList'; -export * from '@react-native/virtualized-lists'; +export * from '../Libraries/Lists/VirtualizedList'; export * from '../Libraries/LogBox/LogBox'; export * from '../Libraries/Modal/Modal'; export * as Systrace from '../Libraries/Performance/Systrace';