From a10d90ffadfa22bf54bae805211c3ab1d9a47619 Mon Sep 17 00:00:00 2001 From: Rory Abraham Date: Wed, 16 Feb 2022 20:42:05 -0800 Subject: [PATCH 01/10] Add onStartReached and onStartReachedThreshold props --- Libraries/Lists/VirtualizedList.js | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index 2d491d4a6c79b4..e42e1985b361b9 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -209,6 +209,18 @@ type OptionalProps = {| * interfere with responding to button taps or other interactions. */ maxToRenderPerBatch?: ?number, + /** + * Called once when the scroll position gets within `onStartReachedThreshold` of the rendered + * content. + */ + onStartReached?: ?(info: {distanceFromStart: number, ...}) => void, + /** + * How far from the start (in units of visible length of the list) the top edge of the + * list must be from the start of the content to trigger the `onStartReached` callback. + * Thus, a value of 0.5 will trigger `onStartReached` when the end of the content is + * within half the visible length of the list. + */ + onStartReachedThreshold?: ?number, /** * Called once when the scroll position gets within `onEndReachedThreshold` of the rendered * content. @@ -217,7 +229,7 @@ type OptionalProps = {| /** * How far from the end (in units of visible length of the list) the bottom edge of the * list must be from the end of the content to trigger the `onEndReached` callback. - * Thus a value of 0.5 will trigger `onEndReached` when the end of the content is + * Thus, a value of 0.5 will trigger `onEndReached` when the end of the content is * within half the visible length of the list. */ onEndReachedThreshold?: ?number, @@ -333,6 +345,11 @@ function maxToRenderPerBatchOrDefault(maxToRenderPerBatch: ?number) { return maxToRenderPerBatch ?? 10; } +// onStartReachedThresholdOrDefault(this.props.onStartReachedThreshold) +function onStartReachedThresholdOrDefault(onStartReachedThreshold: ?number) { + return onStartReachedThreshold ?? 2; +} + // onEndReachedThresholdOrDefault(this.props.onEndReachedThreshold) function onEndReachedThresholdOrDefault(onEndReachedThreshold: ?number) { return onEndReachedThreshold ?? 2; From 9cb06b17418b36d6f64c63eae2e06d78ab938521 Mon Sep 17 00:00:00 2001 From: Rory Abraham Date: Wed, 16 Feb 2022 21:10:33 -0800 Subject: [PATCH 02/10] Implement _maybeCallOnEdgeReached --- Libraries/Lists/VirtualizedList.js | 70 +++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index e42e1985b361b9..fe0df81953b6ac 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -1232,6 +1232,7 @@ class VirtualizedList extends React.PureComponent { visibleLength: 0, }; _scrollRef: ?React.ElementRef = null; + _sentStartForContentLength = 0; _sentEndForContentLength = 0; _totalCellLength = 0; _totalCellsMeasured = 0; @@ -1409,7 +1410,7 @@ class VirtualizedList extends React.PureComponent { } this.props.onLayout && this.props.onLayout(e); this._scheduleCellsToRenderUpdate(); - this._maybeCallOnEndReached(); + this._maybeCallOnEdgeReached(); }; _onLayoutEmpty = e => { @@ -1511,26 +1512,63 @@ class VirtualizedList extends React.PureComponent { return !horizontalOrDefault(this.props.horizontal) ? metrics.y : metrics.x; } - _maybeCallOnEndReached() { - const {data, getItemCount, onEndReached, onEndReachedThreshold} = - this.props; + _maybeCallOnEdgeReached() { + const { + data, + getItemCount, + onStartReached, + onStartReachedThreshold, + onEndReached, + onEndReachedThreshold, + } = this.props; const {contentLength, visibleLength, offset} = this._scrollMetrics; + const distanceFromStart = offset; const distanceFromEnd = contentLength - visibleLength - offset; - const threshold = - onEndReachedThreshold != null ? onEndReachedThreshold * visibleLength : 2; + const startThreshold = + onStartReachedThresholdOrDefault(onStartReachedThreshold) * visibleLength; + const endThreshold = + onEndReachedThresholdOrDefault(onEndReachedThreshold) * visibleLength; + const isWithinStartThreshold = distanceFromStart <= startThreshold; + const isWithinEndThreshold = distanceFromEnd <= endThreshold; + const shouldExecuteNewCallback = + this._scrollMetrics.contentLength !== this._sentStartForContentLength && + this._scrollMetrics.contentLength !== this._sentEndForContentLength; + + // 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.last === getItemCount(data) - 1 && - distanceFromEnd < threshold && - this._scrollMetrics.contentLength !== this._sentEndForContentLength + isWithinEndThreshold && + shouldExecuteNewCallback && + this.state.last === getItemCount(data) - 1 ) { - // Only call onEndReached once for a given content length this._sentEndForContentLength = this._scrollMetrics.contentLength; onEndReached({distanceFromEnd}); - } else if (distanceFromEnd > threshold) { - // If the user scrolls away from the end and back again cause - // an onEndReached to be triggered again - this._sentEndForContentLength = 0; + } + + // Next check if the user just scrolled within the start threshold + // and call onStartReached only once for a given content length, + // and only if onEndReached is not being executed + else if ( + onStartReached && + isWithinStartThreshold && + shouldExecuteNewCallback && + this.state.first === 0 + ) { + this._sentStartForContentLength = this._scrollMetrics.contentLength; + onStartReached({distanceFromStart}); + } + + // If the user scrolls away from the start or end and back again, + // cause onStartReached or onEndReached to be triggered again + else { + this._sentStartForContentLength = isWithinStartThreshold + ? this._sentStartForContentLength + : 0; + this._sentEndForContentLength = isWithinEndThreshold + ? this._sentEndForContentLength + : 0; } } @@ -1555,7 +1593,7 @@ class VirtualizedList extends React.PureComponent { } this._scrollMetrics.contentLength = this._selectLength({height, width}); this._scheduleCellsToRenderUpdate(); - this._maybeCallOnEndReached(); + this._maybeCallOnEdgeReached(); }; /* Translates metrics from a scroll event in a parent VirtualizedList into @@ -1639,7 +1677,7 @@ class VirtualizedList extends React.PureComponent { if (!this.props) { return; } - this._maybeCallOnEndReached(); + this._maybeCallOnEdgeReached(); if (velocity !== 0) { this._fillRateHelper.activate(); } From fda25cd1106e1234946ff22ca0a3ea27579e3e69 Mon Sep 17 00:00:00 2001 From: Rory Abraham Date: Wed, 16 Feb 2022 21:21:56 -0800 Subject: [PATCH 03/10] Fix comments that reference top and bottom --- Libraries/Lists/VirtualizedList.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index fe0df81953b6ac..5ad372df976678 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -215,9 +215,9 @@ type OptionalProps = {| */ onStartReached?: ?(info: {distanceFromStart: number, ...}) => void, /** - * How far from the start (in units of visible length of the list) the top edge of the + * How far from the start (in units of visible length of the list) the leading edge of the * list must be from the start of the content to trigger the `onStartReached` callback. - * Thus, a value of 0.5 will trigger `onStartReached` when the end of the content is + * Thus, a value of 0.5 will trigger `onStartReached` when the start of the content is * within half the visible length of the list. */ onStartReachedThreshold?: ?number, @@ -227,7 +227,7 @@ type OptionalProps = {| */ onEndReached?: ?(info: {distanceFromEnd: number, ...}) => void, /** - * How far from the end (in units of visible length of the list) the bottom edge of the + * How far from the end (in units of visible length of the list) the trailing edge of the * list must be from the end of the content to trigger the `onEndReached` callback. * Thus, a value of 0.5 will trigger `onEndReached` when the end of the content is * within half the visible length of the list. From 0292375430d6009e49f484a93b6d9ffbfafa3d37 Mon Sep 17 00:00:00 2001 From: Rory Abraham Date: Wed, 16 Feb 2022 21:30:05 -0800 Subject: [PATCH 04/10] Implement _scheduleCellsToRenderUpdate --- Libraries/Lists/VirtualizedList.js | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index 5ad372df976678..42603d6495e58d 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -355,6 +355,11 @@ function onEndReachedThresholdOrDefault(onEndReachedThreshold: ?number) { return onEndReachedThreshold ?? 2; } +// getScrollingThreshold(visibleLength, onEndReachedThreshold) +function getScrollingThreshold(threshold: number, visibleLength: number) { + return (threshold * visibleLength) / 2; +} + // scrollEventThrottleOrDefault(this.props.scrollEventThrottle) function scrollEventThrottleOrDefault(scrollEventThrottle: ?number) { return scrollEventThrottle ?? 50; @@ -1690,16 +1695,22 @@ class VirtualizedList extends React.PureComponent { 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, ); - const scrollingThreshold = (onEndReachedThreshold * visibleLength) / 2; // Mark as high priority if we're close to the start of the first item // But only if there are items before the first rendered item if (first > 0) { - const distTop = offset - this.__getFrameMetricsApprox(first).offset; + const distStart = offset - this.__getFrameMetricsApprox(first).offset; hiPri = - hiPri || distTop < 0 || (velocity < -2 && distTop < scrollingThreshold); + hiPri || + distStart < 0 || + (velocity < -2 && + distStart < + 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 @@ -1709,7 +1720,9 @@ class VirtualizedList extends React.PureComponent { hiPri = hiPri || distBottom < 0 || - (velocity > 2 && distBottom < scrollingThreshold); + (velocity > 2 && + distBottom < + getScrollingThreshold(onEndReachedThreshold, visibleLength)); } // Only trigger high-priority updates if we've actually rendered cells, // and with that size estimate, accurately compute how many cells we should render. From 0f313939582fed9bca8107b4622f5b7da7fb81cd Mon Sep 17 00:00:00 2001 From: Rory Abraham Date: Wed, 16 Feb 2022 22:07:23 -0800 Subject: [PATCH 05/10] Remove unnecessary null check --- Libraries/Lists/VirtualizedList.js | 1 - 1 file changed, 1 deletion(-) diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index 42603d6495e58d..e6de1b63778b80 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -1581,7 +1581,6 @@ class VirtualizedList extends React.PureComponent { if ( width > 0 && height > 0 && - this.props.initialScrollIndex != null && this.props.initialScrollIndex > 0 && !this._hasDoneInitialScroll ) { From 9c4213467680dd3a1d8080514183d54fc4537919 Mon Sep 17 00:00:00 2001 From: Rory Abraham Date: Wed, 16 Feb 2022 22:18:03 -0800 Subject: [PATCH 06/10] Add support for bidirectional data loading in ListExampleShared --- .../js/components/ListExampleShared.js | 33 +++++++++++++------ .../js/examples/FlatList/FlatList-basic.js | 6 ++-- .../examples/FlatList/FlatList-multiColumn.js | 4 +-- .../SectionList/SectionList-scrollable.js | 4 +-- 4 files changed, 30 insertions(+), 17 deletions(-) diff --git a/packages/rn-tester/js/components/ListExampleShared.js b/packages/rn-tester/js/components/ListExampleShared.js index 1acad0e6f5a5e5..73a387152de5a2 100644 --- a/packages/rn-tester/js/components/ListExampleShared.js +++ b/packages/rn-tester/js/components/ListExampleShared.js @@ -33,16 +33,28 @@ export type Item = { ... }; -function genItemData(count: number, start: number = 0): Array { +function genItemData(i): Item { + const itemHash = Math.abs(hashCode('Item ' + i)); + return { + title: 'Item ' + i, + text: LOREM_IPSUM.substr(0, (itemHash % 301) + 20), + key: String(i), + pressed: false, + }; +} + +function genNewerItems(count: number, start: number = 0): Array { + const dataBlob = []; + for (let i = start; i < count + start; i++) { + dataBlob.push(genItemData(i)); + } + return dataBlob; +} + +function genOlderItems(count: number, start: number = 0): Array { const dataBlob = []; - for (let ii = start; ii < count + start; ii++) { - const itemHash = Math.abs(hashCode('Item ' + ii)); - dataBlob.push({ - title: 'Item ' + ii, - text: LOREM_IPSUM.substr(0, (itemHash % 301) + 20), - key: String(ii), - pressed: false, - }); + for (let i = count; i > 0; i--) { + dataBlob.push(genItemData(start - i)); } return dataBlob; } @@ -363,7 +375,8 @@ module.exports = { PlainInput, SeparatorComponent, Spindicator, - genItemData, + genNewerItems, + genOlderItems, getItemLayout, pressItem, renderSmallSwitchOption, diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js index 259daccc7b45ac..2b860ff018ed00 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js @@ -34,7 +34,7 @@ import { PlainInput, SeparatorComponent, Spindicator, - genItemData, + genNewerItems, getItemLayout, pressItem, renderSmallSwitchOption, @@ -67,7 +67,7 @@ type State = {| class FlatListExample extends React.PureComponent { state: State = { - data: genItemData(100), + data: genNewerItems(100), debug: false, horizontal: false, inverted: false, @@ -256,7 +256,7 @@ class FlatListExample extends React.PureComponent { return; } this.setState(state => ({ - data: state.data.concat(genItemData(100, state.data.length)), + data: state.data.concat(genNewerItems(100, state.data.length)), })); }; _onPressCallback = () => { diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js b/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js index a4ec63a8c4d397..1c62df145148c2 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js @@ -23,7 +23,7 @@ const { ItemComponent, PlainInput, SeparatorComponent, - genItemData, + genNewerItems, getItemLayout, pressItem, renderSmallSwitchOption, @@ -46,7 +46,7 @@ class MultiColumnExample extends React.PureComponent< numColumns: number, virtualized: boolean, |} = { - data: genItemData(1000), + data: genNewerItems(1000), filterText: '', fixedHeight: true, logViewable: false, diff --git a/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js b/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js index d478424f4c51fd..97bf43b0ae12b2 100644 --- a/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js +++ b/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js @@ -21,7 +21,7 @@ const { PlainInput, SeparatorComponent, Spindicator, - genItemData, + genNewerItems, pressItem, renderSmallSwitchOption, renderStackedItem, @@ -161,7 +161,7 @@ export function SectionList_scrollable(Props: { const [logViewable, setLogViewable] = React.useState(false); const [debug, setDebug] = React.useState(false); const [inverted, setInverted] = React.useState(false); - const [data, setData] = React.useState(genItemData(1000)); + const [data, setData] = React.useState(genNewerItems(1000)); const filterRegex = new RegExp(String(filterText), 'i'); const filter = item => From 66e5f6a32a3e0d6b5c64018b9220360bddff8084 Mon Sep 17 00:00:00 2001 From: Rory Abraham Date: Wed, 16 Feb 2022 22:29:26 -0800 Subject: [PATCH 07/10] Implement bidirectional pagination in FlatList-basic example --- .../js/examples/FlatList/FlatList-basic.js | 29 +++++++++++++++++-- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js index 2b860ff018ed00..714e2a02046405 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js @@ -35,6 +35,7 @@ import { SeparatorComponent, Spindicator, genNewerItems, + genOlderItems, getItemLayout, pressItem, renderSmallSwitchOption, @@ -42,6 +43,10 @@ import { import type {Item} from '../../components/ListExampleShared'; +const PAGE_SIZE = 100; +const INITIAL_PAGE_OFFSET = 5; +const NUM_PAGES = 10; + const VIEWABILITY_CONFIG = { minimumViewTime: 3000, viewAreaCoveragePercentThreshold: 100, @@ -67,7 +72,9 @@ type State = {| class FlatListExample extends React.PureComponent { state: State = { - data: genNewerItems(100), + data: genNewerItems(PAGE_SIZE, PAGE_SIZE * INITIAL_PAGE_OFFSET), + first: PAGE_SIZE * INITIAL_PAGE_OFFSET, + last: PAGE_SIZE + PAGE_SIZE * INITIAL_PAGE_OFFSET, debug: false, horizontal: false, inverted: false, @@ -222,6 +229,7 @@ class FlatListExample extends React.PureComponent { keyboardShouldPersistTaps="always" keyboardDismissMode="on-drag" numColumns={1} + onStartReached={this._onStartReached} onEndReached={this._onEndReached} onRefresh={this._onRefresh} onScroll={ @@ -232,6 +240,10 @@ class FlatListExample extends React.PureComponent { refreshing={false} contentContainerStyle={styles.list} viewabilityConfig={VIEWABILITY_CONFIG} + maintainVisibleContentPosition={{ + minIndexForVisible: 1, + autoscrollToTopThreshold: -Number.MAX_SAFE_INTEGER, + }} {...flatListItemRendererProps} /> @@ -251,12 +263,23 @@ class FlatListExample extends React.PureComponent { _getItemLayout = (data: any, index: number) => { return getItemLayout(data, index, this.state.horizontal); }; + _onStartReached = () => { + if (this.state.first <= 0) { + return; + } + + this.setState(state => ({ + data: genOlderItems(PAGE_SIZE, state.first).concat(state.data), + first: state.first - PAGE_SIZE, + })); + }; _onEndReached = () => { - if (this.state.data.length >= 1000) { + if (this.state.last >= PAGE_SIZE * NUM_PAGES) { return; } this.setState(state => ({ - data: state.data.concat(genNewerItems(100, state.data.length)), + data: state.data.concat(genNewerItems(PAGE_SIZE, state.data.length)), + last: state.last + PAGE_SIZE, })); }; _onPressCallback = () => { From cbca293b0c36d3f010343bccca9fb1adc3a78847 Mon Sep 17 00:00:00 2001 From: Rory Abraham Date: Wed, 16 Feb 2022 22:30:28 -0800 Subject: [PATCH 08/10] Use MIN_SAFE_INTEGER instead of negative MAX_SAFE_INTEGER --- packages/rn-tester/js/examples/FlatList/FlatList-basic.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js index 714e2a02046405..7d8575f2d68dc3 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js @@ -242,7 +242,7 @@ class FlatListExample extends React.PureComponent { viewabilityConfig={VIEWABILITY_CONFIG} maintainVisibleContentPosition={{ minIndexForVisible: 1, - autoscrollToTopThreshold: -Number.MAX_SAFE_INTEGER, + autoscrollToTopThreshold: Number.MIN_SAFE_INTEGER, }} {...flatListItemRendererProps} /> From 090e78d3f3b83b9a782290d7426dd0dba1e97af0 Mon Sep 17 00:00:00 2001 From: Rory Abraham Date: Fri, 25 Feb 2022 11:57:40 -0800 Subject: [PATCH 09/10] Remove autoScrollToTopThreshold --- packages/rn-tester/js/examples/FlatList/FlatList-basic.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js index 7d8575f2d68dc3..e010268c6a0e02 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js @@ -241,8 +241,7 @@ class FlatListExample extends React.PureComponent { contentContainerStyle={styles.list} viewabilityConfig={VIEWABILITY_CONFIG} maintainVisibleContentPosition={{ - minIndexForVisible: 1, - autoscrollToTopThreshold: Number.MIN_SAFE_INTEGER, + minIndexForVisible: 0, }} {...flatListItemRendererProps} /> @@ -278,7 +277,7 @@ class FlatListExample extends React.PureComponent { return; } this.setState(state => ({ - data: state.data.concat(genNewerItems(PAGE_SIZE, state.data.length)), + data: state.data.concat(genNewerItems(PAGE_SIZE, state.last)), last: state.last + PAGE_SIZE, })); }; From ebd80600ceb582ebb25095f44f7e5242c477fffc Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Wed, 13 Apr 2022 20:35:07 -0400 Subject: [PATCH 10/10] Fix maintainVisibleContentPosition --- Libraries/Lists/VirtualizedList.js | 125 ++++++++++++++---- React/Views/ScrollView/RCTScrollView.m | 4 +- .../js/components/ListExampleShared.js | 15 +++ .../js/examples/FlatList/FlatList-basic.js | 65 ++++++--- 4 files changed, 165 insertions(+), 44 deletions(-) diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index e6de1b63778b80..48d50efd3c265b 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -323,6 +323,11 @@ let _keylessItemComponentName: string = ''; type State = { first: number, last: number, + // Used to track items added at the start of the list for maintainVisibleContentPosition. + firstItemKey: ?string, + // When using maintainVisibleContentPosition we need to adjust the window to make sure + // make sure that the visible elements are still rendered. + maintainVisibleContentPositionAdjustment: ?number, }; /** @@ -370,6 +375,40 @@ function windowSizeOrDefault(windowSize: ?number) { return windowSize ?? 21; } +function extractKey(props: Props, item: Item, index: number): 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; +} + +function findItemIndexWithKey(props: Props, key: string): ?number { + for (let ii = 0; ii < props.getItemCount(props.data); ii++) { + const item = props.getItem(props.data, ii); + const curKey = extractKey(props, item, ii); + if (curKey === key) { + return ii; + } + } + return null; +} + +function getItemKey(props: Props, index: number): ?string { + const item = props.getItem(props.data, index); + if (item == null) { + return null; + } + return extractKey(props, item, 0); +} + /** * Base implementation for the more convenient [``](https://reactnative.dev/docs/flatlist) * and [``](https://reactnative.dev/docs/sectionlist) components, which are also better @@ -743,6 +782,8 @@ class VirtualizedList extends React.PureComponent { (this.props.initialScrollIndex || 0) + initialNumToRenderOrDefault(this.props.initialNumToRender), ) - 1, + firstItemKey: getItemKey(this.props, 0), + maintainVisibleContentPositionAdjustment: null, }; if (this._isNestedWithSameOrientation()) { @@ -792,10 +833,27 @@ class VirtualizedList extends React.PureComponent { } static getDerivedStateFromProps(newProps: Props, prevState: State): State { - const {data, getItemCount} = newProps; + const {data, getItemCount, maintainVisibleContentPosition} = newProps; + const {firstItemKey: prevFirstItemKey} = prevState; const maxToRenderPerBatch = maxToRenderPerBatchOrDefault( newProps.maxToRenderPerBatch, ); + + let maintainVisibleContentPositionAdjustment = + prevState.maintainVisibleContentPositionAdjustment; + const newFirstItemKey = getItemKey(newProps, 0); + if ( + maintainVisibleContentPosition != null && + maintainVisibleContentPositionAdjustment == null && + prevFirstItemKey != null && + newFirstItemKey != null + ) { + maintainVisibleContentPositionAdjustment = + newFirstItemKey !== prevFirstItemKey + ? findItemIndexWithKey(newProps, prevFirstItemKey) + : null; + } + // 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. return { @@ -804,6 +862,8 @@ class VirtualizedList extends React.PureComponent { Math.min(prevState.first, getItemCount(data) - 1 - maxToRenderPerBatch), ), last: Math.max(0, Math.min(prevState.last, getItemCount(data) - 1)), + firstItemKey: newFirstItemKey, + maintainVisibleContentPositionAdjustment, }; } @@ -829,7 +889,7 @@ class VirtualizedList extends React.PureComponent { last = Math.min(end, last); for (let ii = first; ii <= last; ii++) { const item = getItem(data, ii); - const key = this._keyExtractor(item, ii); + const key = extractKey(this.props, item, ii); this._indicesToKeys.set(ii, key); if (stickyIndicesFromProps.has(ii + stickyOffset)) { stickyHeaderIndices.push(cells.length); @@ -881,21 +941,6 @@ class VirtualizedList extends React.PureComponent { _getSpacerKey = (isVertical: boolean): string => isVertical ? 'height' : 'width'; - _keyExtractor(item: Item, index: number) { - if (this.props.keyExtractor != null) { - return this.props.keyExtractor(item, index); - } - - const key = defaultKeyExtractor(item, index); - if (key === String(index)) { - _usedIndexForKey = true; - if (item.type && item.type.displayName) { - _keylessItemComponentName = item.type.displayName; - } - } - return key; - } - render(): React.Node { if (__DEV__) { const flatStyles = flattenStyle(this.props.contentContainerStyle); @@ -955,7 +1000,11 @@ class VirtualizedList extends React.PureComponent { const lastInitialIndex = this.props.initialScrollIndex ? -1 : initialNumToRenderOrDefault(this.props.initialNumToRender) - 1; - const {first, last} = this.state; + let {first, last, maintainVisibleContentPositionAdjustment} = this.state; + if (maintainVisibleContentPositionAdjustment != null) { + first += maintainVisibleContentPositionAdjustment; + last += maintainVisibleContentPositionAdjustment; + } this._pushCells( cells, stickyHeaderIndices, @@ -1525,7 +1574,11 @@ class VirtualizedList extends React.PureComponent { onStartReachedThreshold, onEndReached, onEndReachedThreshold, + initialScrollIndex, } = this.props; + if (this.state.maintainVisibleContentPositionAdjustment != null) { + return; + } const {contentLength, visibleLength, offset} = this._scrollMetrics; const distanceFromStart = offset; const distanceFromEnd = contentLength - visibleLength - offset; @@ -1559,7 +1612,10 @@ class VirtualizedList extends React.PureComponent { onStartReached && isWithinStartThreshold && shouldExecuteNewCallback && - this.state.first === 0 + this.state.first === 0 && + // On initial mount when using initialScrollIndex the offset will be 0 initially + // and will trigger an unexpected onStartReached. + (!initialScrollIndex || this._hasInteracted) ) { this._sentStartForContentLength = this._scrollMetrics.contentLength; onStartReached({distanceFromStart}); @@ -1581,7 +1637,7 @@ class VirtualizedList extends React.PureComponent { if ( width > 0 && height > 0 && - this.props.initialScrollIndex > 0 && + this.props.initialScrollIndex && !this._hasDoneInitialScroll ) { if (this.props.contentOffset == null) { @@ -1677,6 +1733,17 @@ class VirtualizedList extends React.PureComponent { velocity, visibleLength, }; + + const {maintainVisibleContentPositionAdjustment} = this.state; + if (maintainVisibleContentPositionAdjustment != null) { + this.setState(state => ({ + maintainVisibleContentPositionAdjustment: null, + // Also update state with adjusted values since previous values are used + // in computeWindowedRenderLimits. + first: state.first + maintainVisibleContentPositionAdjustment, + last: state.last + maintainVisibleContentPositionAdjustment, + })); + } this._updateViewableItems(this.props.data); if (!this.props) { return; @@ -1690,7 +1757,10 @@ class VirtualizedList extends React.PureComponent { }; _scheduleCellsToRenderUpdate() { - const {first, last} = this.state; + const {first, last, maintainVisibleContentPositionAdjustment} = this.state; + if (maintainVisibleContentPositionAdjustment != null) { + return; + } const {offset, visibleLength, velocity} = this._scrollMetrics; const itemCount = this.props.getItemCount(this.props.data); let hiPri = false; @@ -1879,7 +1949,12 @@ class VirtualizedList extends React.PureComponent { _createViewToken = (index: number, isViewable: boolean) => { const {data, getItem} = this.props; const item = getItem(data, index); - return {index, item, key: this._keyExtractor(item, index), isViewable}; + return { + index, + item, + key: extractKey(this.props, item, index), + isViewable, + }; }; __getFrameMetricsApprox: (index: number) => { @@ -1919,7 +1994,7 @@ class VirtualizedList extends React.PureComponent { 'Tried to get frame for out of range index ' + index, ); const item = getItem(data, index); - let frame = item && this._frames[this._keyExtractor(item, index)]; + let frame = item && this._frames[extractKey(this.props, item, index)]; if (!frame || frame.index !== index) { if (getItemLayout) { frame = getItemLayout(data, index); @@ -2123,8 +2198,8 @@ class CellRenderer extends React.Component< : inversionStyle; const result = !CellRendererComponent ? ( /* $FlowFixMe[incompatible-type-arg] (>=0.89.0 site=react_native_fb) * - This comment suppresses an error found when Flow v0.89 was deployed. * - To see the error, delete this comment and run Flow. */ + This comment suppresses an error found when Flow v0.89 was deployed. * + To see the error, delete this comment and run Flow. */ {element} {itemSeparator} diff --git a/React/Views/ScrollView/RCTScrollView.m b/React/Views/ScrollView/RCTScrollView.m index f0f64021aca67e..b7371975262924 100644 --- a/React/Views/ScrollView/RCTScrollView.m +++ b/React/Views/ScrollView/RCTScrollView.m @@ -944,7 +944,7 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager CGPointMake(self->_scrollView.contentOffset.x + deltaX, self->_scrollView.contentOffset.y); if (autoscrollThreshold != nil) { // If the offset WAS within the threshold of the start, animate to the start. - if (x - deltaX <= [autoscrollThreshold integerValue]) { + if (x <= [autoscrollThreshold integerValue]) { [self scrollToOffset:CGPointMake(-leftInset, self->_scrollView.contentOffset.y) animated:YES]; } } @@ -960,7 +960,7 @@ - (void)uiManagerWillPerformMounting:(RCTUIManager *)manager CGPointMake(self->_scrollView.contentOffset.x, self->_scrollView.contentOffset.y + deltaY); if (autoscrollThreshold != nil) { // If the offset WAS within the threshold of the start, animate to the start. - if (y - deltaY <= [autoscrollThreshold integerValue]) { + if (y <= [autoscrollThreshold integerValue]) { [self scrollToOffset:CGPointMake(self->_scrollView.contentOffset.x, -bottomInset) animated:YES]; } } diff --git a/packages/rn-tester/js/components/ListExampleShared.js b/packages/rn-tester/js/components/ListExampleShared.js index 73a387152de5a2..c970c6921bbc34 100644 --- a/packages/rn-tester/js/components/ListExampleShared.js +++ b/packages/rn-tester/js/components/ListExampleShared.js @@ -13,6 +13,7 @@ const React = require('react'); const { + ActivityIndicator, Animated, Image, Platform, @@ -159,6 +160,12 @@ class SeparatorComponent extends React.PureComponent<{...}> { } } +const LoadingComponent: React.ComponentType<{}> = React.memo(() => ( + + + +)); + class ItemSeparatorComponent extends React.PureComponent<$FlowFixMeProps> { render(): React.Node { const style = this.props.highlighted @@ -364,6 +371,13 @@ const styles = StyleSheet.create({ text: { flex: 1, }, + loadingContainer: { + alignItems: 'center', + justifyContent: 'center', + height: 100, + borderTopWidth: 1, + borderTopColor: 'rgb(200, 199, 204)', + }, }); module.exports = { @@ -374,6 +388,7 @@ module.exports = { ItemSeparatorComponent, PlainInput, SeparatorComponent, + LoadingComponent, Spindicator, genNewerItems, genOlderItems, diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js index e010268c6a0e02..f56cce3c46241c 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js @@ -33,6 +33,7 @@ import { ItemSeparatorComponent, PlainInput, SeparatorComponent, + LoadingComponent, Spindicator, genNewerItems, genOlderItems, @@ -44,8 +45,9 @@ import { import type {Item} from '../../components/ListExampleShared'; const PAGE_SIZE = 100; -const INITIAL_PAGE_OFFSET = 5; const NUM_PAGES = 10; +const INITIAL_PAGE_OFFSET = Math.floor(NUM_PAGES / 2); +const LOAD_TIME = 2000; const VIEWABILITY_CONFIG = { minimumViewTime: 3000, @@ -56,6 +58,8 @@ const VIEWABILITY_CONFIG = { type Props = $ReadOnly<{||}>; type State = {| data: Array, + first: number, + last: number, debug: boolean, horizontal: boolean, inverted: boolean, @@ -68,6 +72,9 @@ type State = {| fadingEdgeLength: number, onPressDisabled: boolean, textSelectable: boolean, + maintainVisibleContentPosition: boolean, + previousLoading: boolean, + nextLoading: boolean, |}; class FlatListExample extends React.PureComponent { @@ -87,6 +94,9 @@ class FlatListExample extends React.PureComponent { fadingEdgeLength: 0, onPressDisabled: false, textSelectable: true, + maintainVisibleContentPosition: true, + previousLoading: false, + nextLoading: false, }; _onChangeFilterText = filterText => { @@ -189,6 +199,11 @@ class FlatListExample extends React.PureComponent { this.state.useFlatListItemComponent, this._setBooleanValue('useFlatListItemComponent'), )} + {renderSmallSwitchOption( + 'Maintain content position', + this.state.maintainVisibleContentPosition, + this._setBooleanValue('maintainVisibleContentPosition'), + )} {Platform.OS === 'android' && ( { } - ListFooterComponent={FooterComponent} + ListHeaderComponent={ + this.state.previousLoading ? LoadingComponent : HeaderComponent + } + ListFooterComponent={ + this.state.nextLoading ? LoadingComponent : FooterComponent + } ListEmptyComponent={ListEmptyComponent} data={this.state.empty ? [] : filteredData} debug={this.state.debug} @@ -230,6 +249,7 @@ class FlatListExample extends React.PureComponent { keyboardDismissMode="on-drag" numColumns={1} onStartReached={this._onStartReached} + initialScrollIndex={Math.floor(PAGE_SIZE / 2)} onEndReached={this._onEndReached} onRefresh={this._onRefresh} onScroll={ @@ -240,9 +260,11 @@ class FlatListExample extends React.PureComponent { refreshing={false} contentContainerStyle={styles.list} viewabilityConfig={VIEWABILITY_CONFIG} - maintainVisibleContentPosition={{ - minIndexForVisible: 0, - }} + maintainVisibleContentPosition={ + this.state.maintainVisibleContentPosition + ? {minIndexForVisible: 1} + : undefined + } {...flatListItemRendererProps} /> @@ -263,23 +285,32 @@ class FlatListExample extends React.PureComponent { return getItemLayout(data, index, this.state.horizontal); }; _onStartReached = () => { - if (this.state.first <= 0) { + if (this.state.first <= 0 || this.state.previousLoading) { return; } - this.setState(state => ({ - data: genOlderItems(PAGE_SIZE, state.first).concat(state.data), - first: state.first - PAGE_SIZE, - })); + this.setState({previousLoading: true}); + setTimeout(() => { + this.setState(state => ({ + previousLoading: false, + data: genOlderItems(PAGE_SIZE, state.first).concat(state.data), + first: state.first - PAGE_SIZE, + })); + }, LOAD_TIME); }; _onEndReached = () => { - if (this.state.last >= PAGE_SIZE * NUM_PAGES) { + if (this.state.last >= PAGE_SIZE * NUM_PAGES || this.state.nextLoading) { return; } - this.setState(state => ({ - data: state.data.concat(genNewerItems(PAGE_SIZE, state.last)), - last: state.last + PAGE_SIZE, - })); + + this.setState({nextLoading: true}); + setTimeout(() => { + this.setState(state => ({ + nextLoading: false, + data: state.data.concat(genNewerItems(PAGE_SIZE, state.last)), + last: state.last + PAGE_SIZE, + })); + }, LOAD_TIME); }; _onPressCallback = () => { const {onPressDisabled} = this.state; @@ -337,7 +368,7 @@ class FlatListExample extends React.PureComponent { _pressItem = (key: string) => { this._listRef && this._listRef.recordInteraction(); - const index = Number(key); + const index = this.state.data.findIndex(item => item.key === key); const itemState = pressItem(this.state.data[index]); this.setState(state => ({ ...state,