Skip to content

Commit c97eaa6

Browse files
Abbondanzofacebook-github-bot
authored andcommitted
Fire VirtualizedList edge callbacks after programmatic scrolling (#58657)
Summary: Programmatic list scrolling can move to an edge before asynchronously scheduled cells finish rendering. The scroll handler then skips the edge callback because the terminal cell is not yet in the render window, and no later scroll or layout event is guaranteed. Recheck edge callbacks after post-scroll render updates. Deduplicate each edge by item count and edge item key, re-arm on real movement away from the edge, and allow data-backed layout growth to start a new end-edge generation without treating measurement-only churn as a new callback. Changelog: [General][Fixed] - Fire `VirtualizedList` edge callbacks after programmatic scrolling Differential Revision: D100870075
1 parent 77e25f6 commit c97eaa6

4 files changed

Lines changed: 456 additions & 48 deletions

File tree

‎packages/rn-tester/js/examples/FlatList/BaseFlatListExample.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const Item = ({item, separators}: ListRenderItemInfo<string>) => {
6060
};
6161

6262
type Props = Readonly<{
63+
data?: ReadonlyArray<string>,
6364
exampleProps: Partial<React.ElementConfig<typeof FlatList>>,
6465
exampleTestID?: ?string,
6566
onTest?: ?() => void,
@@ -95,7 +96,7 @@ const BaseFlatListExample: component(
9596
ref={ref}
9697
testID="flat_list"
9798
// $FlowFixMe[incompatible-type]
98-
data={DATA}
99+
data={props.data ?? DATA}
99100
keyExtractor={(item, index) => item + index}
100101
style={styles.list}
101102
// $FlowFixMe[incompatible-type]

‎packages/rn-tester/js/examples/FlatList/FlatList-onEndReached.js‎

Lines changed: 111 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,41 +12,137 @@
1212

1313
import type {RNTesterModuleExample} from '../../types/RNTesterTypes';
1414

15-
import BaseFlatListExample from './BaseFlatListExample';
15+
import BaseFlatListExample, {ITEM_HEIGHT} from './BaseFlatListExample';
1616
import * as React from 'react';
1717
import {useRef, useState} from 'react';
18+
import {Button, FlatList} from 'react-native';
19+
20+
const DATA = Array.from({length: 20}, (_, index) => `Item ${index}`);
21+
const SCROLL_TO_ITEM = DATA[18];
22+
23+
type EventCounts = {
24+
dragEvents: number,
25+
onEndReached: number,
26+
onMomentumScrollEnd: number,
27+
onStartReached: number,
28+
};
1829

1930
export component FlatList_onEndReached() {
20-
const [output, setOutput] = useState('');
21-
const exampleProps = {
22-
onEndReached: (info: {distanceFromEnd: number, ...}) =>
23-
setOutput('onEndReached'),
24-
onEndReachedThreshold: 0,
31+
const [output, setOutput] = useState('ready');
32+
const listRef = useRef<?FlatList<string>>(null);
33+
const actionRef = useRef<'end' | 'start'>('end');
34+
const attemptsRef = useRef(0);
35+
const eventCountsRef = useRef<EventCounts>({
36+
dragEvents: 0,
37+
onEndReached: 0,
38+
onMomentumScrollEnd: 0,
39+
onStartReached: 0,
40+
});
41+
const recordEventsRef = useRef(false);
42+
const momentumEndedRef = useRef(false);
43+
44+
const report = () => {
45+
const {dragEvents, onEndReached, onMomentumScrollEnd, onStartReached} =
46+
eventCountsRef.current;
47+
setOutput(
48+
`${actionRef.current}: attempts=${attemptsRef.current}, ` +
49+
`onEndReached=${onEndReached}, onStartReached=${onStartReached}, ` +
50+
`onMomentumScrollEnd=${onMomentumScrollEnd}, dragEvents=${dragEvents}`,
51+
);
52+
};
53+
54+
const onEndReached = () => {
55+
if (recordEventsRef.current) {
56+
eventCountsRef.current.onEndReached++;
57+
if (momentumEndedRef.current) {
58+
report();
59+
}
60+
}
61+
};
62+
63+
const onStartReached = () => {
64+
if (recordEventsRef.current) {
65+
eventCountsRef.current.onStartReached++;
66+
if (momentumEndedRef.current) {
67+
report();
68+
}
69+
}
2570
};
26-
const ref = useRef<any>(null);
2771

28-
const onTest = () => {
29-
const scrollResponder = ref?.current?.getScrollResponder();
30-
if (scrollResponder != null) {
31-
scrollResponder.scrollToEnd();
72+
const onMomentumScrollEnd = () => {
73+
if (recordEventsRef.current) {
74+
eventCountsRef.current.onMomentumScrollEnd++;
75+
momentumEndedRef.current = true;
76+
report();
3277
}
3378
};
3479

80+
const onDragEvent = () => {
81+
if (recordEventsRef.current) {
82+
eventCountsRef.current.dragEvents++;
83+
}
84+
};
85+
86+
const scrollToEnd = () => {
87+
recordEventsRef.current = true;
88+
momentumEndedRef.current = false;
89+
actionRef.current = 'end';
90+
attemptsRef.current++;
91+
setOutput('running');
92+
listRef.current?.scrollToItem({
93+
animated: true,
94+
item: SCROLL_TO_ITEM,
95+
viewOffset: -ITEM_HEIGHT,
96+
});
97+
};
98+
99+
const scrollToStart = () => {
100+
momentumEndedRef.current = false;
101+
actionRef.current = 'start';
102+
attemptsRef.current++;
103+
setOutput('running');
104+
listRef.current?.scrollToOffset({animated: true, offset: 0});
105+
};
106+
107+
const exampleProps = {
108+
initialNumToRender: 19,
109+
onEndReached,
110+
onEndReachedThreshold: 0.2,
111+
onMomentumScrollEnd,
112+
onScrollBeginDrag: onDragEvent,
113+
onScrollEndDrag: onDragEvent,
114+
onStartReached,
115+
onStartReachedThreshold: 0.1,
116+
windowSize: 2,
117+
};
118+
35119
return (
36120
<BaseFlatListExample
37-
ref={ref}
121+
ref={listRef}
122+
data={DATA}
38123
exampleProps={exampleProps}
39124
testOutput={output}
40-
onTest={onTest}
41-
/>
125+
onTest={scrollToEnd}
126+
testLabel="Scroll to item">
127+
<Button
128+
testID="scroll_to_start"
129+
onPress={scrollToStart}
130+
title="Scroll to start"
131+
/>
132+
<Button
133+
testID="scroll_to_end"
134+
onPress={scrollToEnd}
135+
title="Scroll to item"
136+
/>
137+
</BaseFlatListExample>
42138
);
43139
}
44140

45141
export default {
46142
title: 'onEndReached',
47143
name: 'onEndReached',
48144
description:
49-
'Scroll to end of list or tap Test button to see `onEndReached` triggered.',
145+
'Programmatic scrolling calls edge callbacks once and does not emit drag callbacks.',
50146
render: function () {
51147
return <FlatList_onEndReached />;
52148
},

‎packages/virtualized-lists/Lists/VirtualizedList.js‎

Lines changed: 116 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,7 +1195,10 @@ class VirtualizedList extends StateSafePureComponent<
11951195
}
11961196

11971197
componentDidUpdate(prevProps: VirtualizedListProps) {
1198-
const {data, extraData, getItemLayout} = this.props;
1198+
const {data, extraData} = this.props;
1199+
if (data !== prevProps.data) {
1200+
this._dataChangeCount++;
1201+
}
11991202
if (data !== prevProps.data || extraData !== prevProps.extraData) {
12001203
// clear the viewableIndices cache to also trigger
12011204
// the onViewableItemsChanged callback with the new data
@@ -1217,11 +1220,14 @@ class VirtualizedList extends StateSafePureComponent<
12171220
this._hiPriInProgress = false;
12181221
}
12191222

1220-
// We only call `onEndReached` after we render the last cell, but when
1221-
// getItemLayout is present, we can scroll past the last rendered cell, and
1222-
// never trigger a new layout or bounds change, so we need to check again
1223-
// after rendering more cells.
1224-
if (getItemLayout != null) {
1223+
// We only call `onEndReached` after we render the last cell, but
1224+
// programmatic scrolling (e.g. scrollToItem, scrollToIndex) or using
1225+
// getItemLayout can scroll past the last rendered cell, and never
1226+
// trigger a new layout or bounds change, so we need to check again
1227+
// after rendering more cells. We gate on having received at least one
1228+
// scroll event (timestamp > 0) to avoid false onEndReached calls
1229+
// during initial render.
1230+
if (this.props.getItemLayout != null || this._scrollMetrics.timestamp > 0) {
12251231
this._maybeCallOnEdgeReached();
12261232
}
12271233
}
@@ -1264,8 +1270,16 @@ class VirtualizedList extends StateSafePureComponent<
12641270
zoomScale: 1,
12651271
};
12661272
_scrollRef: ?React.ElementRef<typeof ScrollView> = null;
1267-
_sentStartForContentLength = 0;
1268-
_sentEndForContentLength = 0;
1273+
_contentLengthDataChangeCount: number = 0;
1274+
_dataChangeCount: number = 0;
1275+
_sentStartForFirstItemKey: ?string = null;
1276+
_sentStartForItemCount: ?number = null;
1277+
_sentStartForOffset: ?number = null;
1278+
_sentEndForContentLength: ?number = null;
1279+
_sentEndForDataChangeCount: ?number = null;
1280+
_sentEndForItemCount: ?number = null;
1281+
_sentEndForLastItemKey: ?string = null;
1282+
_sentEndForOffset: ?number = null;
12691283
_updateCellsToRenderTimeoutID: ?ReturnType<typeof setTimeout> = null;
12701284
_viewabilityTuples: Array<ViewabilityHelperCallbackTuple> = [];
12711285

@@ -1557,9 +1571,10 @@ class VirtualizedList extends StateSafePureComponent<
15571571
};
15581572
}
15591573

1560-
_maybeCallOnEdgeReached() {
1574+
_maybeCallOnEdgeReached(isScrollEvent: boolean = false) {
15611575
const {
15621576
data,
1577+
getItem,
15631578
getItemCount,
15641579
onStartReached,
15651580
onStartReachedThreshold,
@@ -1581,9 +1596,9 @@ class VirtualizedList extends StateSafePureComponent<
15811596
}
15821597

15831598
const {visibleLength, offset} = this._scrollMetrics;
1599+
const contentLength = this._listMetrics.getContentLength();
15841600
let distanceFromStart = offset;
1585-
let distanceFromEnd =
1586-
this._listMetrics.getContentLength() - visibleLength - offset;
1601+
let distanceFromEnd = contentLength - visibleLength - offset;
15871602

15881603
// Especially when oERT is zero it's necessary to 'floor' very small distance values to be 0
15891604
// since debouncing causes us to not fire this event for every single "pixel" we scroll and can thus
@@ -1610,39 +1625,102 @@ class VirtualizedList extends StateSafePureComponent<
16101625
const isWithinStartThreshold = distanceFromStart <= startThreshold;
16111626
const isWithinEndThreshold = distanceFromEnd <= endThreshold;
16121627

1613-
// First check if the user just scrolled within the end threshold
1614-
// and call onEndReached only once for a given content length,
1615-
// and only if onStartReached is not being executed
1628+
const itemCount = getItemCount(data);
1629+
16161630
if (
16171631
onEndReached &&
1618-
this.state.cellsAroundViewport.last === getItemCount(data) - 1 &&
1619-
isWithinEndThreshold &&
1620-
this._listMetrics.getContentLength() !== this._sentEndForContentLength
1632+
this.state.cellsAroundViewport.last === itemCount - 1 &&
1633+
isWithinEndThreshold
16211634
) {
1622-
this._sentEndForContentLength = this._listMetrics.getContentLength();
1623-
onEndReached({distanceFromEnd});
1635+
const lastItemKey =
1636+
itemCount > 0
1637+
? VirtualizedList._keyExtractor(
1638+
getItem(data, itemCount - 1),
1639+
itemCount - 1,
1640+
this.props,
1641+
)
1642+
: null;
1643+
// A changed edge may represent a new callback generation even if the
1644+
// item count is unchanged.
1645+
if (
1646+
itemCount !== this._sentEndForItemCount ||
1647+
lastItemKey !== this._sentEndForLastItemKey ||
1648+
(this._dataChangeCount !== this._sentEndForDataChangeCount &&
1649+
this._contentLengthDataChangeCount === this._dataChangeCount &&
1650+
contentLength !== this._sentEndForContentLength) ||
1651+
(isScrollEvent &&
1652+
this._sentEndForContentLength != null &&
1653+
this._sentEndForOffset != null &&
1654+
contentLength > this._sentEndForContentLength &&
1655+
offset > this._sentEndForOffset)
1656+
) {
1657+
this._sentEndForContentLength = contentLength;
1658+
this._sentEndForDataChangeCount = this._dataChangeCount;
1659+
this._sentEndForItemCount = itemCount;
1660+
this._sentEndForLastItemKey = lastItemKey;
1661+
this._sentEndForOffset = offset;
1662+
onEndReached({distanceFromEnd});
1663+
}
16241664
}
16251665

1626-
// Next check if the user just scrolled within the start threshold
1627-
// and call onStartReached only once for a given content length,
1628-
// and only if onEndReached is not being executed
16291666
if (
16301667
onStartReached != null &&
16311668
this.state.cellsAroundViewport.first === 0 &&
1632-
isWithinStartThreshold &&
1633-
this._listMetrics.getContentLength() !== this._sentStartForContentLength
1669+
isWithinStartThreshold
16341670
) {
1635-
this._sentStartForContentLength = this._listMetrics.getContentLength();
1636-
onStartReached({distanceFromStart});
1671+
const firstItemKey =
1672+
itemCount > 0
1673+
? VirtualizedList._keyExtractor(getItem(data, 0), 0, this.props)
1674+
: null;
1675+
// The start edge changes only when its item identity or count changes.
1676+
if (
1677+
itemCount !== this._sentStartForItemCount ||
1678+
firstItemKey !== this._sentStartForFirstItemKey
1679+
) {
1680+
this._sentStartForFirstItemKey = firstItemKey;
1681+
this._sentStartForItemCount = itemCount;
1682+
this._sentStartForOffset = offset;
1683+
onStartReached({distanceFromStart});
1684+
}
16371685
}
16381686

16391687
// If the user scrolls away from the start or end and back again,
16401688
// cause onStartReached or onEndReached to be triggered again
1641-
if (!isWithinStartThreshold) {
1642-
this._sentStartForContentLength = 0;
1689+
if (isScrollEvent && this._sentStartForOffset != null) {
1690+
if (offset < this._sentStartForOffset) {
1691+
this._sentStartForOffset = offset;
1692+
} else if (!isWithinStartThreshold && offset > this._sentStartForOffset) {
1693+
this._sentStartForFirstItemKey = null;
1694+
this._sentStartForItemCount = null;
1695+
this._sentStartForOffset = null;
1696+
}
16431697
}
1644-
if (!isWithinEndThreshold) {
1645-
this._sentEndForContentLength = 0;
1698+
if (this._sentEndForOffset != null) {
1699+
if (
1700+
!isScrollEvent &&
1701+
this._dataChangeCount !== this._sentEndForDataChangeCount &&
1702+
this._contentLengthDataChangeCount === this._dataChangeCount &&
1703+
contentLength !== this._sentEndForContentLength &&
1704+
!isWithinEndThreshold
1705+
) {
1706+
this._sentEndForContentLength = null;
1707+
this._sentEndForDataChangeCount = null;
1708+
this._sentEndForItemCount = null;
1709+
this._sentEndForLastItemKey = null;
1710+
this._sentEndForOffset = null;
1711+
} else if (isScrollEvent && offset > this._sentEndForOffset) {
1712+
this._sentEndForOffset = offset;
1713+
} else if (
1714+
isScrollEvent &&
1715+
!isWithinEndThreshold &&
1716+
offset < this._sentEndForOffset
1717+
) {
1718+
this._sentEndForContentLength = null;
1719+
this._sentEndForDataChangeCount = null;
1720+
this._sentEndForItemCount = null;
1721+
this._sentEndForLastItemKey = null;
1722+
this._sentEndForOffset = null;
1723+
}
16461724
}
16471725
}
16481726

@@ -1651,6 +1729,7 @@ class VirtualizedList extends StateSafePureComponent<
16511729
layout: {width, height},
16521730
orientation: this._orientation(),
16531731
});
1732+
this._contentLengthDataChangeCount = this._dataChangeCount;
16541733

16551734
this._maybeScrollToInitialScrollIndex(width, height);
16561735

@@ -1784,13 +1863,18 @@ class VirtualizedList extends StateSafePureComponent<
17841863
zoomScale,
17851864
};
17861865
if (this.state.pendingScrollUpdateCount > 0) {
1787-
this.setState<'pendingScrollUpdateCount'>({pendingScrollUpdateCount: 0});
1866+
this.setState<'pendingScrollUpdateCount'>(
1867+
{pendingScrollUpdateCount: 0},
1868+
() => {
1869+
this._maybeCallOnEdgeReached(true);
1870+
},
1871+
);
17881872
}
17891873
this._updateViewableItems(this.props, this.state.cellsAroundViewport);
17901874
if (!this.props) {
17911875
return;
17921876
}
1793-
this._maybeCallOnEdgeReached();
1877+
this._maybeCallOnEdgeReached(true);
17941878
if (velocity !== 0) {
17951879
this._fillRateHelper.activate();
17961880
}

0 commit comments

Comments
 (0)