/** * 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 {CellMetricProps, ListOrientation} from './ListMetricsAggregator'; import type {ViewToken} from './ViewabilityHelper'; import type { Item, Props, RenderItemProps, RenderItemType, Separators, } from './VirtualizedListProps'; 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 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 ListMetricsAggregator from './ListMetricsAggregator'; import StateSafePureComponent from './StateSafePureComponent'; import ViewabilityHelper from './ViewabilityHelper'; import CellRenderer from './VirtualizedListCellRenderer'; import { VirtualizedListCellContextProvider, VirtualizedListContext, VirtualizedListContextProvider, } from './VirtualizedListContext.js'; import { horizontalOrDefault, initialNumToRenderOrDefault, maxToRenderPerBatchOrDefault, onEndReachedThresholdOrDefault, onStartReachedThresholdOrDefault, windowSizeOrDefault, } from './VirtualizedListProps'; import { computeWindowedRenderLimits, keyExtractor as defaultKeyExtractor, } from './VirtualizeUtils'; import invariant from 'invariant'; import nullthrows from 'nullthrows'; import * as React from 'react'; import { I18nManager, Platform, RefreshControl, ScrollView, StyleSheet, View, findNodeHandle, } from 'react-native'; 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}, // Used to track items added at the start of the list for maintainVisibleContentPosition. firstVisibleItemKey: ?string, // When > 0 the scroll position available in JS is considered stale and should not be used. pendingScrollUpdateCount: number, }; function getScrollingThreshold(threshold: number, visibleLength: number) { return (threshold * visibleLength) / 2; } /** * 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._listMetrics.getCellMetricsApprox(veryLast, this.props); const offset = Math.max( 0, frame.offset + frame.length + this._footerLength - this._scrollMetrics.visibleLength, ); // TODO: consider using `ref.scrollToEnd` directly this.scrollToOffset({animated, offset}); } // scrollToIndex may be janky without getItemLayout prop scrollToIndex(params: { animated?: ?boolean, index: number, viewOffset?: number, viewPosition?: number, ... }): $FlowFixMe { const {data, 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._listMetrics.getHighestMeasuredCellIndex() ) { 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._listMetrics.getAverageCellLength(), highestMeasuredFrameIndex: this._listMetrics.getHighestMeasuredCellIndex(), index, }); return; } const frame = this._listMetrics.getCellMetricsApprox( Math.floor(index), this.props, ); const offset = Math.max( 0, this._listMetrics.getCellOffsetApprox(index, this.props) - (viewPosition || 0) * (this._scrollMetrics.visibleLength - frame.length), ) - (viewOffset || 0); this.scrollToOffset({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; const scrollRef = this._scrollRef; if (scrollRef == null) { return; } if (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; } const {horizontal, rtl} = this._orientation(); if (horizontal && rtl && !this._listMetrics.hasContentLength()) { console.warn( 'scrollToOffset may not be called in RTL before content is laid out', ); return; } scrollRef.scrollTo({ animated, ...this._scrollToParamsFromOffset(offset), }); } _scrollToParamsFromOffset(offset: number): {x?: number, y?: number} { const {horizontal, rtl} = this._orientation(); if (horizontal && rtl) { // Add the visible length of the scrollview so that the offset is right-aligned const cartOffset = this._listMetrics.cartesianOffset( offset + this._scrollMetrics.visibleLength, ); return horizontal ? {x: cartOffset} : {y: cartOffset}; } else { return horizontal ? {x: offset} : {y: offset}; } } 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: VirtualizedList, }): void => { this._nestedChildLists.add(childList.ref, childList.cellKey); if (this._hasInteracted) { childList.ref.recordInteraction(); } }; _unregisterAsNestedChild = (childList: {ref: VirtualizedList}): void => { this._nestedChildLists.remove(childList.ref); }; state: State; constructor(props: Props) { super(props); this._checkProps(props); this._fillRateHelper = new FillRateHelper(this._listMetrics); 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, }); } } const initialRenderRegion = VirtualizedList._initialRenderRegion(props); const minIndexForVisible = this.props.maintainVisibleContentPosition?.minIndexForVisible ?? 0; this.state = { cellsAroundViewport: initialRenderRegion, renderMask: VirtualizedList._createRenderMask(props, initialRenderRegion), firstVisibleItemKey: this.props.getItemCount(this.props.data) > minIndexForVisible ? VirtualizedList._getItemKey(this.props, minIndexForVisible) : null, // When we have a non-zero initialScrollIndex, we will receive a // scroll event later so this will prevent the window from updating // until we get a valid offset. pendingScrollUpdateCount: this.props.initialScrollIndex != null && this.props.initialScrollIndex > 0 ? 1 : 0, }; } _checkProps(props: Props) { const {onScroll, windowSize, getItemCount, data, initialScrollIndex} = props; invariant( // $FlowFixMe[prop-missing] !onScroll || !onScroll.__isNative, 'Components based on VirtualizedList must be wrapped with Animated.createAnimatedComponent ' + 'to support native onScroll events with useNativeDriver', ); invariant( windowSizeOrDefault(windowSize) > 0, 'VirtualizedList: The windowSize prop must be present and set to a value greater than 0.', ); invariant( getItemCount, 'VirtualizedList: The "getItemCount" prop must be provided', ); const itemCount = getItemCount(data); if ( initialScrollIndex != null && !this._hasTriggeredInitialScrollToIndex && (initialScrollIndex < 0 || (itemCount > 0 && initialScrollIndex >= itemCount)) && !this._hasWarned.initialScrollIndex ) { console.warn( `initialScrollIndex "${initialScrollIndex}" is not valid (list has ${itemCount} items)`, ); this._hasWarned.initialScrollIndex = true; } if (__DEV__ && !this._hasWarned.flexWrap) { // $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.', ); this._hasWarned.flexWrap = true; } } } static _findItemIndexWithKey( props: Props, key: string, hint: ?number, ): ?number { const itemCount = props.getItemCount(props.data); if (hint != null && hint >= 0 && hint < itemCount) { const curKey = VirtualizedList._getItemKey(props, hint); if (curKey === key) { return hint; } } for (let ii = 0; ii < itemCount; ii++) { const curKey = VirtualizedList._getItemKey(props, ii); if (curKey === key) { return ii; } } return null; } static _getItemKey( props: { data: Props['data'], getItem: Props['getItem'], keyExtractor: Props['keyExtractor'], ... }, index: number, ): string { const item = props.getItem(props.data, index); return VirtualizedList._keyExtractor(item, index, props); } 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 firstCellIndex = Math.max( 0, Math.min(itemCount - 1, Math.floor(props.initialScrollIndex ?? 0)), ); const lastCellIndex = Math.min( itemCount, firstCellIndex + initialNumToRenderOrDefault(props.initialNumToRender), ) - 1; return { first: firstCellIndex, last: lastCellIndex, }; } 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}, pendingScrollUpdateCount: number, ): {first: number, last: number} { const {data, getItemCount} = props; const onEndReachedThreshold = onEndReachedThresholdOrDefault( props.onEndReachedThreshold, ); const {offset, visibleLength} = this._scrollMetrics; const contentLength = this._listMetrics.getContentLength(); 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 pending scroll update, we should not adjust the render window as it // might override the correct window. if (pendingScrollUpdateCount > 0) { return cellsAroundViewport.last >= getItemCount(data) ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props) : cellsAroundViewport; } newCellsAroundViewport = computeWindowedRenderLimits( props, maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch), windowSizeOrDefault(props.windowSize), cellsAroundViewport, this._listMetrics, 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(); 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; } let maintainVisibleContentPositionAdjustment: ?number = null; const prevFirstVisibleItemKey = prevState.firstVisibleItemKey; const minIndexForVisible = newProps.maintainVisibleContentPosition?.minIndexForVisible ?? 0; const newFirstVisibleItemKey = newProps.getItemCount(newProps.data) > minIndexForVisible ? VirtualizedList._getItemKey(newProps, minIndexForVisible) : null; if ( newProps.maintainVisibleContentPosition != null && prevFirstVisibleItemKey != null && newFirstVisibleItemKey != null ) { if (newFirstVisibleItemKey !== prevFirstVisibleItemKey) { // Fast path if items were added at the start of the list. const hint = itemCount - prevState.renderMask.numCells() + minIndexForVisible; const firstVisibleItemIndex = VirtualizedList._findItemIndexWithKey( newProps, prevFirstVisibleItemKey, hint, ); maintainVisibleContentPositionAdjustment = firstVisibleItemIndex != null ? firstVisibleItemIndex - minIndexForVisible : null; } else { maintainVisibleContentPositionAdjustment = null; } } const constrainedCells = VirtualizedList._constrainToItemCount( maintainVisibleContentPositionAdjustment != null ? { first: prevState.cellsAroundViewport.first + maintainVisibleContentPositionAdjustment, last: prevState.cellsAroundViewport.last + maintainVisibleContentPositionAdjustment, } : prevState.cellsAroundViewport, newProps, ); return { cellsAroundViewport: constrainedCells, renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells), firstVisibleItemKey: newFirstVisibleItemKey, pendingScrollUpdateCount: maintainVisibleContentPositionAdjustment != null ? prevState.pendingScrollUpdateCount + 1 : prevState.pendingScrollUpdateCount, }; } _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 = VirtualizedList._keyExtractor(item, ii, this.props); this._indicesToKeys.set(ii, key); if (stickyIndicesFromProps.has(ii + stickyOffset)) { stickyHeaderIndices.push(cells.length); } const shouldListenForLayout = getItemLayout == null || debug || this._fillRateHelper.enabled(); cells.push( { this._cellRefs[key] = ref; }} renderItem={renderItem} {...(shouldListenForLayout && { onCellLayout: this._onCellLayout, })} />, ); prevCellKey = key; } } static _constrainToItemCount( cells: {first: number, last: number}, props: Props, ): {first: number, last: number} { const itemCount = props.getItemCount(props.data); const lastPossibleCellIndex = itemCount - 1; // Constraining `last` may significantly shrink the window. Adjust `first` // to expand the window if the new `last` results in a new window smaller // than the number of cells rendered per batch. const maxToRenderPerBatch = maxToRenderPerBatchOrDefault( props.maxToRenderPerBatch, ); const maxFirst = Math.max(0, lastPossibleCellIndex - maxToRenderPerBatch); return { first: clamp(0, cells.first, maxFirst), last: Math.min(lastPossibleCellIndex, cells.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'; static _keyExtractor( item: Item, index: number, props: { keyExtractor?: ?(item: Item, index: number) => string, ... }, ): string { 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 { this._checkProps(this.props); 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: ExactReactElement_DEPRECATED = ((React.isValidElement( ListEmptyComponent, ) ? ( ListEmptyComponent ) : ( // $FlowFixMe[not-a-component] // $FlowFixMe[incompatible-type-arg] )): any); cells.push( {React.cloneElement(element, { onLayout: (event: LayoutEvent) => { this._onLayoutEmpty(event); // $FlowFixMe[prop-missing] React.Element internal inspection if (element.props.onLayout) { element.props.onLayout(event); } }, // $FlowFixMe[prop-missing] React.Element internal inspection 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 lastRegion = renderRegions[renderRegions.length - 1]; const lastSpacer = lastRegion?.isSpacer ? lastRegion : null; 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._listMetrics.getHighestMeasuredCellIndex(), ) : section.last; const firstMetrics = this._listMetrics.getCellMetricsApprox( section.first, this.props, ); const lastMetrics = this._listMetrics.getCellMetricsApprox( 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, // iOS/macOS requires a non-zero scrollEventThrottle to fire more than a // single notification while scrolling. This will otherwise no-op. scrollEventThrottle: this.props.scrollEventThrottle ?? 0.0001, invertStickyHeaders: this.props.invertStickyHeaders !== undefined ? this.props.invertStickyHeaders : this.props.inverted, stickyHeaderIndices, style: inversionStyle ? [inversionStyle, this.props.style] : this.props.style, isInvertedVirtualizedList: this.props.inverted, maintainVisibleContentPosition: this.props.maintainVisibleContentPosition != null ? { ...this.props.maintainVisibleContentPosition, // Adjust index to account for ListHeaderComponent. minIndexForVisible: this.props.maintainVisibleContentPosition.minIndexForVisible + (this.props.ListHeaderComponent ? 1 : 0), } : undefined, }; 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 ) { 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, getItemLayout} = 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; } // We only call `onEndReached` after we render the last cell, but when // getItemLayout is present, we can scroll past the last rendered cell, and // never trigger a new layout or bounds change, so we need to check again // after rendering more cells. if (getItemLayout != null) { this._maybeCallOnEdgeReached(); } } _cellRefs: {[string]: null | CellRenderer} = {}; _fillRateHelper: FillRateHelper; _listMetrics: ListMetricsAggregator = new ListMetricsAggregator(); _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 _indicesToKeys: Map = new Map(); _lastFocusedCellKey: ?string = null; _nestedChildLists: ChildListCollection = new ChildListCollection(); _offsetFromParentVirtualizedList: number = 0; _pendingViewabilityUpdate: boolean = false; _prevParentOffset: number = 0; _scrollMetrics: { dOffset: number, dt: number, offset: number, timestamp: number, velocity: number, visibleLength: number, zoomScale: number, } = { dOffset: 0, dt: 10, offset: 0, timestamp: 0, velocity: 0, visibleLength: 0, zoomScale: 1, }; _scrollRef: ?React.ElementRef = null; _sentStartForContentLength = 0; _sentEndForContentLength = 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()) { // Prevent VirtualizedList._onContentSizeChange from being triggered by a bubbling onContentSizeChange event. // This could lead to internal inconsistencies within VirtualizedList. const {onContentSizeChange, ...otherProps} = props; 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, cellIndex: number, ): void => { const layoutHasChanged = this._listMetrics.notifyCellLayout({ cellIndex, cellKey, layout: e.nativeEvent.layout, orientation: this._orientation(), }); if (layoutHasChanged) { this._scheduleCellsToRenderUpdate(); } this._triggerRemeasureForChildListsInCell(cellKey); this._computeBlankness(); this._updateViewableItems(this.props, this.state.cellsAroundViewport); }; _onCellFocusCapture = (cellKey: string) => { this._lastFocusedCellKey = cellKey; this._updateCellsToRender(); }; _onCellUnmount = (cellKey: string) => { delete this._cellRefs[cellKey]; this._listMetrics.notifyCellUnmounted(cellKey); }; _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._listMetrics.notifyListContentLayout({ layout: {width, height}, orientation: this._orientation(), }); 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._listMetrics.getContentLength() || 1); const framesInLayout = []; const itemCount = this.props.getItemCount(this.props.data); for (let ii = 0; ii < itemCount; ii++) { const frame = this._listMetrics.getCellMetricsApprox(ii, this.props); if (frame.isMounted) { framesInLayout.push(frame); } } const windowTop = this._listMetrics.getCellMetricsApprox( this.state.cellsAroundViewport.first, this.props, ).offset; const frameLast = this._listMetrics.getCellMetricsApprox( 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({x, y}: $ReadOnly<{x: number, y: number, ...}>): number { return this._orientation().horizontal ? x : y; } _orientation(): ListOrientation { return { horizontal: horizontalOrDefault(this.props.horizontal), rtl: I18nManager.isRTL, }; } _maybeCallOnEdgeReached() { const { data, getItemCount, onStartReached, onStartReachedThreshold, onEndReached, onEndReachedThreshold, } = this.props; // Wait until we have real metrics if ( !this._listMetrics.hasContentLength() || this._scrollMetrics.visibleLength === 0 ) { return; } // If we have any pending scroll updates it means that the scroll metrics // are out of date and we should not call any of the edge reached callbacks. if (this.state.pendingScrollUpdateCount > 0) { return; } const {visibleLength, offset} = this._scrollMetrics; let distanceFromStart = offset; let distanceFromEnd = this._listMetrics.getContentLength() - 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._listMetrics.getContentLength() !== this._sentEndForContentLength ) { this._sentEndForContentLength = this._listMetrics.getContentLength(); 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 if ( onStartReached != null && this.state.cellsAroundViewport.first === 0 && isWithinStartThreshold && this._listMetrics.getContentLength() !== this._sentStartForContentLength ) { this._sentStartForContentLength = this._listMetrics.getContentLength(); onStartReached({distanceFromStart}); } // If the user scrolls away from the start or end and back again, // cause onStartReached or onEndReached to be triggered again if (!isWithinStartThreshold) { this._sentStartForContentLength = 0; } if (!isWithinEndThreshold) { this._sentEndForContentLength = 0; } } _onContentSizeChange = (width: number, height: number) => { this._listMetrics.notifyListContentLayout({ layout: {width, height}, orientation: this._orientation(), }); this._maybeScrollToInitialScrollIndex(width, height); if (this.props.onContentSizeChange) { this.props.onContentSizeChange(width, height); } this._scheduleCellsToRenderUpdate(); this._maybeCallOnEdgeReached(); }; /** * Scroll to a specified `initialScrollIndex` prop after the ScrollView * content has been laid out, if it is still valid. Only a single scroll is * triggered throughout the lifetime of the list. */ _maybeScrollToInitialScrollIndex( contentWidth: number, contentHeight: number, ) { if ( contentWidth > 0 && contentHeight > 0 && this.props.initialScrollIndex != null && this.props.initialScrollIndex > 0 && !this._hasTriggeredInitialScrollToIndex ) { if (this.props.contentOffset == null) { if ( this.props.initialScrollIndex < this.props.getItemCount(this.props.data) ) { this.scrollToIndex({ animated: false, index: nullthrows(this.props.initialScrollIndex), }); } else { this.scrollToEnd({animated: false}); } } this._hasTriggeredInitialScrollToIndex = true; } } /* 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._listMetrics.getContentLength(); return { visibleLength, contentLength, offset, dOffset, }; }; unstable_onScroll(e: Object) { this._onScroll(e); } _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._offsetFromScrollEvent(e); let dOffset = offset - this._scrollMetrics.offset; if (this._isNestedWithSameOrientation()) { if (this._listMetrics.getContentLength() === 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 = { dt, dOffset, offset, timestamp, velocity, visibleLength, zoomScale, }; if (this.state.pendingScrollUpdateCount > 0) { this.setState(state => ({ pendingScrollUpdateCount: state.pendingScrollUpdateCount - 1, })); } this._updateViewableItems(this.props, this.state.cellsAroundViewport); if (!this.props) { return; } this._maybeCallOnEdgeReached(); if (velocity !== 0) { this._fillRateHelper.activate(); } this._computeBlankness(); this._scheduleCellsToRenderUpdate(); }; _offsetFromScrollEvent(e: ScrollEvent): number { const {contentOffset, contentSize, layoutMeasurement} = e.nativeEvent; const {horizontal, rtl} = this._orientation(); if (horizontal && rtl) { return ( this._selectLength(contentSize) - (this._selectOffset(contentOffset) + this._selectLength(layoutMeasurement)) ); } else { return this._selectOffset(contentOffset); } } _scheduleCellsToRenderUpdate() { // 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 ( (this._listMetrics.getAverageCellLength() > 0 || this.props.getItemLayout != null) && this._shouldRenderWithPriority() && !this._hiPriInProgress ) { this._hiPriInProgress = true; // Don't worry about interactions when scrolling quickly; focus on filling content as fast // as possible. this._updateCellsToRenderBatcher.dispose(); this._updateCellsToRender(); return; } else { this._updateCellsToRenderBatcher.schedule(); } } _shouldRenderWithPriority(): boolean { 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._listMetrics.getCellMetricsApprox(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._listMetrics.getCellMetricsApprox(last, this.props).offset - (offset + visibleLength); hiPri = distBottom < 0 || (velocity > 2 && distBottom < getScrollingThreshold(onEndReachedThreshold, visibleLength)); } return hiPri; } unstable_onScrollBeginDrag(e: ScrollEvent) { this._onScrollBeginDrag(e); } _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); }; unstable_onScrollEndDrag(e: ScrollEvent) { this._onScrollEndDrag(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); }; unstable_onMomentumScrollBegin(e: ScrollEvent) { this._onMomentumScrollBegin(e); } _onMomentumScrollBegin = (e: ScrollEvent): void => { this._nestedChildLists.forEach(childList => { childList._onMomentumScrollBegin(e); }); this.props.onMomentumScrollBegin && this.props.onMomentumScrollBegin(e); }; unstable_onMomentumScrollEnd(e: ScrollEvent) { this._onMomentumScrollEnd(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._updateViewableItems(this.props, this.state.cellsAroundViewport); this.setState((state, props) => { const cellsAroundViewport = this._adjustCellsAroundViewport( props, state.cellsAroundViewport, state.pendingScrollUpdateCount, ); 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: CellMetricProps, // $FlowFixMe[missing-local-annot] ) => { const {data, getItem} = props; const item = getItem(data, index); return { index, item, key: VirtualizedList._keyExtractor(item, index, props), isViewable, }; }; __getListMetrics(): ListMetricsAggregator { return this._listMetrics; } _getNonViewportRenderRegions = ( props: CellMetricProps, ): $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 last cell we rendered may be at a new index. Bail if we don't know // where it is. if ( focusedCellIndex >= itemCount || VirtualizedList._getItemKey(props, 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._listMetrics.getCellMetricsApprox( i, props, ).length; } let last = focusedCellIndex; let heightOfCellsAfterFocused = 0; for ( let i = last + 1; i < itemCount && heightOfCellsAfterFocused < this._scrollMetrics.visibleLength; i++ ) { last++; heightOfCellsAfterFocused += this._listMetrics.getCellMetricsApprox( i, props, ).length; } return [{first, last}]; }; _updateViewableItems( props: CellMetricProps, cellsAroundViewport: {first: number, last: number}, ) { // If we have any pending scroll updates it means that the scroll metrics // are out of date and we should not call any of the visibility callbacks. if (this.state.pendingScrollUpdateCount > 0) { return; } this._viewabilityTuples.forEach(tuple => { tuple.viewabilityHelper.onUpdate( props, this._scrollMetrics.offset, this._scrollMetrics.visibleLength, this._listMetrics, this._createViewToken, tuple.onViewableItemsChanged, cellsAroundViewport, ); }); } } const styles = StyleSheet.create({ verticallyInverted: Platform.OS === 'android' ? {transform: [{scale: -1}]} : {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;