Skip to content

Commit 7683713

Browse files
janicduplessisfacebook-github-bot
authored andcommitted
Add onStartReached and onStartReachedThreshold to VirtualizedList (#35321)
Summary: Add `onStartReached` and `onStartReachedThreshold` to `VirtualizedList`. This allows implementing bidirectional paging. ## Changelog [General] [Added] - Add onStartReached and onStartReachedThreshold to VirtualizedList Pull Request resolved: #35321 Test Plan: Tested in the new RN tester example that the callback is triggered when close to the start of the list. Reviewed By: yungsters Differential Revision: D41653054 Pulled By: NickGerleman fbshipit-source-id: 368b357fa0d83a43afb52a3f8df84a2fbbedc132
1 parent 79e603c commit 7683713

File tree

8 files changed

+362
-45
lines changed

8 files changed

+362
-45
lines changed

Libraries/Lists/FlatList.d.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -104,19 +104,6 @@ export interface FlatListProps<ItemT> extends VirtualizedListProps<ItemT> {
104104
*/
105105
numColumns?: number | undefined;
106106

107-
/**
108-
* Called once when the scroll position gets within onEndReachedThreshold of the rendered content.
109-
*/
110-
onEndReached?: ((info: {distanceFromEnd: number}) => void) | null | undefined;
111-
112-
/**
113-
* How far from the end (in units of visible length of the list) the bottom edge of the
114-
* list must be from the end of the content to trigger the `onEndReached` callback.
115-
* Thus a value of 0.5 will trigger `onEndReached` when the end of the content is
116-
* within half the visible length of the list.
117-
*/
118-
onEndReachedThreshold?: number | null | undefined;
119-
120107
/**
121108
* If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality.
122109
* Make sure to also set the refreshing prop correctly.

Libraries/Lists/VirtualizedList.d.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,8 +262,18 @@ export interface VirtualizedListWithoutRenderItemProps<ItemT>
262262
*/
263263
maxToRenderPerBatch?: number | undefined;
264264

265+
/**
266+
* Called once when the scroll position gets within within `onEndReachedThreshold`
267+
* from the logical end of the list.
268+
*/
265269
onEndReached?: ((info: {distanceFromEnd: number}) => void) | null | undefined;
266270

271+
/**
272+
* How far from the end (in units of visible length of the list) the trailing edge of the
273+
* list must be from the end of the content to trigger the `onEndReached` callback.
274+
* Thus, a value of 0.5 will trigger `onEndReached` when the end of the content is
275+
* within half the visible length of the list.
276+
*/
267277
onEndReachedThreshold?: number | null | undefined;
268278

269279
onLayout?: ((event: LayoutChangeEvent) => void) | undefined;
@@ -287,6 +297,23 @@ export interface VirtualizedListWithoutRenderItemProps<ItemT>
287297
}) => void)
288298
| undefined;
289299

300+
/**
301+
* Called once when the scroll position gets within within `onStartReachedThreshold`
302+
* from the logical start of the list.
303+
*/
304+
onStartReached?:
305+
| ((info: {distanceFromStart: number}) => void)
306+
| null
307+
| undefined;
308+
309+
/**
310+
* How far from the start (in units of visible length of the list) the leading edge of the
311+
* list must be from the start of the content to trigger the `onStartReached` callback.
312+
* Thus, a value of 0.5 will trigger `onStartReached` when the start of the content is
313+
* within half the visible length of the list.
314+
*/
315+
onStartReachedThreshold?: number | null | undefined;
316+
290317
/**
291318
* Called when the viewability of rows changes, as defined by the
292319
* `viewabilityConfig` prop.

Libraries/Lists/VirtualizedList.js

Lines changed: 92 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ import * as React from 'react';
5050

5151
export type {RenderItemProps, RenderItemType, Separators};
5252

53-
const ON_END_REACHED_EPSILON = 0.001;
53+
const ON_EDGE_REACHED_EPSILON = 0.001;
5454

5555
let _usedIndexForKey = false;
5656
let _keylessItemComponentName: string = '';
@@ -90,11 +90,21 @@ function maxToRenderPerBatchOrDefault(maxToRenderPerBatch: ?number) {
9090
return maxToRenderPerBatch ?? 10;
9191
}
9292

93+
// onStartReachedThresholdOrDefault(this.props.onStartReachedThreshold)
94+
function onStartReachedThresholdOrDefault(onStartReachedThreshold: ?number) {
95+
return onStartReachedThreshold ?? 2;
96+
}
97+
9398
// onEndReachedThresholdOrDefault(this.props.onEndReachedThreshold)
9499
function onEndReachedThresholdOrDefault(onEndReachedThreshold: ?number) {
95100
return onEndReachedThreshold ?? 2;
96101
}
97102

103+
// getScrollingThreshold(visibleLength, onEndReachedThreshold)
104+
function getScrollingThreshold(threshold: number, visibleLength: number) {
105+
return (threshold * visibleLength) / 2;
106+
}
107+
98108
// scrollEventThrottleOrDefault(this.props.scrollEventThrottle)
99109
function scrollEventThrottleOrDefault(scrollEventThrottle: ?number) {
100110
return scrollEventThrottle ?? 50;
@@ -1114,6 +1124,7 @@ export default class VirtualizedList extends StateSafePureComponent<
11141124
zoomScale: 1,
11151125
};
11161126
_scrollRef: ?React.ElementRef<any> = null;
1127+
_sentStartForContentLength = 0;
11171128
_sentEndForContentLength = 0;
11181129
_totalCellLength = 0;
11191130
_totalCellsMeasured = 0;
@@ -1301,7 +1312,7 @@ export default class VirtualizedList extends StateSafePureComponent<
13011312
}
13021313
this.props.onLayout && this.props.onLayout(e);
13031314
this._scheduleCellsToRenderUpdate();
1304-
this._maybeCallOnEndReached();
1315+
this._maybeCallOnEdgeReached();
13051316
};
13061317

13071318
_onLayoutEmpty = (e: LayoutEvent) => {
@@ -1410,35 +1421,86 @@ export default class VirtualizedList extends StateSafePureComponent<
14101421
return !horizontalOrDefault(this.props.horizontal) ? metrics.y : metrics.x;
14111422
}
14121423

1413-
_maybeCallOnEndReached() {
1414-
const {data, getItemCount, onEndReached, onEndReachedThreshold} =
1415-
this.props;
1424+
_maybeCallOnEdgeReached() {
1425+
const {
1426+
data,
1427+
getItemCount,
1428+
onStartReached,
1429+
onStartReachedThreshold,
1430+
onEndReached,
1431+
onEndReachedThreshold,
1432+
initialScrollIndex,
1433+
} = this.props;
14161434
const {contentLength, visibleLength, offset} = this._scrollMetrics;
1435+
let distanceFromStart = offset;
14171436
let distanceFromEnd = contentLength - visibleLength - offset;
14181437

1419-
// Especially when oERT is zero it's necessary to 'floor' very small distanceFromEnd values to be 0
1438+
// Especially when oERT is zero it's necessary to 'floor' very small distance values to be 0
14201439
// since debouncing causes us to not fire this event for every single "pixel" we scroll and can thus
1421-
// be at the "end" of the list with a distanceFromEnd approximating 0 but not quite there.
1422-
if (distanceFromEnd < ON_END_REACHED_EPSILON) {
1440+
// be at the edge of the list with a distance approximating 0 but not quite there.
1441+
if (distanceFromStart < ON_EDGE_REACHED_EPSILON) {
1442+
distanceFromStart = 0;
1443+
}
1444+
if (distanceFromEnd < ON_EDGE_REACHED_EPSILON) {
14231445
distanceFromEnd = 0;
14241446
}
14251447

1426-
// TODO: T121172172 Look into why we're "defaulting" to a threshold of 2 when oERT is not present
1427-
const threshold =
1428-
onEndReachedThreshold != null ? onEndReachedThreshold * visibleLength : 2;
1448+
// TODO: T121172172 Look into why we're "defaulting" to a threshold of 2px
1449+
// when oERT is not present (different from 2 viewports used elsewhere)
1450+
const DEFAULT_THRESHOLD_PX = 2;
1451+
1452+
const startThreshold =
1453+
onStartReachedThreshold != null
1454+
? onStartReachedThreshold * visibleLength
1455+
: DEFAULT_THRESHOLD_PX;
1456+
const endThreshold =
1457+
onEndReachedThreshold != null
1458+
? onEndReachedThreshold * visibleLength
1459+
: DEFAULT_THRESHOLD_PX;
1460+
const isWithinStartThreshold = distanceFromStart <= startThreshold;
1461+
const isWithinEndThreshold = distanceFromEnd <= endThreshold;
1462+
1463+
// First check if the user just scrolled within the end threshold
1464+
// and call onEndReached only once for a given content length,
1465+
// and only if onStartReached is not being executed
14291466
if (
14301467
onEndReached &&
14311468
this.state.cellsAroundViewport.last === getItemCount(data) - 1 &&
1432-
distanceFromEnd <= threshold &&
1469+
isWithinEndThreshold &&
14331470
this._scrollMetrics.contentLength !== this._sentEndForContentLength
14341471
) {
1435-
// Only call onEndReached once for a given content length
14361472
this._sentEndForContentLength = this._scrollMetrics.contentLength;
14371473
onEndReached({distanceFromEnd});
1438-
} else if (distanceFromEnd > threshold) {
1439-
// If the user scrolls away from the end and back again cause
1440-
// an onEndReached to be triggered again
1441-
this._sentEndForContentLength = 0;
1474+
}
1475+
1476+
// Next check if the user just scrolled within the start threshold
1477+
// and call onStartReached only once for a given content length,
1478+
// and only if onEndReached is not being executed
1479+
else if (
1480+
onStartReached != null &&
1481+
this.state.cellsAroundViewport.first === 0 &&
1482+
isWithinStartThreshold &&
1483+
this._scrollMetrics.contentLength !== this._sentStartForContentLength
1484+
) {
1485+
// On initial mount when using initialScrollIndex the offset will be 0 initially
1486+
// and will trigger an unexpected onStartReached. To avoid this we can use
1487+
// timestamp to differentiate between the initial scroll metrics and when we actually
1488+
// received the first scroll event.
1489+
if (!initialScrollIndex || this._scrollMetrics.timestamp !== 0) {
1490+
this._sentStartForContentLength = this._scrollMetrics.contentLength;
1491+
onStartReached({distanceFromStart});
1492+
}
1493+
}
1494+
1495+
// If the user scrolls away from the start or end and back again,
1496+
// cause onStartReached or onEndReached to be triggered again
1497+
else {
1498+
this._sentStartForContentLength = isWithinStartThreshold
1499+
? this._sentStartForContentLength
1500+
: 0;
1501+
this._sentEndForContentLength = isWithinEndThreshold
1502+
? this._sentEndForContentLength
1503+
: 0;
14421504
}
14431505
}
14441506

@@ -1463,7 +1525,7 @@ export default class VirtualizedList extends StateSafePureComponent<
14631525
}
14641526
this._scrollMetrics.contentLength = this._selectLength({height, width});
14651527
this._scheduleCellsToRenderUpdate();
1466-
this._maybeCallOnEndReached();
1528+
this._maybeCallOnEdgeReached();
14671529
};
14681530

14691531
/* Translates metrics from a scroll event in a parent VirtualizedList into
@@ -1551,7 +1613,7 @@ export default class VirtualizedList extends StateSafePureComponent<
15511613
if (!this.props) {
15521614
return;
15531615
}
1554-
this._maybeCallOnEndReached();
1616+
this._maybeCallOnEdgeReached();
15551617
if (velocity !== 0) {
15561618
this._fillRateHelper.activate();
15571619
}
@@ -1564,28 +1626,34 @@ export default class VirtualizedList extends StateSafePureComponent<
15641626
const {offset, visibleLength, velocity} = this._scrollMetrics;
15651627
const itemCount = this.props.getItemCount(this.props.data);
15661628
let hiPri = false;
1629+
const onStartReachedThreshold = onStartReachedThresholdOrDefault(
1630+
this.props.onStartReachedThreshold,
1631+
);
15671632
const onEndReachedThreshold = onEndReachedThresholdOrDefault(
15681633
this.props.onEndReachedThreshold,
15691634
);
1570-
const scrollingThreshold = (onEndReachedThreshold * visibleLength) / 2;
15711635
// Mark as high priority if we're close to the start of the first item
15721636
// But only if there are items before the first rendered item
15731637
if (first > 0) {
15741638
const distTop =
15751639
offset - this.__getFrameMetricsApprox(first, this.props).offset;
15761640
hiPri =
1577-
hiPri || distTop < 0 || (velocity < -2 && distTop < scrollingThreshold);
1641+
distTop < 0 ||
1642+
(velocity < -2 &&
1643+
distTop <
1644+
getScrollingThreshold(onStartReachedThreshold, visibleLength));
15781645
}
15791646
// Mark as high priority if we're close to the end of the last item
15801647
// But only if there are items after the last rendered item
1581-
if (last >= 0 && last < itemCount - 1) {
1648+
if (!hiPri && last >= 0 && last < itemCount - 1) {
15821649
const distBottom =
15831650
this.__getFrameMetricsApprox(last, this.props).offset -
15841651
(offset + visibleLength);
15851652
hiPri =
1586-
hiPri ||
15871653
distBottom < 0 ||
1588-
(velocity > 2 && distBottom < scrollingThreshold);
1654+
(velocity > 2 &&
1655+
distBottom <
1656+
getScrollingThreshold(onEndReachedThreshold, visibleLength));
15891657
}
15901658
// Only trigger high-priority updates if we've actually rendered cells,
15911659
// and with that size estimate, accurately compute how many cells we should render.

Libraries/Lists/VirtualizedListProps.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -170,16 +170,15 @@ type OptionalProps = {|
170170
*/
171171
maxToRenderPerBatch?: ?number,
172172
/**
173-
* Called once when the scroll position gets within `onEndReachedThreshold` of the rendered
174-
* content.
173+
* Called once when the scroll position gets within within `onEndReachedThreshold`
174+
* from the logical end of the list.
175175
*/
176176
onEndReached?: ?(info: {distanceFromEnd: number, ...}) => void,
177177
/**
178-
* How far from the end (in units of visible length of the list) the bottom edge of the
178+
* How far from the end (in units of visible length of the list) the trailing edge of the
179179
* list must be from the end of the content to trigger the `onEndReached` callback.
180-
* Thus a value of 0.5 will trigger `onEndReached` when the end of the content is
181-
* within half the visible length of the list. A value of 0 will not trigger until scrolling
182-
* to the very end of the list.
180+
* Thus, a value of 0.5 will trigger `onEndReached` when the end of the content is
181+
* within half the visible length of the list.
183182
*/
184183
onEndReachedThreshold?: ?number,
185184
/**
@@ -198,6 +197,18 @@ type OptionalProps = {|
198197
averageItemLength: number,
199198
...
200199
}) => void,
200+
/**
201+
* Called once when the scroll position gets within within `onStartReachedThreshold`
202+
* from the logical start of the list.
203+
*/
204+
onStartReached?: ?(info: {distanceFromStart: number, ...}) => void,
205+
/**
206+
* How far from the start (in units of visible length of the list) the leading edge of the
207+
* list must be from the start of the content to trigger the `onStartReached` callback.
208+
* Thus, a value of 0.5 will trigger `onStartReached` when the start of the content is
209+
* within half the visible length of the list.
210+
*/
211+
onStartReachedThreshold?: ?number,
201212
/**
202213
* Called when the viewability of rows changes, as defined by the
203214
* `viewabilityConfig` prop.

0 commit comments

Comments
 (0)