UNPKG

@react-native-mac/virtualized-lists

Version:
1,496 lines (1,363 loc) 71.1 kB
/** * 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 type {KeyEvent} from 'react-native/Libraries/Types/CoreEventTypes'; // [macOS] 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<ViewToken>, changed: Array<ViewToken>, ... }) => void, ... }; type State = { renderMask: CellRenderMask, cellsAroundViewport: {first: number, last: number}, selectedRowIndex: number, // [macOS] // 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 [`<FlatList>`](https://reactnative.dev/docs/flatlist) * and [`<SectionList>`](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<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._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}; } } // [macOS ensureItemAtIndexIsVisible(rowIndex: number) { const frame = this._listMetrics.getCellMetricsApprox(rowIndex, this.props); const visTop = this._scrollMetrics.offset; const visLen = this._scrollMetrics.visibleLength; const visEnd = visTop + visLen; const contentLength = this._listMetrics.getContentLength(); const frameEnd = frame.offset + frame.length; if (frameEnd > visEnd) { const newOffset = Math.min(contentLength, visTop + (frameEnd - visEnd)); this.scrollToOffset({offset: newOffset}); } else if (frame.offset < visTop) { const newOffset = Math.min(frame.offset, visTop - frame.length); this.scrollToOffset({offset: newOffset}); } } selectRowAtIndex(rowIndex: number) { this._selectRowAtIndex(rowIndex); } // macOS] 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<typeof ScrollView> | ?React.ElementRef<typeof View> { 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), selectedRowIndex: this.props.initialSelectedIndex ?? -1, // [macOS] 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<number>, 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), // [macOS selectedRowIndex: Math.max( -1, // Used to indicate no row is selected Math.min(prevState.selectedRowIndex, itemCount), ), // macOS] firstVisibleItemKey: newFirstVisibleItemKey, pendingScrollUpdateCount: maintainVisibleContentPositionAdjustment != null ? prevState.pendingScrollUpdateCount + 1 : prevState.pendingScrollUpdateCount, }; } _pushCells( cells: Array<Object>, stickyHeaderIndices: Array<number>, stickyIndicesFromProps: Set<number>, 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( <CellRenderer CellRendererComponent={CellRendererComponent} ItemSeparatorComponent={ii < end ? ItemSeparatorComponent : undefined} ListItemComponent={ListItemComponent} cellKey={key} horizontal={horizontal} index={ii} inversionStyle={inversionStyle} item={item} // [macOS isSelected={ this.props.enableSelectionOnKeyPress && this.state.selectedRowIndex === ii ? true : false } // macOS] key={key} prevCellKey={prevCellKey} onUpdateSeparators={this._onUpdateSeparators} onCellFocusCapture={this._onCellFocusCapture} onUnmount={this._onCellUnmount} ref={ref => { 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<?string>, 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; // macOS natively supports inverted lists, thus not needing an inversion style const inversionStyle = this.props.inverted && Platform.OS !== 'macos' // [macOS] ? horizontalOrDefault(this.props.horizontal) ? styles.horizontallyInverted : styles.verticallyInverted : null; const cells: Array<any | React.Node> = []; 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] <ListHeaderComponent /> ); cells.push( <VirtualizedListCellContextProvider cellKey={this._getCellKey() + '-header'} key="$header"> <View // We expect that header component will be a single native view so make it // not collapsable to avoid this view being flattened and make this assumption // no longer true. collapsable={false} onLayout={this._onLayoutHeader} style={StyleSheet.compose( inversionStyle, this.props.ListHeaderComponentStyle, )}> { // $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors element } </View> </VirtualizedListCellContextProvider>, ); } // 2a. Add a cell for ListEmptyComponent if applicable const itemCount = this.props.getItemCount(data); if (itemCount === 0 && ListEmptyComponent) { const element: ExactReactElement_DEPRECATED<any> = ((React.isValidElement( ListEmptyComponent, ) ? ( ListEmptyComponent ) : ( // $FlowFixMe[not-a-component] // $FlowFixMe[incompatible-type-arg] <ListEmptyComponent /> )): any); cells.push( // $FlowFixMe[prop-missing] React.Element internal inspection <VirtualizedListCellContextProvider cellKey={this._getCellKey() + '-empty'} collapsable={Platform.OS !== 'macos'} // [macOS] key="$empty"> {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), })} </VirtualizedListCellContextProvider>, ); } // 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( <View collapsable={Platform.OS !== 'macos'} // [macOS] key={`$spacer-${section.first}`} // $FlowFixMe[incompatible-type] style={{[spacerKey]: spacerSize}} />, ); } 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] <ListFooterComponent /> ); cells.push( <VirtualizedListCellContextProvider cellKey={this._getFooterCellKey()} key="$footer"> <View collapsable={Platform.OS !== 'macos'} // [macOS] onLayout={this._onLayoutFooter} style={StyleSheet.compose( inversionStyle, this.props.ListFooterComponentStyle, )}> { // $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors element } </View> </VirtualizedListCellContextProvider>, ); } // 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 = ( <VirtualizedListContextProvider value={{ cellKey: null, getScrollMetrics: this._getScrollMetrics, horizontal: horizontalOrDefault(this.props.horizontal), getOutermostParentListRef: this._getOutermostParentListRef, registerAsNestedChild: this._registerAsNestedChild, unregisterAsNestedChild: this._unregisterAsNestedChild, }}> {React.cloneElement( ( this.props.renderScrollComponent || this._defaultRenderScrollComponent )(scrollProps), { ref: this._captureScrollRef, }, cells, )} </VirtualizedListContextProvider> ); let ret: React.Node = innerRet; if (__DEV__) { ret = ( <ScrollView.Context.Consumer> {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; }} </ScrollView.Context.Consumer> ); } if (this.props.debug) { return ( <View style={styles.debug}> {ret} {this._renderDebugOverlay()} </View> ); } 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<any>} = {}; _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<number, string> = new Map(); _lastFocusedCellKey: ?string = null; _nestedChildLists: ChildListCollection<VirtualizedList> = 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<any> = null; _sentStartForContentLength = 0; _sentEndForContentLength = 0; _updateCellsToRenderBatcher: Batchinator; _viewabilityTuples: Array<ViewabilityHelperCallbackTuple> = []; /* $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 => { // [macOS const preferredScrollerStyleDidChangeHandler = this.props.onPreferredScrollerStyleDidChange; const invertedDidChange = this.props.onInvertedDidChange; let keyDownEvents = [ {key: 'ArrowUp'}, {key: 'ArrowDown'}, {key: 'Home'}, {key: 'End'}, ]; const keyboardNavigationProps = { focusable: true, keyDownEvents: keyDownEvents, onKeyDown: this._handleKeyDown, }; // macOS] 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 <View {...otherProps} />; } 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] <ScrollView // [macOS {...(props.enableSelectionOnKeyPress && keyboardNavigationProps)} onInvertedDidChange={invertedDidChange} onPreferredScrollerStyleDidChange={ preferredScrollerStyleDidChangeHandler } {...props} // macOS] refreshControl={ props.refreshControl == null ? ( <RefreshControl // $FlowFixMe[incompatible-type] refreshing={props.refreshing} onRefresh={onRefresh} progressViewOffset={props.progressViewOffset} /> ) : ( props.refreshControl ) } /> ); } else { return ( // $FlowFixMe[prop-missing] Invalid prop usage // $FlowFixMe[incompatible-use] <ScrollView // [macOS {...(props.enableSelectionOnKeyPress && keyboardNavigationProps)} onInvertedDidChange={invertedDidChange} onPreferredScrollerStyleDidChange={ preferredScrollerStyleDidChangeHandler } // macOS] {...props} /> ); } }; _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); }; // [macOS _selectRowAtIndex = (rowIndex: number) => { const prevIndex = this.state.selectedRowIndex; const newIndex = rowIndex; this.setState({selectedRowIndex: newIndex}); this.ensureItemAtIndexIsVisible(newInde