diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index 62f614ec06e497..8df94b90d4f16b 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -1077,8 +1077,6 @@ class VirtualizedList extends React.PureComponent { componentDidUpdate(prevProps: Props) { const {data, extraData} = this.props; if (data !== prevProps.data || extraData !== prevProps.extraData) { - this._hasDataChangedSinceEndReached = true; - // clear the viewableIndices cache to also trigger // the onViewableItemsChanged callback with the new data this._viewabilityTuples.forEach(tuple => { @@ -1107,7 +1105,6 @@ class VirtualizedList extends React.PureComponent { _fillRateHelper: FillRateHelper; _frames = {}; _footerLength = 0; - _hasDataChangedSinceEndReached = true; _hasDoneInitialScroll = false; _hasInteracted = false; _hasMore = false; @@ -1137,6 +1134,7 @@ class VirtualizedList extends React.PureComponent { _totalCellsMeasured = 0; _updateCellsToRenderBatcher: Batchinator; _viewabilityTuples: Array = []; + _hasDoneFirstScroll = false; _captureScrollRef = ref => { this._scrollRef = ref; @@ -1371,30 +1369,48 @@ class VirtualizedList extends React.PureComponent { return !this.props.horizontal ? metrics.y : metrics.x; } - _maybeCallOnEndReached() { - const { - data, - getItemCount, - onEndReached, - onEndReachedThreshold, - } = this.props; - const {contentLength, visibleLength, offset} = this._scrollMetrics; - const distanceFromEnd = contentLength - visibleLength - offset; + _maybeCallOnEndReached(hasShrinkedContentLength: boolean = false) { + const {onEndReached, onEndReachedThreshold} = this.props; + if (!onEndReached) { + return; + } + + const {contentLength, visibleLength, offset, dOffset} = this._scrollMetrics; + + // If this is just after the initial rendering if ( - onEndReached && - this.state.last === getItemCount(data) - 1 && - /* $FlowFixMe(>=0.63.0 site=react_native_fb) This comment suppresses an - * error found when Flow v0.63 was deployed. To see the error delete this - * comment and run Flow. */ - distanceFromEnd < onEndReachedThreshold * visibleLength && - (this._hasDataChangedSinceEndReached || - this._scrollMetrics.contentLength !== this._sentEndForContentLength) + !hasShrinkedContentLength && + !this._hasDoneFirstScroll && + offset === 0 ) { - // Only call onEndReached once for a given dataset + content length. - this._hasDataChangedSinceEndReached = false; - this._sentEndForContentLength = this._scrollMetrics.contentLength; - onEndReached({distanceFromEnd}); + return; } + + // If scrolled up in the vertical list + if (dOffset < 0) { + return; + } + + // If contentLength has not changed + if (contentLength === this._sentEndForContentLength) { + return; + } + + const distanceFromEnd = contentLength - visibleLength - offset; + + // If the distance is so farther than the area shown on the screen + if (distanceFromEnd >= visibleLength * 1.5) { + return; + } + + // $FlowFixMe + const minimumDistanceFromEnd = onEndReachedThreshold * visibleLength; + if (distanceFromEnd >= minimumDistanceFromEnd) { + return; + } + + this._sentEndForContentLength = contentLength; + onEndReached({distanceFromEnd}); } _onContentSizeChange = (width: number, height: number) => { @@ -1414,9 +1430,24 @@ class VirtualizedList extends React.PureComponent { if (this.props.onContentSizeChange) { this.props.onContentSizeChange(width, height); } - this._scrollMetrics.contentLength = this._selectLength({height, width}); + const {contentLength: currentContentLength} = this._scrollMetrics; + const contentLength = this._selectLength({height, width}); + this._scrollMetrics.contentLength = contentLength; this._scheduleCellsToRenderUpdate(); - this._maybeCallOnEndReached(); + + const hasShrinkedContentLength = + currentContentLength > 0 && + contentLength > 0 && + contentLength < currentContentLength; + + if ( + hasShrinkedContentLength && + this._sentEndForContentLength >= contentLength + ) { + this._sentEndForContentLength = 0; + } + + this._maybeCallOnEndReached(hasShrinkedContentLength); }; /* Translates metrics from a scroll event in a parent VirtualizedList into @@ -1503,6 +1534,7 @@ class VirtualizedList extends React.PureComponent { if (!this.props) { return; } + this._hasDoneFirstScroll = true; this._maybeCallOnEndReached(); if (velocity !== 0) { this._fillRateHelper.activate(); diff --git a/Libraries/Lists/__tests__/VirtualizedList-test.js b/Libraries/Lists/__tests__/VirtualizedList-test.js index c4873374e0ef54..a9eb83f90f92d9 100644 --- a/Libraries/Lists/__tests__/VirtualizedList-test.js +++ b/Libraries/Lists/__tests__/VirtualizedList-test.js @@ -274,3 +274,139 @@ describe('VirtualizedList', () => { ); }); }); + +describe('VirtualizedList > OnEndReached', () => { + const ITEM_HEIGHT = 100; + const INITIAL_ITEM_COUNT = 20; + const APPENDED_ITEM_COUNT = 10; + + let listItems, onEndReached, instance; + let shrinkedItemHeight; + + beforeEach(() => { + shrinkedItemHeight = 0; + + listItems = appendNewItems([], INITIAL_ITEM_COUNT); + + onEndReached = jest.fn(function() { + appendNewItems(listItems, APPENDED_ITEM_COUNT); + }); + + instance = createComponentInstance(); + }); + + it('should not be called after initial rendering', () => { + expect(onEndReached).not.toHaveBeenCalled(); + expect(listItems.length).toBe(INITIAL_ITEM_COUNT); + }); + + it('should be called when the item layout is shrinked', () => { + expect(onEndReached).not.toHaveBeenCalled(); + expect(listItems.length).toBe(INITIAL_ITEM_COUNT); + + shrinkedItemHeight = ITEM_HEIGHT / 2; + + const scroll = createScrollMethod(); + scroll(0); + + expect(onEndReached).toHaveBeenCalledTimes(1); + expect(listItems.length).toBe(INITIAL_ITEM_COUNT + APPENDED_ITEM_COUNT); + }); + + it('should be called once after scrolling by 800', () => { + const scroll = createScrollMethod(); + scroll(800); + + expect(onEndReached).toHaveBeenCalledTimes(1); + expect(onEndReached).toHaveBeenLastCalledWith({ + distanceFromEnd: 464, + }); + expect(listItems.length).toBe(INITIAL_ITEM_COUNT + APPENDED_ITEM_COUNT); + }); + + it('should not be called twice in a short period while scrolling fast', () => { + const scroll = createScrollMethod(); + scroll(800); + + expect(onEndReached).toHaveBeenCalledTimes(1); + expect(onEndReached).toHaveBeenLastCalledWith({ + distanceFromEnd: 464, + }); + expect(listItems.length).toBe(INITIAL_ITEM_COUNT + APPENDED_ITEM_COUNT); + + scroll(850, 50); + expect(onEndReached).toHaveBeenCalledTimes(1); + }); + + it('should be called when required to load more items', () => { + const scroll = createScrollMethod(); + scroll(800); + expect(onEndReached).toHaveBeenCalledTimes(1); + expect(listItems.length).toBe(INITIAL_ITEM_COUNT + APPENDED_ITEM_COUNT); + + scroll(1600); + expect(onEndReached).toHaveBeenCalledTimes(2); + expect(onEndReached).toHaveBeenLastCalledWith({ + distanceFromEnd: 664, + }); + expect(listItems.length).toBe(INITIAL_ITEM_COUNT + APPENDED_ITEM_COUNT * 2); + }); + + function createComponentInstance() { + const props = { + data: listItems, + renderItem: ({item}) => , + getItem: (items, index) => items[index], + getItemCount: items => items.length, + getItemLayout: (items, index) => ({ + length: shrinkedItemHeight ? shrinkedItemHeight : ITEM_HEIGHT, + offset: (shrinkedItemHeight ? shrinkedItemHeight : ITEM_HEIGHT) * index, + index, + }), + onEndReached, + }; + + const component = ReactTestRenderer.create(); + return component.getInstance(); + } + + function appendNewItems(items, count) { + const nextId = (items.length > 0 ? items[items.length - 1].id : 0) + 1; + + for (let loop = 1; loop <= count; loop++) { + const id = nextId + loop; + items.push({ + id: id, + key: `k${id}`, + }); + } + + return items; + } + + function createScrollMethod() { + let scrollTimeStamp = 0; + + return function scroll(y, delay = 1000) { + scrollTimeStamp += delay; + + const nativeEvent = { + contentOffset: {y, x: 0}, + layoutMeasurement: {width: 414, height: 736}, + contentSize: { + width: 414, + height: + listItems.length * + (shrinkedItemHeight ? shrinkedItemHeight : ITEM_HEIGHT), + }, + zoomScale: 1, + contentInset: {right: 0, top: 0, left: 0, bottom: 0}, + }; + + instance._onScroll({ + timeStamp: scrollTimeStamp, + nativeEvent, + }); + }; + } +});