From acc5fdff478646f473feff26eec3102cfd0ce2bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Tue, 18 Apr 2023 09:51:51 -0700 Subject: [PATCH 1/2] fix: make sure initialScrollIndex is bigger than 0 (#36844) Summary: Hey, `adjustCellsAroundViewport` function was checking if `props.initialScrollIndex` is truthy and -1 was returning true. This caused bugs with rendering for tvOS: https://github.com/react-native-tvos/react-native-tvos/pull/485 There are warnings in the code about `initalScrollIndex` being smaller than 0 but this if statement would still allow that. ## Changelog: [General] [Fixed] - Make sure initialScrollToIndex is bigger than 0 when adjusting cells Pull Request resolved: https://github.com/facebook/react-native/pull/36844 Test Plan: Pass -1 as initialScrollToIndex. Check that this code is executed. Reviewed By: cipolleschi Differential Revision: D44856266 Pulled By: NickGerleman fbshipit-source-id: 781a1c0efeae93f00766eede4a42559dcd066d7d --- .../Lists/VirtualizedList.js | 5 +- .../VirtualizedList-test.js.snap | 53 ++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index de44f303b07ce9..2aca79a2ea2503 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -616,7 +616,7 @@ class VirtualizedList extends StateSafePureComponent { ), }; } else { - // If we have a non-zero initialScrollIndex and run this before we've scrolled, + // If we have a positive non-zero initialScrollIndex and run this before we've scrolled, // we'll wipe out the initialNumToRender rendered elements starting at initialScrollIndex. // So let's wait until we've scrolled the view to the right place. And until then, // we will trust the initialScrollIndex suggestion. @@ -627,7 +627,8 @@ class VirtualizedList extends StateSafePureComponent { // - initialScrollIndex > 0 AND the end of the list is visible (this handles the case // where the list is shorter than the visible area) if ( - props.initialScrollIndex && + props.initialScrollIndex != null && + props.initialScrollIndex > 0 && !this._scrollMetrics.offset && Math.abs(distanceFromEnd) >= Number.EPSILON ) { diff --git a/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap b/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap index b59f291eb6dbbe..cd174da04cca2c 100644 --- a/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap +++ b/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap @@ -3122,12 +3122,53 @@ exports[`gracefully handles negative initialScrollIndex 1`] = ` /> + onFocusCapture={[Function]} + style={null} + > + + + + + + + + + + + + + + + + + `; From 96d7c92a06163afe4cc7dc556dee86196a21591b Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Thu, 27 Apr 2023 15:31:35 -0700 Subject: [PATCH 2/2] Fix VirtualizedList with maintainVisibleContentPosition (#35993) Summary: `maintainVisibleContentPosition` is broken when using virtualization and the new content pushes visible content outside its "window". This can be reproduced in the example from this diff. When using a large page size it will always push visible content outside of the list "window" which will cause currently visible views to be unmounted so the implementation of `maintainVisibleContentPosition` can't adjust the content inset since the visible views no longer exist. The first illustration shows the working case, when the new content doesn't push visible content outside the window. The red box represents the window, all views outside the box are not mounted, which means the native implementation of `maintainVisibleContentPosition` has no way to know it exists. In that case the first visible view is https://github.com/facebook/react-native/issues/2, after new content is added https://github.com/facebook/react-native/issues/2 is still inside the window so there's not problem adjusting content offset to maintain position. As you can see Step 1 and 3 result in the same position for all initial views. The second illustation shows the broken case, when new content is added and pushes the first visible view outside the window. As you can see in step 2 the view https://github.com/facebook/react-native/issues/2 is no longer rendered so there's no way to maintain its position. #### Illustration 1 ![image](https://user-images.githubusercontent.com/2677334/163263472-eaf7342a-9b94-4c49-9a34-17bf8ef4ffb9.png) #### Illustration 2 ![image](https://user-images.githubusercontent.com/2677334/163263528-a8172341-137e-417e-a0c7-929d1e4e6791.png) To fix `maintainVisibleContentPosition` when using `VirtualizedList` we need to make sure the visible items stay rendered when new items are added at the start of the list. In order to do that we need to do the following: - Detect new items that will cause content to be adjusted - Add cells to render mask so that previously visible cells stay rendered - Ignore certain updates while scroll metrics are invalid ### Detect new items that will cause content to be adjusted The goal here is to know that scroll position will be updated natively by the `maintainVisibleContentPosition` implementation. The problem is that the native code uses layout heuristics which are not easily available to JS to do so. In order to approximate the native heuristic we can assume that if new items are added at the start of the list, it will cause `maintainVisibleContentPosition` to be triggered. This simplifies JS logic a lot as we don't have to track visible items. In the worst case if for some reason our JS heuristic is wrong, it will cause extra cells to be rendered until the next scroll event, or content position will not be maintained (what happens all the time currently). I think this is a good compromise between complexity and accuracy. We need to find how many items have been added before the first one. To do that we save the key of the first item in state `firstItemKey`. When data changes we can find the index of `firstItemKey` in the new data and that will be the amount we need to adjust the window state by. Note that this means that keys need to be stable, and using index won't work. ### Add cells to render mask so that previously visible cells stay rendered Once we have the adjusted number we can save this in a new state value `maintainVisibleContentPositionAdjustment` and add the adjusted cells to the render mask. This state is then cleared when we receive updated scroll metrics, once the native implementation is done adding the new items and adjusting the content offset. This value is also only set when `maintainVisibleContentPosition` is set so this makes sure this maintains the currently behavior when that prop is not set. ### Ignore certain updates while scroll metrics are invalid While the `maintainVisibleContentPositionAdjustment` state is set we know that the current scroll metrics are invalid since they will be updated in the native `ScrollView` implementation. In that case we want to prevent certain code from running. One example is `onStartReached` that will be called incorrectly while we are waiting for updated scroll metrics. ## Changelog [General] [Fixed] - Fix VirtualizedList with maintainVisibleContentPosition Pull Request resolved: https://github.com/facebook/react-native/pull/35993 Test Plan: Added bidirectional paging to RN tester FlatList example. Note that for this to work RN tester need to be run using old architecture on iOS, to use new architecture it requires https://github.com/facebook/react-native/pull/35319 Using debug mode we can see that virtualization is still working properly, and content position is being maintained. https://user-images.githubusercontent.com/2677334/163294404-e2eeae5b-e079-4dba-8664-ad280c171ae6.mov Reviewed By: yungsters Differential Revision: D45294060 Pulled By: NickGerleman fbshipit-source-id: 8e5228318886aa75da6ae397f74d1801d40295e8 --- .../__snapshots__/FlatList-test.js.snap | 1 + .../__snapshots__/SectionList-test.js.snap | 1 + .../js/components/ListExampleShared.js | 48 +- .../js/examples/FlatList/FlatList-basic.js | 71 +- .../examples/FlatList/FlatList-multiColumn.js | 4 +- .../SectionList/SectionList-scrollable.js | 4 +- .../Lists/VirtualizedList.js | 183 ++++- .../Lists/__tests__/VirtualizedList-test.js | 66 +- .../VirtualizedList-test.js.snap | 726 +++++++++++++++--- .../VirtualizedSectionList-test.js.snap | 2 + 10 files changed, 916 insertions(+), 190 deletions(-) diff --git a/packages/react-native/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap b/packages/react-native/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap index 2fec3a235fb118..86901053a00d9f 100644 --- a/packages/react-native/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap +++ b/packages/react-native/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap @@ -111,6 +111,7 @@ exports[`FlatList renders all the bells and whistles 1`] = `
diff --git a/packages/react-native/Libraries/Lists/__tests__/__snapshots__/SectionList-test.js.snap b/packages/react-native/Libraries/Lists/__tests__/__snapshots__/SectionList-test.js.snap index ea1021883c1d92..a700cdb7be2a73 100644 --- a/packages/react-native/Libraries/Lists/__tests__/__snapshots__/SectionList-test.js.snap +++ b/packages/react-native/Libraries/Lists/__tests__/__snapshots__/SectionList-test.js.snap @@ -243,6 +243,7 @@ exports[`SectionList renders all the bells and whistles 1`] = `
{ +function genItemData(i: number): 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; } @@ -147,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 @@ -352,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 = { @@ -362,8 +388,10 @@ module.exports = { ItemSeparatorComponent, PlainInput, SeparatorComponent, + LoadingComponent, 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 97f991265de3ba..7ffaedef0cd3de 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatList-basic.js +++ b/packages/rn-tester/js/examples/FlatList/FlatList-basic.js @@ -35,8 +35,10 @@ import { ItemSeparatorComponent, PlainInput, SeparatorComponent, + LoadingComponent, Spindicator, - genItemData, + genNewerItems, + genOlderItems, getItemLayout, pressItem, renderSmallSwitchOption, @@ -44,6 +46,11 @@ import { import type {Item} from '../../components/ListExampleShared'; +const PAGE_SIZE = 100; +const NUM_PAGES = 10; +const INITIAL_PAGE_OFFSET = Math.floor(NUM_PAGES / 2); +const LOAD_TIME = 2000; + const VIEWABILITY_CONFIG = { minimumViewTime: 3000, viewAreaCoveragePercentThreshold: 100, @@ -53,6 +60,8 @@ const VIEWABILITY_CONFIG = { type Props = $ReadOnly<{||}>; type State = {| data: Array, + first: number, + last: number, debug: boolean, horizontal: boolean, inverted: boolean, @@ -66,13 +75,18 @@ type State = {| onPressDisabled: boolean, textSelectable: boolean, isRTL: boolean, + maintainVisibleContentPosition: boolean, + previousLoading: boolean, + nextLoading: boolean, |}; const IS_RTL = I18nManager.isRTL; class FlatListExample extends React.PureComponent { state: State = { - data: genItemData(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, @@ -86,6 +100,9 @@ class FlatListExample extends React.PureComponent { onPressDisabled: false, textSelectable: true, isRTL: IS_RTL, + maintainVisibleContentPosition: true, + previousLoading: false, + nextLoading: false, }; /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's @@ -209,6 +226,11 @@ class FlatListExample extends React.PureComponent { this.state.isRTL, this._setIsRTL, )} + {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} // $FlowFixMe[missing-empty-array-annot] data={this.state.empty ? [] : filteredData} @@ -250,6 +276,8 @@ class FlatListExample extends React.PureComponent { keyboardShouldPersistTaps="always" keyboardDismissMode="on-drag" numColumns={1} + onStartReached={this._onStartReached} + initialScrollIndex={Math.floor(PAGE_SIZE / 2)} onEndReached={this._onEndReached} onRefresh={this._onRefresh} onScroll={ @@ -260,6 +288,11 @@ class FlatListExample extends React.PureComponent { refreshing={false} contentContainerStyle={styles.list} viewabilityConfig={VIEWABILITY_CONFIG} + maintainVisibleContentPosition={ + this.state.maintainVisibleContentPosition + ? {minIndexForVisible: 0} + : undefined + } {...flatListItemRendererProps} /> @@ -280,13 +313,33 @@ class FlatListExample extends React.PureComponent { _getItemLayout = (data: any, index: number) => { return getItemLayout(data, index, this.state.horizontal); }; + _onStartReached = () => { + if (this.state.first <= 0 || this.state.previousLoading) { + return; + } + + 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.data.length >= 1000) { + if (this.state.last >= PAGE_SIZE * NUM_PAGES || this.state.nextLoading) { return; } - this.setState(state => ({ - data: state.data.concat(genItemData(100, state.data.length)), - })); + + 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); }; // $FlowFixMe[missing-local-annot] _onPressCallback = () => { @@ -343,7 +396,7 @@ class FlatListExample extends React.PureComponent { _pressItem = (key: string) => { 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, diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js b/packages/rn-tester/js/examples/FlatList/FlatList-multiColumn.js index c92c688840fca2..3d8c8256dccb50 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 815eb03bba887b..248f4e59e3804a 100644 --- a/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js +++ b/packages/rn-tester/js/examples/SectionList/SectionList-scrollable.js @@ -22,7 +22,7 @@ const { PlainInput, SeparatorComponent, Spindicator, - genItemData, + genNewerItems, pressItem, renderSmallSwitchOption, renderStackedItem, @@ -170,7 +170,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: Item) => diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index 2aca79a2ea2503..8a23e81cbef1a6 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -73,6 +73,10 @@ type ViewabilityHelperCallbackTuple = { type State = { renderMask: CellRenderMask, cellsAroundViewport: {first: number, last: number}, + // Used to track items added at the start of the list for maintainVisibleContentPosition. + firstVisibleItemKey: ?string, + // When > 0 the scroll position available in JS is considered stale and should not be used. + pendingScrollUpdateCount: number, }; /** @@ -448,9 +452,24 @@ class VirtualizedList extends StateSafePureComponent { const initialRenderRegion = VirtualizedList._initialRenderRegion(props); + const minIndexForVisible = + this.props.maintainVisibleContentPosition?.minIndexForVisible ?? 0; + this.state = { cellsAroundViewport: initialRenderRegion, renderMask: VirtualizedList._createRenderMask(props, initialRenderRegion), + firstVisibleItemKey: + this.props.getItemCount(this.props.data) > minIndexForVisible + ? VirtualizedList._getItemKey(this.props, minIndexForVisible) + : null, + // When we have a non-zero initialScrollIndex, we will receive a + // scroll event later so this will prevent the window from updating + // until we get a valid offset. + pendingScrollUpdateCount: + this.props.initialScrollIndex != null && + this.props.initialScrollIndex > 0 + ? 1 + : 0, }; } @@ -502,6 +521,40 @@ class VirtualizedList extends StateSafePureComponent { } } + 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}, @@ -585,6 +638,7 @@ class VirtualizedList extends StateSafePureComponent { _adjustCellsAroundViewport( props: Props, cellsAroundViewport: {first: number, last: number}, + pendingScrollUpdateCount: number, ): {first: number, last: number} { const {data, getItemCount} = props; const onEndReachedThreshold = onEndReachedThresholdOrDefault( @@ -616,22 +670,9 @@ class VirtualizedList extends StateSafePureComponent { ), }; } else { - // If we have a positive non-zero initialScrollIndex and run this before we've scrolled, - // we'll wipe out the initialNumToRender rendered elements starting at initialScrollIndex. - // So let's wait until we've scrolled the view to the right place. And until then, - // we will trust the initialScrollIndex suggestion. - - // Thus, we want to recalculate the windowed render limits if any of the following hold: - // - initialScrollIndex is undefined or is 0 - // - initialScrollIndex > 0 AND scrolling is complete - // - initialScrollIndex > 0 AND the end of the list is visible (this handles the case - // where the list is shorter than the visible area) - if ( - props.initialScrollIndex != null && - props.initialScrollIndex > 0 && - !this._scrollMetrics.offset && - Math.abs(distanceFromEnd) >= Number.EPSILON - ) { + // 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; @@ -713,14 +754,59 @@ class VirtualizedList extends StateSafePureComponent { 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( - prevState.cellsAroundViewport, + maintainVisibleContentPositionAdjustment != null + ? { + first: + prevState.cellsAroundViewport.first + + maintainVisibleContentPositionAdjustment, + last: + prevState.cellsAroundViewport.last + + maintainVisibleContentPositionAdjustment, + } + : prevState.cellsAroundViewport, newProps, ); return { cellsAroundViewport: constrainedCells, renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells), + firstVisibleItemKey: newFirstVisibleItemKey, + pendingScrollUpdateCount: + maintainVisibleContentPositionAdjustment != null + ? prevState.pendingScrollUpdateCount + 1 + : prevState.pendingScrollUpdateCount, }; } @@ -752,7 +838,7 @@ class VirtualizedList extends StateSafePureComponent { for (let ii = first; ii <= last; ii++) { const item = getItem(data, ii); - const key = this._keyExtractor(item, ii, this.props); + const key = VirtualizedList._keyExtractor(item, ii, this.props); this._indicesToKeys.set(ii, key); if (stickyIndicesFromProps.has(ii + stickyOffset)) { @@ -825,15 +911,14 @@ class VirtualizedList extends StateSafePureComponent { _getSpacerKey = (isVertical: boolean): string => isVertical ? 'height' : 'width'; - _keyExtractor( + static _keyExtractor( item: Item, index: number, props: { keyExtractor?: ?(item: Item, index: number) => string, ... }, - // $FlowFixMe[missing-local-annot] - ) { + ): string { if (props.keyExtractor != null) { return props.keyExtractor(item, index); } @@ -879,6 +964,10 @@ class VirtualizedList extends StateSafePureComponent { cellKey={this._getCellKey() + '-header'} key="$header"> { style: inversionStyle ? [inversionStyle, this.props.style] : this.props.style, + 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; @@ -1457,8 +1556,13 @@ class VirtualizedList extends StateSafePureComponent { onStartReachedThreshold, onEndReached, onEndReachedThreshold, - initialScrollIndex, } = this.props; + // If we have any pending scroll updates it means that the scroll metrics + // are out of date and we should not call any of the edge reached callbacks. + if (this.state.pendingScrollUpdateCount > 0) { + return; + } + const {contentLength, visibleLength, offset} = this._scrollMetrics; let distanceFromStart = offset; let distanceFromEnd = contentLength - visibleLength - offset; @@ -1510,14 +1614,8 @@ class VirtualizedList extends StateSafePureComponent { isWithinStartThreshold && this._scrollMetrics.contentLength !== this._sentStartForContentLength ) { - // On initial mount when using initialScrollIndex the offset will be 0 initially - // and will trigger an unexpected onStartReached. To avoid this we can use - // timestamp to differentiate between the initial scroll metrics and when we actually - // received the first scroll event. - if (!initialScrollIndex || this._scrollMetrics.timestamp !== 0) { - this._sentStartForContentLength = this._scrollMetrics.contentLength; - onStartReached({distanceFromStart}); - } + this._sentStartForContentLength = this._scrollMetrics.contentLength; + onStartReached({distanceFromStart}); } // If the user scrolls away from the start or end and back again, @@ -1644,6 +1742,11 @@ class VirtualizedList extends StateSafePureComponent { visibleLength, zoomScale, }; + if (this.state.pendingScrollUpdateCount > 0) { + this.setState(state => ({ + pendingScrollUpdateCount: state.pendingScrollUpdateCount - 1, + })); + } this._updateViewableItems(this.props, this.state.cellsAroundViewport); if (!this.props) { return; @@ -1759,6 +1862,7 @@ class VirtualizedList extends StateSafePureComponent { const cellsAroundViewport = this._adjustCellsAroundViewport( props, state.cellsAroundViewport, + state.pendingScrollUpdateCount, ); const renderMask = VirtualizedList._createRenderMask( props, @@ -1789,7 +1893,7 @@ class VirtualizedList extends StateSafePureComponent { return { index, item, - key: this._keyExtractor(item, index, props), + key: VirtualizedList._keyExtractor(item, index, props), isViewable, }; }; @@ -1850,13 +1954,12 @@ class VirtualizedList extends StateSafePureComponent { inLayout?: boolean, ... } => { - const {data, getItem, getItemCount, getItemLayout} = props; + const {data, getItemCount, getItemLayout} = props; invariant( index >= 0 && index < getItemCount(data), 'Tried to get frame for out of range index ' + index, ); - const item = getItem(data, index); - const frame = this._frames[this._keyExtractor(item, index, props)]; + const frame = this._frames[VirtualizedList._getItemKey(props, index)]; if (!frame || frame.index !== index) { if (getItemLayout) { /* $FlowFixMe[prop-missing] (>=0.63.0 site=react_native_fb) This comment @@ -1891,11 +1994,8 @@ class VirtualizedList extends StateSafePureComponent { // where it is. if ( focusedCellIndex >= itemCount || - this._keyExtractor( - props.getItem(props.data, focusedCellIndex), - focusedCellIndex, - props, - ) !== this._lastFocusedCellKey + VirtualizedList._getItemKey(props, focusedCellIndex) !== + this._lastFocusedCellKey ) { return []; } @@ -1936,6 +2036,11 @@ class VirtualizedList extends StateSafePureComponent { props: FrameMetricProps, cellsAroundViewport: {first: number, last: number}, ) { + // If we have any pending scroll updates it means that the scroll metrics + // are out of date and we should not call any of the visibility callbacks. + if (this.state.pendingScrollUpdateCount > 0) { + return; + } this._viewabilityTuples.forEach(tuple => { tuple.viewabilityHelper.onUpdate( props, diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js index 218f7c1cae71db..c030e8a2d7e690 100644 --- a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js @@ -2164,10 +2164,70 @@ it('virtualizes away last focused index if item removed', () => { expect(component).toMatchSnapshot(); }); -function generateItems(count) { +it('handles maintainVisibleContentPosition', () => { + const items = generateItems(20); + const ITEM_HEIGHT = 10; + + let component; + ReactTestRenderer.act(() => { + component = ReactTestRenderer.create( + , + ); + }); + + ReactTestRenderer.act(() => { + simulateLayout(component, { + viewport: {width: 10, height: 50}, + content: {width: 10, height: items.length * ITEM_HEIGHT}, + }); + + performAllBatches(); + }); + + // Initial render. + expect(component).toMatchSnapshot(); + + // Add new items at the start of the list to trigger the maintainVisibleContentPosition adjustment. + const newItems = [...generateItems(10, items.length), ...items]; + ReactTestRenderer.act(() => { + component.update( + , + ); + }); + + // Previously rendered cells should be rendered still. + expect(component).toMatchSnapshot(); + + // Simulate scroll adjustment from native maintainVisibleContentPosition. + ReactTestRenderer.act(() => { + simulateContentLayout(component, { + width: 10, + height: newItems.length * ITEM_HEIGHT, + }); + simulateScroll(component, {x: 0, y: 10 * ITEM_HEIGHT}); + performAllBatches(); + }); + + // Previously rendered cells should be rendered still + starting to render new cells ahead. + expect(component).toMatchSnapshot(); +}); + +function generateItems(count, startKey = 0) { return Array(count) .fill() - .map((_, i) => ({key: i})); + .map((_, i) => ({key: i + startKey})); } function generateItemsStickyEveryN(count, n) { @@ -2225,7 +2285,7 @@ function simulateContentLayout(component, dimensions) { function simulateCellLayout(component, items, itemIndex, dimensions) { const instance = component.getInstance(); - const cellKey = instance._keyExtractor( + const cellKey = VirtualizedList._keyExtractor( items[itemIndex], itemIndex, instance.props, diff --git a/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap b/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap index cd174da04cca2c..b877e3da84d999 100644 --- a/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap +++ b/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedList-test.js.snap @@ -65,6 +65,7 @@ exports[`VirtualizedList forwards correct stickyHeaderIndices when ListHeaderCom >
@@ -1048,6 +1049,7 @@ exports[`VirtualizedList renders all the bells and whistles 1`] = `
@@ -1834,45 +1837,12 @@ exports[`adjusts render area with non-zero initialScrollIndex 1`] = ` /> - - - - - - - - - - - - - - + style={ + Object { + "height": 50, + } + } + /> `; @@ -3173,7 +3143,7 @@ exports[`gracefully handles negative initialScrollIndex 1`] = ` `; -exports[`initially renders nothing when initialNumToRender is 0 1`] = ` +exports[`handles maintainVisibleContentPosition 1`] = ` + + + + + + + + + + + + + + + @@ -3236,10 +3282,40 @@ exports[`initially renders nothing when initialNumToRender is 0 1`] = ` `; -exports[`keeps viewport above last focused rendered 1`] = ` +exports[`handles maintainVisibleContentPosition 2`] = ` - - - - - - - - - - - - @@ -3371,23 +3420,7 @@ exports[`keeps viewport above last focused rendered 1`] = ` style={null} > - - - - - - - - + style={ + Object { + "height": 150, + } + } + /> `; -exports[`keeps viewport below last focused rendered 1`] = ` +exports[`handles maintainVisibleContentPosition 3`] = ` + + + + + + + + + + + + + + +`; + +exports[`initially renders nothing when initialNumToRender is 0 1`] = ` + + + + + +`; + +exports[`keeps viewport above last focused rendered 1`] = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +exports[`keeps viewport below last focused rendered 1`] = ` + + + + + + + + + + + + + - - - - - + style={ + Object { + "height": 20, + } + } + /> `; diff --git a/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedSectionList-test.js.snap b/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedSectionList-test.js.snap index 9f2d41bf9aa513..3f64c8856611e6 100644 --- a/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedSectionList-test.js.snap +++ b/packages/virtualized-lists/Lists/__tests__/__snapshots__/VirtualizedSectionList-test.js.snap @@ -831,6 +831,7 @@ exports[`VirtualizedSectionList renders all the bells and whistles 1`] = `