diff --git a/Libraries/Lists/VirtualizeUtils.js b/Libraries/Lists/VirtualizeUtils.js index 91cb58e8f..b665bd2d2 100644 --- a/Libraries/Lists/VirtualizeUtils.js +++ b/Libraries/Lists/VirtualizeUtils.js @@ -114,6 +114,15 @@ function computeWindowedRenderLimits( ); const overscanEnd = Math.max(0, visibleEnd + leadFactor * overscanLength); + const lastItemOffset = getFrameMetricsApprox(itemCount - 1).offset; + if (lastItemOffset < overscanBegin) { + // Entire list is before our overscan window + return { + first: Math.max(0, itemCount - 1 - maxToRenderPerBatch), + last: itemCount - 1, + }; + } + // Find the indices that correspond to the items at the render boundaries we're targetting. let [overscanFirst, first, last, overscanLast] = elementsThatOverlapOffsets( [overscanBegin, visibleBegin, visibleEnd, overscanEnd], diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index 7ddebdc5c..95fb0cda6 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -20,6 +20,7 @@ const ReactNative = require('ReactNative'); const RefreshControl = require('RefreshControl'); const ScrollView = require('ScrollView'); const StyleSheet = require('StyleSheet'); +const UIManager = require('UIManager'); const View = require('View'); const ViewabilityHelper = require('ViewabilityHelper'); @@ -129,6 +130,12 @@ type OptionalProps = { * a rendered element. */ ListHeaderComponent?: ?(React.ComponentType | React.Element), + /** + * A unique identifier for this list. If there are multiple VirtualizedLists at the same level of + * nesting within another VirtualizedList, this key is necessary for virtualization to + * work properly. + */ + listKey?: string, /** * The maximum number of items to render in each incremental render batch. The more rendered at * once, the better the fill rate, but responsiveness my suffer because rendering content may @@ -206,6 +213,19 @@ export type Props = RequiredProps & OptionalProps; let _usedIndexForKey = false; +type Frame = { + offset: number, + length: number, + index: number, + inLayout: boolean, +}; + +type ChildListState = { + first: number, + last: number, + frames: {[key: number]: Frame}, +}; + type State = {first: number, last: number}; /** @@ -412,26 +432,92 @@ class VirtualizedList extends React.PureComponent { }; static contextTypes = { + virtualizedListCellRenderer: PropTypes.shape({ + cellKey: PropTypes.string, + }), virtualizedList: PropTypes.shape({ + getScrollMetrics: PropTypes.func, horizontal: PropTypes.bool, + getOutermostParentListRef: PropTypes.func, + registerAsNestedChild: PropTypes.func, + unregisterAsNestedChild: PropTypes.func, }), }; static childContextTypes = { virtualizedList: PropTypes.shape({ + getScrollMetrics: PropTypes.func, horizontal: PropTypes.bool, + getOutermostParentListRef: PropTypes.func, + registerAsNestedChild: PropTypes.func, + unregisterAsNestedChild: PropTypes.func, }), }; getChildContext() { return { virtualizedList: { + getScrollMetrics: this._getScrollMetrics, horizontal: this.props.horizontal, - // TODO: support nested virtualization and onViewableItemsChanged + getOutermostParentListRef: this._getOutermostParentListRef, + registerAsNestedChild: this._registerAsNestedChild, + unregisterAsNestedChild: this._unregisterAsNestedChild, }, }; } + _getScrollMetrics = () => { + return this._scrollMetrics; + }; + + hasMore(): boolean { + return this._hasMore; + } + + _getOutermostParentListRef = () => { + if (this._isNestedWithSameOrientation()) { + return this.context.virtualizedList.getOutermostParentListRef(); + } else { + return this; + } + }; + + _registerAsNestedChild = (childList: { + cellKey: string, + key: string, + ref: VirtualizedList, + }): ?ChildListState => { + // Register the mapping between this child key and the cellKey for its cell + const childListsInCell = + this._cellKeysToChildListKeys.get(childList.cellKey) || new Set(); + childListsInCell.add(childList.key); + this._cellKeysToChildListKeys.set(childList.cellKey, childListsInCell); + + const existingChildData = this._nestedChildLists.get(childList.key); + invariant( + !(existingChildData && existingChildData.ref !== null), + 'A VirtualizedList contains a cell which itself contains ' + + 'more than one VirtualizedList of the same orientation as the parent ' + + 'list. You must pass a unique listKey prop to each sibling list.', + ); + this._nestedChildLists.set(childList.key, { + ref: childList.ref, + state: null, + }); + + return existingChildData && existingChildData.state; + }; + + _unregisterAsNestedChild = (childList: { + key: string, + state: ChildListState, + }): void => { + this._nestedChildLists.set(childList.key, { + ref: null, + state: childList.state, + }); + }; + state: State; constructor(props: Props, context: Object) { @@ -441,11 +527,6 @@ class VirtualizedList extends React.PureComponent { 'Components based on VirtualizedList must be wrapped with Animated.createAnimatedComponent ' + 'to support native onScroll events with useNativeDriver', ); - invariant( - !(this._isNestedWithSameOrientation() && props.onViewableItemsChanged), - 'Nesting lists that scroll in the same direction does not support onViewableItemsChanged' + - 'on the inner list.', - ); this._fillRateHelper = new FillRateHelper(this._getFrameMetrics); this._updateCellsToRenderBatcher = new Batchinator( @@ -467,7 +548,7 @@ class VirtualizedList extends React.PureComponent { }); } - this.state = { + let initialState = { first: this.props.initialScrollIndex || 0, last: Math.min( @@ -475,8 +556,27 @@ class VirtualizedList extends React.PureComponent { (this.props.initialScrollIndex || 0) + this.props.initialNumToRender, ) - 1, }; + + if (this._isNestedWithSameOrientation()) { + const storedState = this.context.virtualizedList.registerAsNestedChild({ + cellKey: this.context.virtualizedListCellRenderer.cellKey, + key: + this.props.listKey || + this.context.virtualizedListCellRenderer.cellKey, + ref: this, + }); + if (storedState) { + initialState = storedState; + this.state = storedState; + this._frames = storedState.frames; + } + } + + this.state = initialState; } + componentWillMount() {} + componentDidMount() { if (this.props.initialScrollIndex) { this._initialScrollIndexTimeout = setTimeout( @@ -491,8 +591,20 @@ class VirtualizedList extends React.PureComponent { } componentWillUnmount() { + if (this._isNestedWithSameOrientation()) { + this.context.virtualizedList.unregisterAsNestedChild({ + key: + this.props.listKey || + this.context.virtualizedListCellRenderer.cellKey, + state: { + first: this.state.first, + last: this.state.last, + frames: this._frames, + }, + }); + } this._updateViewableItems(null); - this._updateCellsToRenderBatcher.dispose(); + this._updateCellsToRenderBatcher.dispose({abort: true}); this._viewabilityTuples.forEach(tuple => { tuple.viewabilityHelper.dispose(); }); @@ -549,6 +661,7 @@ class VirtualizedList extends React.PureComponent { for (let ii = first; ii <= last; ii++) { const item = getItem(data, ii); const key = keyExtractor(item, ii); + this._indicesToKeys.set(ii, key); if (stickyIndicesFromProps.has(ii + stickyOffset)) { stickyHeaderIndices.push(cells.length); } @@ -585,9 +698,7 @@ class VirtualizedList extends React.PureComponent { }; _isVirtualizationDisabled(): boolean { - return ( - this.props.disableVirtualization || this._isNestedWithSameOrientation() - ); + return this.props.disableVirtualization; } _isNestedWithSameOrientation(): boolean { @@ -606,7 +717,6 @@ class VirtualizedList extends React.PureComponent { 'Consider using `numColumns` with `FlatList` instead.', ); } - const { ListEmptyComponent, ListFooterComponent, @@ -779,6 +889,10 @@ class VirtualizedList extends React.PureComponent { if (inversionStyle) { scrollProps.style = [inversionStyle, this.props.style]; } + + this._hasMore = + this.state.last < this.props.getItemCount(this.props.data) - 1; + const ret = React.cloneElement( (this.props.renderScrollComponent || this._defaultRenderScrollComponent)( scrollProps, @@ -805,15 +919,25 @@ class VirtualizedList extends React.PureComponent { } _averageCellLength = 0; + // Maps a cell key to the set of keys for all outermost child lists within that cell + _cellKeysToChildListKeys: Map> = new Map(); _cellRefs = {}; - _hasDataChangedSinceEndReached = true; - _hasWarned = {}; - _highestMeasuredFrameIndex = 0; - _headerLength = 0; - _initialScrollIndexTimeout = 0; _fillRateHelper: FillRateHelper; _frames = {}; _footerLength = 0; + _hasDataChangedSinceEndReached = true; + _hasMore = false; + _hasWarned = {}; + _highestMeasuredFrameIndex = 0; + _headerLength = 0; + _indicesToKeys: Map = new Map(); + _initialScrollIndexTimeout = 0; + _nestedChildLists: Map< + string, + {ref: ?VirtualizedList, state: ?ChildListState}, + > = new Map(); + _offsetFromParentVirtualizedList: number = 0; + _prevParentOffset: number = 0; _scrollMetrics = { contentLength: 0, dOffset: 0, @@ -910,10 +1034,41 @@ class VirtualizedList extends React.PureComponent { } }; - _onLayout = (e: Object) => { - this._scrollMetrics.visibleLength = this._selectLength( - e.nativeEvent.layout, + _measureLayoutRelativeToContainingList(): void { + UIManager.measureLayout( + ReactNative.findNodeHandle(this), + ReactNative.findNodeHandle( + this.context.virtualizedList.getOutermostParentListRef(), + ), + error => { + console.warn( + "VirtualizedList: Encountered an error while measuring a list's" + + ' offset from its containing VirtualizedList.', + ); + }, + (x, y, width, height) => { + this._offsetFromParentVirtualizedList = this._selectOffset({x, y}); + this._scrollMetrics.contentLength = this._selectLength({width, height}); + + const scrollMetrics = this._convertParentScrollMetrics( + this.context.virtualizedList.getScrollMetrics(), + ); + this._scrollMetrics.visibleLength = scrollMetrics.visibleLength; + this._scrollMetrics.offset = scrollMetrics.offset; + }, ); + } + + _onLayout = (e: Object) => { + if (this._isNestedWithSameOrientation()) { + // Need to adjust our scroll metrics to be relative to our containing + // VirtualizedList before we can make claims about list item viewability + this._measureLayoutRelativeToContainingList(); + } else { + this._scrollMetrics.visibleLength = this._selectLength( + e.nativeEvent.layout, + ); + } this.props.onLayout && this.props.onLayout(e); this._scheduleCellsToRenderUpdate(); this._maybeCallOnEndReached(); @@ -1033,17 +1188,63 @@ class VirtualizedList extends React.PureComponent { this._maybeCallOnEndReached(); }; + /* Translates metrics from a scroll event in a parent VirtualizedList into + * coordinates relative to the child list. + */ + _convertParentScrollMetrics = (metrics: { + visibleLength: number, + offset: number, + }) => { + // Offset of the top of the nested list relative to the top of its parent's viewport + const offset = metrics.offset - this._offsetFromParentVirtualizedList; + // Child's visible length is the same as its parent's + const visibleLength = metrics.visibleLength; + const dOffset = offset - this._scrollMetrics.offset; + const contentLength = this._scrollMetrics.contentLength; + + return { + visibleLength, + contentLength, + offset, + dOffset, + }; + }; + _onScroll = (e: Object) => { + this._nestedChildLists.forEach(childList => { + childList.ref && childList.ref._onScroll(e); + }); if (this.props.onScroll) { this.props.onScroll(e); } const timestamp = e.timeStamp; - const visibleLength = this._selectLength(e.nativeEvent.layoutMeasurement); - const contentLength = this._selectLength(e.nativeEvent.contentSize); - const offset = this._selectOffset(e.nativeEvent.contentOffset); + let visibleLength = this._selectLength(e.nativeEvent.layoutMeasurement); + let contentLength = this._selectLength(e.nativeEvent.contentSize); + let offset = this._selectOffset(e.nativeEvent.contentOffset); + let dOffset = offset - this._scrollMetrics.offset; + + if (this._isNestedWithSameOrientation()) { + if (this._scrollMetrics.contentLength === 0) { + // Ignore scroll events until onLayout has been called and we + // know our offset from our offset from our parent + return; + } + ({ + visibleLength, + contentLength, + offset, + dOffset, + } = this._convertParentScrollMetrics({ + visibleLength, + offset, + })); + } + const dt = this._scrollMetrics.timestamp ? Math.max(1, timestamp - this._scrollMetrics.timestamp) : 1; + const velocity = dOffset / dt; + if ( dt > 500 && this._scrollMetrics.dt > 500 && @@ -1058,8 +1259,6 @@ class VirtualizedList extends React.PureComponent { ); this._hasWarned.perf = true; } - const dOffset = offset - this._scrollMetrics.offset; - const velocity = dOffset / dt; this._scrollMetrics = { contentLength, dt, @@ -1175,6 +1374,36 @@ class VirtualizedList extends React.PureComponent { last: Math.min(state.last + renderAhead, getItemCount(data) - 1), }; } + if (newState && this._nestedChildLists.size > 0) { + const newFirst = newState.first; + const newLast = newState.last; + // If some cell in the new state has a child list in it, we should only render + // up through that item, so that we give that list a chance to render. + // Otherwise there's churn from multiple child lists mounting and un-mounting + // their items. + for (let ii = newFirst; ii <= newLast; ii++) { + const cellKeyForIndex = this._indicesToKeys.get(ii); + const childListKeys = + cellKeyForIndex && + this._cellKeysToChildListKeys.get(cellKeyForIndex); + if (!childListKeys) { + continue; + } + let someChildHasMore = false; + // For each cell, need to check whether any child list in it has more elements to render + for (let childKey of childListKeys) { + const childList = this._nestedChildLists.get(childKey); + if (childList && childList.ref && childList.ref.hasMore()) { + someChildHasMore = true; + break; + } + } + if (someChildHasMore) { + newState.last = ii; + break; + } + } + } return newState; }); }; @@ -1292,6 +1521,20 @@ class CellRenderer extends React.Component< }, }; + static childContextTypes = { + virtualizedListCellRenderer: PropTypes.shape({ + cellKey: PropTypes.string, + }), + }; + + getChildContext() { + return { + virtualizedListCellRenderer: { + cellKey: this.props.cellKey, + }, + }; + } + // TODO: consider factoring separator stuff out of VirtualizedList into FlatList since it's not // reused by SectionList and we can keep VirtualizedList simpler. _separators = {