From 76837132649d740e1ec2c3c78f0085b444a4367c Mon Sep 17 00:00:00 2001 From: Janic Duplessis Date: Tue, 3 Jan 2023 14:58:40 -0800 Subject: [PATCH] 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: https://github.com/facebook/react-native/pull/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 --- Libraries/Lists/FlatList.d.ts | 13 -- Libraries/Lists/VirtualizedList.d.ts | 27 +++ Libraries/Lists/VirtualizedList.js | 116 ++++++++++--- Libraries/Lists/VirtualizedListProps.js | 23 ++- .../Lists/__tests__/VirtualizedList-test.js | 162 ++++++++++++++++++ .../examples/FlatList/BaseFlatListExample.js | 10 +- .../FlatList/FlatList-onStartReached.js | 54 ++++++ .../examples/FlatList/FlatListExampleIndex.js | 2 + 8 files changed, 362 insertions(+), 45 deletions(-) create mode 100644 packages/rn-tester/js/examples/FlatList/FlatList-onStartReached.js diff --git a/Libraries/Lists/FlatList.d.ts b/Libraries/Lists/FlatList.d.ts index 9796869d3dbce7..344d5671359c45 100644 --- a/Libraries/Lists/FlatList.d.ts +++ b/Libraries/Lists/FlatList.d.ts @@ -104,19 +104,6 @@ export interface FlatListProps extends VirtualizedListProps { */ 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. diff --git a/Libraries/Lists/VirtualizedList.d.ts b/Libraries/Lists/VirtualizedList.d.ts index eaef385999f51d..d874dab4d10271 100644 --- a/Libraries/Lists/VirtualizedList.d.ts +++ b/Libraries/Lists/VirtualizedList.d.ts @@ -262,8 +262,18 @@ export interface VirtualizedListWithoutRenderItemProps */ 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; @@ -287,6 +297,23 @@ export interface VirtualizedListWithoutRenderItemProps }) => 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. diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index a6114ccaf0015c..75ad8f0ca46b19 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -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 = ''; @@ -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; @@ -1114,6 +1124,7 @@ export default class VirtualizedList extends StateSafePureComponent< zoomScale: 1, }; _scrollRef: ?React.ElementRef = null; + _sentStartForContentLength = 0; _sentEndForContentLength = 0; _totalCellLength = 0; _totalCellsMeasured = 0; @@ -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) => { @@ -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; } } @@ -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 @@ -1551,7 +1613,7 @@ export default class VirtualizedList extends StateSafePureComponent< if (!this.props) { return; } - this._maybeCallOnEndReached(); + this._maybeCallOnEdgeReached(); if (velocity !== 0) { this._fillRateHelper.activate(); } @@ -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. diff --git a/Libraries/Lists/VirtualizedListProps.js b/Libraries/Lists/VirtualizedListProps.js index 59e57f2bb6df01..f4d497b1d467a8 100644 --- a/Libraries/Lists/VirtualizedListProps.js +++ b/Libraries/Lists/VirtualizedListProps.js @@ -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, /** @@ -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. diff --git a/Libraries/Lists/__tests__/VirtualizedList-test.js b/Libraries/Lists/__tests__/VirtualizedList-test.js index 969d72b9014239..fa0c40b3b5d636 100644 --- a/Libraries/Lists/__tests__/VirtualizedList-test.js +++ b/Libraries/Lists/__tests__/VirtualizedList-test.js @@ -356,6 +356,168 @@ describe('VirtualizedList', () => { expect(scrollRef.measureLayout).toBeInstanceOf(jest.fn().constructor); expect(scrollRef.measureInWindow).toBeInstanceOf(jest.fn().constructor); }); + + it('calls onStartReached when near the start', () => { + const ITEM_HEIGHT = 40; + const layout = {width: 300, height: 600}; + let data = Array(40) + .fill() + .map((_, index) => ({key: `key-${index}`})); + const onStartReached = jest.fn(); + const props = { + data, + initialNumToRender: 10, + onStartReachedThreshold: 1, + windowSize: 10, + renderItem: ({item}) => , + getItem: (items, index) => items[index], + getItemCount: items => items.length, + getItemLayout: (items, index) => ({ + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + }), + onStartReached, + initialScrollIndex: data.length - 1, + }; + + const component = ReactTestRenderer.create(); + + const instance = component.getInstance(); + + instance._onLayout({nativeEvent: {layout, zoomScale: 1}}); + instance._onContentSizeChange(300, data.length * ITEM_HEIGHT); + + // Make sure onStartReached is not called initially when initialScrollIndex is set. + performAllBatches(); + expect(onStartReached).not.toHaveBeenCalled(); + + // Scroll for a small amount and make sure onStartReached is not called. + instance._onScroll({ + timeStamp: 1000, + nativeEvent: { + contentOffset: {y: (data.length - 2) * ITEM_HEIGHT, x: 0}, + layoutMeasurement: layout, + contentSize: {...layout, height: data.length * ITEM_HEIGHT}, + zoomScale: 1, + contentInset: {right: 0, top: 0, left: 0, bottom: 0}, + }, + }); + performAllBatches(); + expect(onStartReached).not.toHaveBeenCalled(); + + // Scroll to start and make sure onStartReached is called. + instance._onScroll({ + timeStamp: 1000, + nativeEvent: { + contentOffset: {y: 0, x: 0}, + layoutMeasurement: layout, + contentSize: {...layout, height: data.length * ITEM_HEIGHT}, + zoomScale: 1, + contentInset: {right: 0, top: 0, left: 0, bottom: 0}, + }, + }); + performAllBatches(); + expect(onStartReached).toHaveBeenCalled(); + }); + + it('calls onStartReached initially', () => { + const ITEM_HEIGHT = 40; + const layout = {width: 300, height: 600}; + let data = Array(40) + .fill() + .map((_, index) => ({key: `key-${index}`})); + const onStartReached = jest.fn(); + const props = { + data, + initialNumToRender: 10, + onStartReachedThreshold: 1, + windowSize: 10, + renderItem: ({item}) => , + getItem: (items, index) => items[index], + getItemCount: items => items.length, + getItemLayout: (items, index) => ({ + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + }), + onStartReached, + }; + + const component = ReactTestRenderer.create(); + + const instance = component.getInstance(); + + instance._onLayout({nativeEvent: {layout, zoomScale: 1}}); + instance._onContentSizeChange(300, data.length * ITEM_HEIGHT); + + performAllBatches(); + expect(onStartReached).toHaveBeenCalled(); + }); + + it('calls onEndReached when near the end', () => { + const ITEM_HEIGHT = 40; + const layout = {width: 300, height: 600}; + let data = Array(40) + .fill() + .map((_, index) => ({key: `key-${index}`})); + const onEndReached = jest.fn(); + const props = { + data, + initialNumToRender: 10, + onEndReachedThreshold: 1, + windowSize: 10, + renderItem: ({item}) => , + getItem: (items, index) => items[index], + getItemCount: items => items.length, + getItemLayout: (items, index) => ({ + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + }), + onEndReached, + }; + + const component = ReactTestRenderer.create(); + + const instance = component.getInstance(); + + instance._onLayout({nativeEvent: {layout, zoomScale: 1}}); + instance._onContentSizeChange(300, data.length * ITEM_HEIGHT); + + // Make sure onEndReached is not called initially. + performAllBatches(); + expect(onEndReached).not.toHaveBeenCalled(); + + // Scroll for a small amount and make sure onEndReached is not called. + instance._onScroll({ + timeStamp: 1000, + nativeEvent: { + contentOffset: {y: ITEM_HEIGHT, x: 0}, + layoutMeasurement: layout, + contentSize: {...layout, height: data.length * ITEM_HEIGHT}, + zoomScale: 1, + contentInset: {right: 0, top: 0, left: 0, bottom: 0}, + }, + }); + performAllBatches(); + expect(onEndReached).not.toHaveBeenCalled(); + + // Scroll to end and make sure onEndReached is called. + instance._onScroll({ + timeStamp: 1000, + nativeEvent: { + contentOffset: {y: data.length * ITEM_HEIGHT, x: 0}, + layoutMeasurement: layout, + contentSize: {...layout, height: data.length * ITEM_HEIGHT}, + zoomScale: 1, + contentInset: {right: 0, top: 0, left: 0, bottom: 0}, + }, + }); + performAllBatches(); + expect(onEndReached).toHaveBeenCalled(); + }); + it('does not call onEndReached when onContentSizeChange happens after onLayout', () => { const ITEM_HEIGHT = 40; const layout = {width: 300, height: 600}; diff --git a/packages/rn-tester/js/examples/FlatList/BaseFlatListExample.js b/packages/rn-tester/js/examples/FlatList/BaseFlatListExample.js index 5667dc7c5f5812..708f0d9598907d 100644 --- a/packages/rn-tester/js/examples/FlatList/BaseFlatListExample.js +++ b/packages/rn-tester/js/examples/FlatList/BaseFlatListExample.js @@ -110,11 +110,17 @@ export default (BaseFlatListExample: React.AbstractComponent< FlatList, >); +const ITEM_INNER_HEIGHT = 70; +const ITEM_MARGIN = 8; +export const ITEM_HEIGHT: number = ITEM_INNER_HEIGHT + ITEM_MARGIN * 2; + const styles = StyleSheet.create({ item: { backgroundColor: 'pink', - padding: 20, - marginVertical: 8, + paddingHorizontal: 20, + height: ITEM_INNER_HEIGHT, + marginVertical: ITEM_MARGIN, + justifyContent: 'center', }, header: { fontSize: 32, diff --git a/packages/rn-tester/js/examples/FlatList/FlatList-onStartReached.js b/packages/rn-tester/js/examples/FlatList/FlatList-onStartReached.js new file mode 100644 index 00000000000000..344337a796259b --- /dev/null +++ b/packages/rn-tester/js/examples/FlatList/FlatList-onStartReached.js @@ -0,0 +1,54 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + * @flow + */ + +'use strict'; +import type {RNTesterModuleExample} from '../../types/RNTesterTypes'; +import BaseFlatListExample, {ITEM_HEIGHT} from './BaseFlatListExample'; +import * as React from 'react'; +import {FlatList} from 'react-native'; + +export function FlatList_onStartReached(): React.Node { + const [output, setOutput] = React.useState(''); + const exampleProps = { + onStartReached: (info: {distanceFromStart: number, ...}) => + setOutput('onStartReached'), + onStartReachedThreshold: 0, + initialScrollIndex: 5, + getItemLayout: (data: any, index: number) => ({ + length: ITEM_HEIGHT, + offset: ITEM_HEIGHT * index, + index, + }), + }; + const ref = React.useRef>(null); + + const onTest = () => { + ref.current?.scrollToOffset({offset: 0}); + }; + + return ( + + ); +} + +export default ({ + title: 'onStartReached', + name: 'onStartReached', + description: + 'Scroll to start of list or tap Test button to see `onStartReached` triggered.', + render: function (): React.Element { + return ; + }, +}: RNTesterModuleExample); diff --git a/packages/rn-tester/js/examples/FlatList/FlatListExampleIndex.js b/packages/rn-tester/js/examples/FlatList/FlatListExampleIndex.js index 2fc61e2f55fbb1..bdfbb8a237ffb9 100644 --- a/packages/rn-tester/js/examples/FlatList/FlatListExampleIndex.js +++ b/packages/rn-tester/js/examples/FlatList/FlatListExampleIndex.js @@ -10,6 +10,7 @@ import type {RNTesterModule} from '../../types/RNTesterTypes'; import BasicExample from './FlatList-basic'; +import OnStartReachedExample from './FlatList-onStartReached'; import OnEndReachedExample from './FlatList-onEndReached'; import ContentInsetExample from './FlatList-contentInset'; import InvertedExample from './FlatList-inverted'; @@ -28,6 +29,7 @@ export default ({ showIndividualExamples: true, examples: [ BasicExample, + OnStartReachedExample, OnEndReachedExample, ContentInsetExample, InvertedExample,