Skip to content

Commit

Permalink
Add onStartReached and onStartReachedThreshold to VirtualizedList (#3…
Browse files Browse the repository at this point in the history
…5321)

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
  • Loading branch information
janicduplessis authored and facebook-github-bot committed Jan 3, 2023
1 parent 79e603c commit 7683713
Show file tree
Hide file tree
Showing 8 changed files with 362 additions and 45 deletions.
13 changes: 0 additions & 13 deletions Libraries/Lists/FlatList.d.ts
Expand Up @@ -104,19 +104,6 @@ export interface FlatListProps<ItemT> extends VirtualizedListProps<ItemT> {
*/
numColumns?: number | undefined;

/**
* Called once when the scroll position gets within onEndReachedThreshold of the rendered content.
*/
onEndReached?: ((info: {distanceFromEnd: number}) => void) | null | undefined;

/**
* 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
* within half the visible length of the list.
*/
onEndReachedThreshold?: number | null | undefined;

/**
* If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality.
* Make sure to also set the refreshing prop correctly.
Expand Down
27 changes: 27 additions & 0 deletions Libraries/Lists/VirtualizedList.d.ts
Expand Up @@ -262,8 +262,18 @@ export interface VirtualizedListWithoutRenderItemProps<ItemT>
*/
maxToRenderPerBatch?: number | undefined;

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

/**
* 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.
*/
onEndReachedThreshold?: number | null | undefined;

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

/**
* Called once when the scroll position gets within within `onStartReachedThreshold`
* from the logical start of the list.
*/
onStartReached?:
| ((info: {distanceFromStart: number}) => void)
| null
| undefined;

/**
* 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 start of the content is
* within half the visible length of the list.
*/
onStartReachedThreshold?: number | null | undefined;

/**
* Called when the viewability of rows changes, as defined by the
* `viewabilityConfig` prop.
Expand Down
116 changes: 92 additions & 24 deletions Libraries/Lists/VirtualizedList.js
Expand Up @@ -50,7 +50,7 @@ import * as React from 'react';

export type {RenderItemProps, RenderItemType, Separators};

const ON_END_REACHED_EPSILON = 0.001;
const ON_EDGE_REACHED_EPSILON = 0.001;

let _usedIndexForKey = false;
let _keylessItemComponentName: string = '';
Expand Down Expand Up @@ -90,11 +90,21 @@ 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;
}

// getScrollingThreshold(visibleLength, onEndReachedThreshold)
function getScrollingThreshold(threshold: number, visibleLength: number) {
return (threshold * visibleLength) / 2;
}

// scrollEventThrottleOrDefault(this.props.scrollEventThrottle)
function scrollEventThrottleOrDefault(scrollEventThrottle: ?number) {
return scrollEventThrottle ?? 50;
Expand Down Expand Up @@ -1114,6 +1124,7 @@ export default class VirtualizedList extends StateSafePureComponent<
zoomScale: 1,
};
_scrollRef: ?React.ElementRef<any> = null;
_sentStartForContentLength = 0;
_sentEndForContentLength = 0;
_totalCellLength = 0;
_totalCellsMeasured = 0;
Expand Down Expand Up @@ -1301,7 +1312,7 @@ export default class VirtualizedList extends StateSafePureComponent<
}
this.props.onLayout && this.props.onLayout(e);
this._scheduleCellsToRenderUpdate();
this._maybeCallOnEndReached();
this._maybeCallOnEdgeReached();
};

_onLayoutEmpty = (e: LayoutEvent) => {
Expand Down Expand Up @@ -1410,35 +1421,86 @@ export default class VirtualizedList extends StateSafePureComponent<
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,
initialScrollIndex,
} = this.props;
const {contentLength, visibleLength, offset} = this._scrollMetrics;
let distanceFromStart = offset;
let distanceFromEnd = contentLength - visibleLength - offset;

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

// TODO: T121172172 Look into why we're "defaulting" to a threshold of 2 when oERT is not present
const threshold =
onEndReachedThreshold != null ? onEndReachedThreshold * visibleLength : 2;
// TODO: T121172172 Look into why we're "defaulting" to a threshold of 2px
// when oERT is not present (different from 2 viewports used elsewhere)
const DEFAULT_THRESHOLD_PX = 2;

const startThreshold =
onStartReachedThreshold != null
? onStartReachedThreshold * visibleLength
: DEFAULT_THRESHOLD_PX;
const endThreshold =
onEndReachedThreshold != null
? onEndReachedThreshold * visibleLength
: DEFAULT_THRESHOLD_PX;
const isWithinStartThreshold = distanceFromStart <= startThreshold;
const isWithinEndThreshold = distanceFromEnd <= endThreshold;

// 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.cellsAroundViewport.last === getItemCount(data) - 1 &&
distanceFromEnd <= threshold &&
isWithinEndThreshold &&
this._scrollMetrics.contentLength !== this._sentEndForContentLength
) {
// 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 != null &&
this.state.cellsAroundViewport.first === 0 &&
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});
}
}

// 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;
}
}

Expand All @@ -1463,7 +1525,7 @@ export default class VirtualizedList extends StateSafePureComponent<
}
this._scrollMetrics.contentLength = this._selectLength({height, width});
this._scheduleCellsToRenderUpdate();
this._maybeCallOnEndReached();
this._maybeCallOnEdgeReached();
};

/* Translates metrics from a scroll event in a parent VirtualizedList into
Expand Down Expand Up @@ -1551,7 +1613,7 @@ export default class VirtualizedList extends StateSafePureComponent<
if (!this.props) {
return;
}
this._maybeCallOnEndReached();
this._maybeCallOnEdgeReached();
if (velocity !== 0) {
this._fillRateHelper.activate();
}
Expand All @@ -1564,28 +1626,34 @@ export default class VirtualizedList extends StateSafePureComponent<
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, this.props).offset;
hiPri =
hiPri || distTop < 0 || (velocity < -2 && distTop < scrollingThreshold);
distTop < 0 ||
(velocity < -2 &&
distTop <
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
if (last >= 0 && last < itemCount - 1) {
if (!hiPri && last >= 0 && last < itemCount - 1) {
const distBottom =
this.__getFrameMetricsApprox(last, this.props).offset -
(offset + visibleLength);
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.
Expand Down
23 changes: 17 additions & 6 deletions Libraries/Lists/VirtualizedListProps.js
Expand Up @@ -170,16 +170,15 @@ type OptionalProps = {|
*/
maxToRenderPerBatch?: ?number,
/**
* Called once when the scroll position gets within `onEndReachedThreshold` of the rendered
* content.
* Called once when the scroll position gets within within `onEndReachedThreshold`
* from the logical end of the list.
*/
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. A value of 0 will not trigger until scrolling
* to the very end of the list.
* 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,
/**
Expand All @@ -198,6 +197,18 @@ type OptionalProps = {|
averageItemLength: number,
...
}) => void,
/**
* Called once when the scroll position gets within within `onStartReachedThreshold`
* from the logical start of the list.
*/
onStartReached?: ?(info: {distanceFromStart: number, ...}) => void,
/**
* 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 start of the content is
* within half the visible length of the list.
*/
onStartReachedThreshold?: ?number,
/**
* Called when the viewability of rows changes, as defined by the
* `viewabilityConfig` prop.
Expand Down

0 comments on commit 7683713

Please sign in to comment.