From 2e3dbe9c2fbff52448e2d5a7c1e4c96b1016cf25 Mon Sep 17 00:00:00 2001 From: Gabriel Donadel Dall'Agnol Date: Mon, 6 Feb 2023 13:39:13 -0800 Subject: [PATCH] feat: Move virtualized lists to @react-native/virtualized-lists (#35406) Summary: This PR moves `VirtualizedList`, `VirtualizedSectionList`, and its files to a separate package called `react-native/virtualized-lists` located under `packages/virtualized-lists` as proposed on https://github.com/facebook/react-native/issues/35263 ## Changelog [General] [Changed] - Move virtualized lists to react-native/virtualized-lists package Pull Request resolved: https://github.com/facebook/react-native/pull/35406 Test Plan: 1. Open the RNTester app and navigate to `FlatList` or `SectionList` page 2. Test virtualized lists through the many sections https://user-images.githubusercontent.com/11707729/202878843-2b1322f5-cfee-484e-aaf3-d8d4dc0b96cc.mov Reviewed By: cipolleschi Differential Revision: D41745930 Pulled By: hoxyq fbshipit-source-id: d3d33896801fd69448c6893b86fd5c2363144fd0 --- Libraries/Inspector/NetworkOverlay.js | 2 +- 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 +- Libraries/Lists/ViewabilityHelper.js | 351 +-- Libraries/Lists/VirtualizedList.js | 1949 +--------------- Libraries/Lists/VirtualizedSectionList.js | 609 +---- Libraries/Modal/Modal.js | 2 +- Libraries/Utilities/ReactNativeTestTools.js | 2 +- index.js | 2 +- package.json | 1 + .../js/examples/FlatList/FlatList-basic.js | 2 +- .../js/examples/FlatList/FlatList-nested.js | 4 +- .../FlatList-onViewableItemsChanged.js | 2 +- .../Interaction/Batchinator.js | 2 +- .../Interaction/__tests__/Batchinator-test.js | 4 - .../Lists/CellRenderMask.js | 0 .../Lists/ChildListCollection.js | 0 .../Lists/FillRateHelper.js | 0 .../Lists/StateSafePureComponent.js | 0 .../Lists/ViewabilityHelper.js | 360 +++ .../Lists/VirtualizeUtils.js | 0 .../Lists/VirtualizedList.d.ts | 12 +- .../Lists/VirtualizedList.js | 1955 +++++++++++++++++ .../Lists/VirtualizedListCellRenderer.js | 10 +- .../Lists/VirtualizedListContext.js | 0 .../Lists/VirtualizedListProps.js | 4 +- .../Lists/VirtualizedSectionList.js | 617 ++++++ .../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 .../Utilities/__tests__/clamp-test.js | 0 .../virtualized-lists}/Utilities/clamp.js | 0 .../virtualized-lists/Utilities/infoLog.js | 20 + packages/virtualized-lists/index.d.ts | 10 + packages/virtualized-lists/index.js | 51 + packages/virtualized-lists/package.json | 18 + types/index.d.ts | 2 +- 46 files changed, 3100 insertions(+), 2927 deletions(-) rename {Libraries => packages/virtualized-lists}/Interaction/Batchinator.js (97%) rename {Libraries => packages/virtualized-lists}/Interaction/__tests__/Batchinator-test.js (95%) rename {Libraries => packages/virtualized-lists}/Lists/CellRenderMask.js (100%) rename {Libraries => packages/virtualized-lists}/Lists/ChildListCollection.js (100%) rename {Libraries => packages/virtualized-lists}/Lists/FillRateHelper.js (100%) rename {Libraries => packages/virtualized-lists}/Lists/StateSafePureComponent.js (100%) create mode 100644 packages/virtualized-lists/Lists/ViewabilityHelper.js rename {Libraries => packages/virtualized-lists}/Lists/VirtualizeUtils.js (100%) rename {Libraries => packages/virtualized-lists}/Lists/VirtualizedList.d.ts (97%) create mode 100644 packages/virtualized-lists/Lists/VirtualizedList.js rename {Libraries => packages/virtualized-lists}/Lists/VirtualizedListCellRenderer.js (96%) rename {Libraries => packages/virtualized-lists}/Lists/VirtualizedListContext.js (100%) rename {Libraries => packages/virtualized-lists}/Lists/VirtualizedListProps.js (98%) create mode 100644 packages/virtualized-lists/Lists/VirtualizedSectionList.js rename {Libraries => packages/virtualized-lists}/Lists/__tests__/CellRenderMask-test.js (100%) rename {Libraries => packages/virtualized-lists}/Lists/__tests__/FillRateHelper-test.js (100%) rename {Libraries => packages/virtualized-lists}/Lists/__tests__/ViewabilityHelper-test.js (100%) rename {Libraries => packages/virtualized-lists}/Lists/__tests__/VirtualizeUtils-test.js (100%) rename {Libraries => packages/virtualized-lists}/Lists/__tests__/VirtualizedList-test.js (100%) rename {Libraries => packages/virtualized-lists}/Lists/__tests__/VirtualizedSectionList-test.js (100%) rename {Libraries => packages/virtualized-lists}/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap (100%) rename {Libraries => packages/virtualized-lists}/Lists/__tests__/__snapshots__/VirtualizedSectionList-test.js.snap (100%) rename {Libraries => packages/virtualized-lists}/Utilities/__tests__/clamp-test.js (100%) rename {Libraries => packages/virtualized-lists}/Utilities/clamp.js (100%) create mode 100644 packages/virtualized-lists/Utilities/infoLog.js create mode 100644 packages/virtualized-lists/index.d.ts create mode 100644 packages/virtualized-lists/index.js create mode 100644 packages/virtualized-lists/package.json diff --git a/Libraries/Inspector/NetworkOverlay.js b/Libraries/Inspector/NetworkOverlay.js index 169318cea27f28..c6d1ec33907e36 100644 --- a/Libraries/Inspector/NetworkOverlay.js +++ b/Libraries/Inspector/NetworkOverlay.js @@ -10,7 +10,7 @@ 'use strict'; -import type {RenderItemProps} from '../Lists/VirtualizedList'; +import type {RenderItemProps} from '@react-native/virtualized-lists'; const ScrollView = require('../Components/ScrollView/ScrollView'); const TouchableHighlight = require('../Components/Touchable/TouchableHighlight'); diff --git a/Libraries/Lists/FlatList.d.ts b/Libraries/Lists/FlatList.d.ts index 344d5671359c45..6ac7f57fdcd843 100644 --- a/Libraries/Lists/FlatList.d.ts +++ b/Libraries/Lists/FlatList.d.ts @@ -12,7 +12,7 @@ import type { ListRenderItem, ViewToken, VirtualizedListProps, -} from './VirtualizedList'; +} from '@react-native/virtualized-lists'; 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 56748eaf75240d..40372bc70dd311 100644 --- a/Libraries/Lists/FlatList.js +++ b/Libraries/Lists/FlatList.js @@ -11,14 +11,17 @@ import typeof ScrollViewNativeComponent from '../Components/ScrollView/ScrollViewNativeComponent'; import type {ViewStyleProp} from '../StyleSheet/StyleSheet'; import type { + RenderItemProps, + RenderItemType, ViewabilityConfigCallbackPair, ViewToken, -} from './ViewabilityHelper'; -import type {RenderItemProps, RenderItemType} from './VirtualizedList'; +} from '@react-native/virtualized-lists'; import {type ScrollResponderType} from '../Components/ScrollView/ScrollView'; -import VirtualizedList from './VirtualizedList'; -import {keyExtractor as defaultKeyExtractor} from './VirtualizeUtils'; +import { + VirtualizedList, + keyExtractor as defaultKeyExtractor, +} from '@react-native/virtualized-lists'; 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 10a7c9073257bd..304a1006182186 100644 --- a/Libraries/Lists/FlatList.js.flow +++ b/Libraries/Lists/FlatList.js.flow @@ -14,8 +14,13 @@ 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} from './VirtualizedList'; -import typeof VirtualizedList from './VirtualizedList'; +import type { + RenderItemType, + RenderItemProps, + ViewToken, + ViewabilityConfigCallbackPair, +} from '@react-native/virtualized-lists'; +import {typeof VirtualizedList} from '@react-native/virtualized-lists'; type RequiredProps = {| /** diff --git a/Libraries/Lists/SectionList.d.ts b/Libraries/Lists/SectionList.d.ts index ae1b10df46a10d..7ff5bbb0a3cbe1 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 './VirtualizedList'; +} from '@react-native/virtualized-lists'; import type { ScrollView, ScrollViewProps, diff --git a/Libraries/Lists/SectionList.js b/Libraries/Lists/SectionList.js index d452ee2b7f6419..0f199487b92a23 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, -} from './VirtualizedSectionList'; + VirtualizedSectionListProps, +} from '@react-native/virtualized-lists'; import Platform from '../Utilities/Platform'; -import VirtualizedSectionList from './VirtualizedSectionList'; +import {VirtualizedSectionList} from '@react-native/virtualized-lists'; import * as React from 'react'; type Item = any; diff --git a/Libraries/Lists/SectionListModern.js b/Libraries/Lists/SectionListModern.js index c7856e13d9a666..d9676f106f8cfc 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, -} from './VirtualizedSectionList'; + VirtualizedSectionListProps, +} from '@react-native/virtualized-lists'; import type {AbstractComponent, Element, ElementRef} from 'react'; import Platform from '../Utilities/Platform'; -import VirtualizedSectionList from './VirtualizedSectionList'; +import {VirtualizedSectionList} from '@react-native/virtualized-lists'; import React, {forwardRef, useImperativeHandle, useRef} from 'react'; type Item = any; diff --git a/Libraries/Lists/ViewabilityHelper.js b/Libraries/Lists/ViewabilityHelper.js index 33a9811825affd..9a0a5aa694fb96 100644 --- a/Libraries/Lists/ViewabilityHelper.js +++ b/Libraries/Lists/ViewabilityHelper.js @@ -10,351 +10,14 @@ 'use strict'; -import type {FrameMetricProps} from './VirtualizedListProps'; +export type { + ViewToken, + ViewabilityConfigCallbackPair, +} from '@react-native/virtualized-lists'; -const invariant = require('invariant'); +import {typeof ViewabilityHelper as ViewabilityHelperType} from '@react-native/virtualized-lists'; -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; -} +const ViewabilityHelper: ViewabilityHelperType = + require('@react-native/virtualized-lists').ViewabilityHelper; module.exports = ViewabilityHelper; diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index efce2bed49bfcd..2488b1e5e37f57 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -8,1945 +8,16 @@ * @format */ -import type {ScrollResponderType} from '../Components/ScrollView/ScrollView'; -import type {ViewStyleProp} from '../StyleSheet/StyleSheet'; -import type {LayoutEvent, ScrollEvent} from '../Types/CoreEventTypes'; -import type {ViewToken} from './ViewabilityHelper'; -import type { - FrameMetricProps, - Item, - Props, - RenderItemProps, - RenderItemType, - Separators, -} from './VirtualizedListProps'; - -import RefreshControl from '../Components/RefreshControl/RefreshControl'; -import ScrollView from '../Components/ScrollView/ScrollView'; -import View from '../Components/View/View'; -import Batchinator from '../Interaction/Batchinator'; -import {findNodeHandle} from '../ReactNative/RendererProxy'; -import flattenStyle from '../StyleSheet/flattenStyle'; -import StyleSheet from '../StyleSheet/StyleSheet'; -import clamp from '../Utilities/clamp'; -import infoLog from '../Utilities/infoLog'; -import {CellRenderMask} from './CellRenderMask'; -import ChildListCollection from './ChildListCollection'; -import FillRateHelper from './FillRateHelper'; -import StateSafePureComponent from './StateSafePureComponent'; -import ViewabilityHelper from './ViewabilityHelper'; -import CellRenderer from './VirtualizedListCellRenderer'; -import { - VirtualizedListCellContextProvider, - VirtualizedListContext, - VirtualizedListContextProvider, -} from './VirtualizedListContext.js'; -import { - computeWindowedRenderLimits, - keyExtractor as defaultKeyExtractor, -} from './VirtualizeUtils'; -import invariant from 'invariant'; -import * as React from 'react'; - -export type {RenderItemProps, RenderItemType, Separators}; - -const ON_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; - } +'use strict'; - componentDidMount() { - if (this._isNestedWithSameOrientation()) { - this.context.registerAsNestedChild({ - ref: this, - cellKey: this.context.cellKey, - }); - } - } +import {typeof VirtualizedList as VirtualizedListType} from '@react-native/virtualized-lists'; - componentWillUnmount() { - if (this._isNestedWithSameOrientation()) { - this.context.unregisterAsNestedChild({ref: this}); - } - this._updateCellsToRenderBatcher.dispose({abort: true}); - this._viewabilityTuples.forEach(tuple => { - tuple.viewabilityHelper.dispose(); - }); - this._fillRateHelper.deactivateAndFlush(); - } +const VirtualizedList: VirtualizedListType = + require('@react-native/virtualized-lists').VirtualizedList; - 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, - }, -}); +export type { + RenderItemProps, + RenderItemType, + Separators, +} from '@react-native/virtualized-lists'; +module.exports = VirtualizedList; diff --git a/Libraries/Lists/VirtualizedSectionList.js b/Libraries/Lists/VirtualizedSectionList.js index e4bf2fe58a0c44..90c187bc659bbe 100644 --- a/Libraries/Lists/VirtualizedSectionList.js +++ b/Libraries/Lists/VirtualizedSectionList.js @@ -8,610 +8,11 @@ * @format */ -import type {ViewToken} from './ViewabilityHelper'; +'use strict'; -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'; +import {typeof VirtualizedSectionList as VirtualizedSectionListType} from '@react-native/virtualized-lists'; -type Item = any; +const VirtualizedSectionList: VirtualizedSectionListType = + require('@react-native/virtualized-lists').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, - ... - }>, ->); +module.exports = VirtualizedSectionList; diff --git a/Libraries/Modal/Modal.js b/Libraries/Modal/Modal.js index 46e9e714d29262..9750d2e5be31d3 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 5dc2221528dcee..8671575054137c 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/index.js b/index.js index 0b6e97c0522640..7149c6463b52fa 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').default; + return require('./Libraries/Lists/VirtualizedList'); }, get VirtualizedSectionList(): VirtualizedSectionList { return require('./Libraries/Lists/VirtualizedSectionList'); diff --git a/package.json b/package.json index 109d1dd68e389f..225346a4a1d73d 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,7 @@ "@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 25c511e570a3eb..97f991265de3ba 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/VirtualizedListProps'; +import type {RenderItemProps} from 'react-native/Libraries/Lists/VirtualizedList'; 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 bb53258da9c82c..cd1f96ded9d3af 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 '../../../../../Libraries/Lists/ViewabilityHelper'; -import type {RenderItemProps} from '../../../../../Libraries/Lists/VirtualizedListProps'; +import type {ViewToken} from 'react-native/Libraries/Lists/ViewabilityHelper'; +import type {RenderItemProps} from 'react-native/Libraries/Lists/VirtualizedList'; 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 76e29fac105fe1..f65111e16596ba 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 '../../../../../Libraries/Lists/ViewabilityHelper'; +import type {ViewToken} from 'react-native/Libraries/Lists/ViewabilityHelper'; import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; import BaseFlatListExample from './BaseFlatListExample'; diff --git a/Libraries/Interaction/Batchinator.js b/packages/virtualized-lists/Interaction/Batchinator.js similarity index 97% rename from Libraries/Interaction/Batchinator.js rename to packages/virtualized-lists/Interaction/Batchinator.js index 2ca2d7986d1a78..4fbc1931ca5708 100644 --- a/Libraries/Interaction/Batchinator.js +++ b/packages/virtualized-lists/Interaction/Batchinator.js @@ -10,7 +10,7 @@ 'use strict'; -const InteractionManager = require('./InteractionManager'); +const {InteractionManager} = require('react-native'); /** * A simple class for batching up invocations of a low-pri callback. A timeout is set to run the diff --git a/Libraries/Interaction/__tests__/Batchinator-test.js b/packages/virtualized-lists/Interaction/__tests__/Batchinator-test.js similarity index 95% rename from Libraries/Interaction/__tests__/Batchinator-test.js rename to packages/virtualized-lists/Interaction/__tests__/Batchinator-test.js index e8261b3515e23f..b680e98c507d00 100644 --- a/Libraries/Interaction/__tests__/Batchinator-test.js +++ b/packages/virtualized-lists/Interaction/__tests__/Batchinator-test.js @@ -10,10 +10,6 @@ 'use strict'; -jest - .mock('../../vendor/core/ErrorUtils') - .mock('../../BatchedBridge/BatchedBridge'); - function expectToBeCalledOnce(fn) { expect(fn.mock.calls.length).toBe(1); } diff --git a/Libraries/Lists/CellRenderMask.js b/packages/virtualized-lists/Lists/CellRenderMask.js similarity index 100% rename from Libraries/Lists/CellRenderMask.js rename to packages/virtualized-lists/Lists/CellRenderMask.js diff --git a/Libraries/Lists/ChildListCollection.js b/packages/virtualized-lists/Lists/ChildListCollection.js similarity index 100% rename from Libraries/Lists/ChildListCollection.js rename to packages/virtualized-lists/Lists/ChildListCollection.js diff --git a/Libraries/Lists/FillRateHelper.js b/packages/virtualized-lists/Lists/FillRateHelper.js similarity index 100% rename from Libraries/Lists/FillRateHelper.js rename to packages/virtualized-lists/Lists/FillRateHelper.js diff --git a/Libraries/Lists/StateSafePureComponent.js b/packages/virtualized-lists/Lists/StateSafePureComponent.js similarity index 100% rename from Libraries/Lists/StateSafePureComponent.js rename to packages/virtualized-lists/Lists/StateSafePureComponent.js diff --git a/packages/virtualized-lists/Lists/ViewabilityHelper.js b/packages/virtualized-lists/Lists/ViewabilityHelper.js new file mode 100644 index 00000000000000..33a9811825affd --- /dev/null +++ b/packages/virtualized-lists/Lists/ViewabilityHelper.js @@ -0,0 +1,360 @@ +/** + * 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/Libraries/Lists/VirtualizeUtils.js b/packages/virtualized-lists/Lists/VirtualizeUtils.js similarity index 100% rename from Libraries/Lists/VirtualizeUtils.js rename to packages/virtualized-lists/Lists/VirtualizeUtils.js diff --git a/Libraries/Lists/VirtualizedList.d.ts b/packages/virtualized-lists/Lists/VirtualizedList.d.ts similarity index 97% rename from Libraries/Lists/VirtualizedList.d.ts rename to packages/virtualized-lists/Lists/VirtualizedList.d.ts index d874dab4d10271..a2702d9101e107 100644 --- a/Libraries/Lists/VirtualizedList.d.ts +++ b/packages/virtualized-lists/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, - ScrollViewProps, -} from '../Components/ScrollView/ScrollView'; -import type {View} from '../Components/View/View'; +} from 'react-native'; export interface ViewToken { item: any; diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js new file mode 100644 index 00000000000000..b93878c5658295 --- /dev/null +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -0,0 +1,1955 @@ +/** + * 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/Libraries/Lists/VirtualizedListCellRenderer.js b/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js similarity index 96% rename from Libraries/Lists/VirtualizedListCellRenderer.js rename to packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js index b16900d63b742b..0064f788b8a9c4 100644 --- a/Libraries/Lists/VirtualizedListCellRenderer.js +++ b/packages/virtualized-lists/Lists/VirtualizedListCellRenderer.js @@ -8,13 +8,15 @@ * @format */ -import type {ViewStyleProp} from '../StyleSheet/StyleSheet'; -import type {FocusEvent, LayoutEvent} from '../Types/CoreEventTypes'; +import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet'; +import type { + FocusEvent, + LayoutEvent, +} from 'react-native/Libraries/Types/CoreEventTypes'; import type FillRateHelper from './FillRateHelper'; import type {RenderItemType} from './VirtualizedListProps'; -import View from '../Components/View/View'; -import StyleSheet from '../StyleSheet/StyleSheet'; +import {View, StyleSheet} from 'react-native'; import {VirtualizedListCellContextProvider} from './VirtualizedListContext.js'; import invariant from 'invariant'; import * as React from 'react'; diff --git a/Libraries/Lists/VirtualizedListContext.js b/packages/virtualized-lists/Lists/VirtualizedListContext.js similarity index 100% rename from Libraries/Lists/VirtualizedListContext.js rename to packages/virtualized-lists/Lists/VirtualizedListContext.js diff --git a/Libraries/Lists/VirtualizedListProps.js b/packages/virtualized-lists/Lists/VirtualizedListProps.js similarity index 98% rename from Libraries/Lists/VirtualizedListProps.js rename to packages/virtualized-lists/Lists/VirtualizedListProps.js index f4d497b1d467a8..bfc59673270a57 100644 --- a/Libraries/Lists/VirtualizedListProps.js +++ b/packages/virtualized-lists/Lists/VirtualizedListProps.js @@ -8,8 +8,8 @@ * @format */ -import typeof ScrollView from '../Components/ScrollView/ScrollView'; -import type {ViewStyleProp} from '../StyleSheet/StyleSheet'; +import {typeof ScrollView} from 'react-native'; +import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet'; import type { ViewabilityConfig, ViewabilityConfigCallbackPair, diff --git a/packages/virtualized-lists/Lists/VirtualizedSectionList.js b/packages/virtualized-lists/Lists/VirtualizedSectionList.js new file mode 100644 index 00000000000000..61519bca4dc866 --- /dev/null +++ b/packages/virtualized-lists/Lists/VirtualizedSectionList.js @@ -0,0 +1,617 @@ +/** + * 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/Libraries/Lists/__tests__/CellRenderMask-test.js b/packages/virtualized-lists/Lists/__tests__/CellRenderMask-test.js similarity index 100% rename from Libraries/Lists/__tests__/CellRenderMask-test.js rename to packages/virtualized-lists/Lists/__tests__/CellRenderMask-test.js diff --git a/Libraries/Lists/__tests__/FillRateHelper-test.js b/packages/virtualized-lists/Lists/__tests__/FillRateHelper-test.js similarity index 100% rename from Libraries/Lists/__tests__/FillRateHelper-test.js rename to packages/virtualized-lists/Lists/__tests__/FillRateHelper-test.js diff --git a/Libraries/Lists/__tests__/ViewabilityHelper-test.js b/packages/virtualized-lists/Lists/__tests__/ViewabilityHelper-test.js similarity index 100% rename from Libraries/Lists/__tests__/ViewabilityHelper-test.js rename to packages/virtualized-lists/Lists/__tests__/ViewabilityHelper-test.js diff --git a/Libraries/Lists/__tests__/VirtualizeUtils-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js similarity index 100% rename from Libraries/Lists/__tests__/VirtualizeUtils-test.js rename to packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js diff --git a/Libraries/Lists/__tests__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js similarity index 100% rename from Libraries/Lists/__tests__/VirtualizedList-test.js rename to packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js diff --git a/Libraries/Lists/__tests__/VirtualizedSectionList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js similarity index 100% rename from Libraries/Lists/__tests__/VirtualizedSectionList-test.js rename to packages/virtualized-lists/Lists/__tests__/VirtualizedSectionList-test.js diff --git a/Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap b/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap similarity index 100% rename from Libraries/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap rename to packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap diff --git a/Libraries/Lists/__tests__/__snapshots__/VirtualizedSectionList-test.js.snap b/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedSectionList-test.js.snap similarity index 100% rename from Libraries/Lists/__tests__/__snapshots__/VirtualizedSectionList-test.js.snap rename to packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedSectionList-test.js.snap diff --git a/Libraries/Utilities/__tests__/clamp-test.js b/packages/virtualized-lists/Utilities/__tests__/clamp-test.js similarity index 100% rename from Libraries/Utilities/__tests__/clamp-test.js rename to packages/virtualized-lists/Utilities/__tests__/clamp-test.js diff --git a/Libraries/Utilities/clamp.js b/packages/virtualized-lists/Utilities/clamp.js similarity index 100% rename from Libraries/Utilities/clamp.js rename to packages/virtualized-lists/Utilities/clamp.js diff --git a/packages/virtualized-lists/Utilities/infoLog.js b/packages/virtualized-lists/Utilities/infoLog.js new file mode 100644 index 00000000000000..6cb6df8d414971 --- /dev/null +++ b/packages/virtualized-lists/Utilities/infoLog.js @@ -0,0 +1,20 @@ +/** + * 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 new file mode 100644 index 00000000000000..c66fc20521e439 --- /dev/null +++ b/packages/virtualized-lists/index.d.ts @@ -0,0 +1,10 @@ +/** + * 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 new file mode 100644 index 00000000000000..31d47809b369d6 --- /dev/null +++ b/packages/virtualized-lists/index.js @@ -0,0 +1,51 @@ +/** + * 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'; + +export type { + ViewToken, + ViewabilityConfigCallbackPair, +} from './Lists/ViewabilityHelper'; +export type { + RenderItemProps, + RenderItemType, + Separators, +} from './Lists/VirtualizedListProps'; +export type { + Props as VirtualizedSectionListProps, + ScrollToLocationParamsType, + SectionBase, +} from './Lists/VirtualizedSectionList'; + +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'); + }, +}; diff --git a/packages/virtualized-lists/package.json b/packages/virtualized-lists/package.json new file mode 100644 index 00000000000000..64480808cb2664 --- /dev/null +++ b/packages/virtualized-lists/package.json @@ -0,0 +1,18 @@ +{ + "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", + "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 97b34d5d2321db..349efc65a9fc8c 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 '../Libraries/Lists/VirtualizedList'; +export * from '@react-native/virtualized-lists'; export * from '../Libraries/LogBox/LogBox'; export * from '../Libraries/Modal/Modal'; export * as Systrace from '../Libraries/Performance/Systrace';