Skip to content

Commit ca132d2

Browse files
Abbondanzofacebook-github-bot
authored andcommitted
Fire VirtualizedList edge callbacks after programmatic scrolling
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. Track both content length and item count when an edge callback is sent so internal measurement changes cannot emit duplicates while the list remains at the same edge; scrolling away still re-arms the callback. Changelog: [General][Fixed] - Fire `VirtualizedList` edge callbacks after programmatic scrolling Differential Revision: D100870075
1 parent 18f5ddb commit ca132d2

4 files changed

Lines changed: 226 additions & 27 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: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,7 +1195,7 @@ class VirtualizedList extends StateSafePureComponent<
11951195
}
11961196

11971197
componentDidUpdate(prevProps: VirtualizedListProps) {
1198-
const {data, extraData, getItemLayout} = this.props;
1198+
const {data, extraData} = this.props;
11991199
if (data !== prevProps.data || extraData !== prevProps.extraData) {
12001200
// clear the viewableIndices cache to also trigger
12011201
// the onViewableItemsChanged callback with the new data
@@ -1217,11 +1217,14 @@ class VirtualizedList extends StateSafePureComponent<
12171217
this._hiPriInProgress = false;
12181218
}
12191219

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) {
1220+
// We only call `onEndReached` after we render the last cell, but
1221+
// programmatic scrolling (e.g. scrollToItem, scrollToIndex) or using
1222+
// getItemLayout can scroll past the last rendered cell, and never
1223+
// trigger a new layout or bounds change, so we need to check again
1224+
// after rendering more cells. We gate on having received at least one
1225+
// scroll event (timestamp > 0) to avoid false onEndReached calls
1226+
// during initial render.
1227+
if (this.props.getItemLayout != null || this._scrollMetrics.timestamp > 0) {
12251228
this._maybeCallOnEdgeReached();
12261229
}
12271230
}
@@ -1265,7 +1268,9 @@ class VirtualizedList extends StateSafePureComponent<
12651268
};
12661269
_scrollRef: ?React.ElementRef<typeof ScrollView> = null;
12671270
_sentStartForContentLength = 0;
1271+
_sentStartForItemCount = 0;
12681272
_sentEndForContentLength = 0;
1273+
_sentEndForItemCount = 0;
12691274
_updateCellsToRenderTimeoutID: ?ReturnType<typeof setTimeout> = null;
12701275
_viewabilityTuples: Array<ViewabilityHelperCallbackTuple> = [];
12711276

@@ -1610,39 +1615,48 @@ class VirtualizedList extends StateSafePureComponent<
16101615
const isWithinStartThreshold = distanceFromStart <= startThreshold;
16111616
const isWithinEndThreshold = distanceFromEnd <= endThreshold;
16121617

1618+
const itemCount = getItemCount(data);
1619+
16131620
// First check if the user just scrolled within the end threshold
1614-
// and call onEndReached only once for a given content length,
1621+
// and call onEndReached only once for a given content length and item count,
16151622
// and only if onStartReached is not being executed
16161623
if (
16171624
onEndReached &&
1618-
this.state.cellsAroundViewport.last === getItemCount(data) - 1 &&
1625+
this.state.cellsAroundViewport.last === itemCount - 1 &&
16191626
isWithinEndThreshold &&
1620-
this._listMetrics.getContentLength() !== this._sentEndForContentLength
1627+
this._listMetrics.getContentLength() !== this._sentEndForContentLength &&
1628+
itemCount !== this._sentEndForItemCount
16211629
) {
16221630
this._sentEndForContentLength = this._listMetrics.getContentLength();
1631+
this._sentEndForItemCount = itemCount;
16231632
onEndReached({distanceFromEnd});
16241633
}
16251634

16261635
// Next check if the user just scrolled within the start threshold
1627-
// and call onStartReached only once for a given content length,
1636+
// and call onStartReached only once for a given content length and item count,
16281637
// and only if onEndReached is not being executed
16291638
if (
16301639
onStartReached != null &&
16311640
this.state.cellsAroundViewport.first === 0 &&
16321641
isWithinStartThreshold &&
1633-
this._listMetrics.getContentLength() !== this._sentStartForContentLength
1642+
this._listMetrics.getContentLength() !==
1643+
this._sentStartForContentLength &&
1644+
itemCount !== this._sentStartForItemCount
16341645
) {
16351646
this._sentStartForContentLength = this._listMetrics.getContentLength();
1647+
this._sentStartForItemCount = itemCount;
16361648
onStartReached({distanceFromStart});
16371649
}
16381650

16391651
// If the user scrolls away from the start or end and back again,
16401652
// cause onStartReached or onEndReached to be triggered again
16411653
if (!isWithinStartThreshold) {
16421654
this._sentStartForContentLength = 0;
1655+
this._sentStartForItemCount = 0;
16431656
}
16441657
if (!isWithinEndThreshold) {
16451658
this._sentEndForContentLength = 0;
1659+
this._sentEndForItemCount = 0;
16461660
}
16471661
}
16481662

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

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,94 @@ describe('VirtualizedList', () => {
799799
expect(onEndReached).toHaveBeenCalled();
800800
});
801801

802+
it('calls onEndReached once after a programmatic scroll renders the last cell', async () => {
803+
const ITEM_HEIGHT = 40;
804+
const layout = {width: 300, height: 600};
805+
let data = Array(40)
806+
.fill()
807+
.map((_, index) => ({key: `key-${index}`}));
808+
const onEndReached = jest.fn();
809+
const props = {
810+
data,
811+
initialNumToRender: 10,
812+
onEndReachedThreshold: 1,
813+
windowSize: 2,
814+
renderItem: ({item}) => <item value={item.key} />,
815+
getItem: (items, index) => items[index],
816+
getItemCount: items => items.length,
817+
onEndReached,
818+
};
819+
820+
let component;
821+
await act(() => {
822+
component = create(<VirtualizedList {...props} />);
823+
});
824+
const instance = component.getInstance();
825+
826+
await act(() => {
827+
instance._onLayout({nativeEvent: {layout, zoomScale: 1}});
828+
instance._onContentSizeChange(300, data.length * ITEM_HEIGHT);
829+
for (let i = 0; i < props.initialNumToRender; i++) {
830+
simulateCellLayout(component, data, i, {
831+
width: layout.width,
832+
height: ITEM_HEIGHT,
833+
x: 0,
834+
y: i * ITEM_HEIGHT,
835+
});
836+
}
837+
performAllBatches();
838+
});
839+
expect(onEndReached).not.toHaveBeenCalled();
840+
841+
const scrollToEnd = async (timeStamp: number) => {
842+
await act(() => {
843+
instance._onScroll({
844+
timeStamp,
845+
nativeEvent: {
846+
contentOffset: {
847+
y: data.length * ITEM_HEIGHT - layout.height,
848+
x: 0,
849+
},
850+
layoutMeasurement: layout,
851+
contentSize: {...layout, height: data.length * ITEM_HEIGHT},
852+
zoomScale: 1,
853+
contentInset: {right: 0, top: 0, left: 0, bottom: 0},
854+
},
855+
});
856+
performAllBatches();
857+
});
858+
};
859+
860+
await scrollToEnd(1000);
861+
expect(onEndReached).toHaveBeenCalledTimes(1);
862+
expect(onEndReached).toHaveBeenLastCalledWith({distanceFromEnd: 0});
863+
864+
await act(() => {
865+
instance._onContentSizeChange(300, data.length * ITEM_HEIGHT + 1);
866+
performAllBatches();
867+
});
868+
expect(onEndReached).toHaveBeenCalledTimes(1);
869+
870+
await scrollToEnd(2000);
871+
expect(onEndReached).toHaveBeenCalledTimes(1);
872+
873+
await act(() => {
874+
instance._onScroll({
875+
timeStamp: 3000,
876+
nativeEvent: {
877+
contentOffset: {y: 0, x: 0},
878+
layoutMeasurement: layout,
879+
contentSize: {...layout, height: data.length * ITEM_HEIGHT},
880+
zoomScale: 1,
881+
contentInset: {right: 0, top: 0, left: 0, bottom: 0},
882+
},
883+
});
884+
performAllBatches();
885+
});
886+
await scrollToEnd(4000);
887+
expect(onEndReached).toHaveBeenCalledTimes(2);
888+
});
889+
802890
it('does not call onEndReached when onContentSizeChange happens after onLayout', async () => {
803891
const ITEM_HEIGHT = 40;
804892
const layout = {width: 300, height: 600};

0 commit comments

Comments
 (0)