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
This commit is contained in:
Родитель
79e603c5ab
Коммит
7683713264
|
@ -104,19 +104,6 @@ export interface FlatListProps<ItemT> extends VirtualizedListProps<ItemT> {
|
||||||
*/
|
*/
|
||||||
numColumns?: number | undefined;
|
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.
|
* If provided, a standard RefreshControl will be added for "Pull to Refresh" functionality.
|
||||||
* Make sure to also set the refreshing prop correctly.
|
* Make sure to also set the refreshing prop correctly.
|
||||||
|
|
|
@ -262,8 +262,18 @@ export interface VirtualizedListWithoutRenderItemProps<ItemT>
|
||||||
*/
|
*/
|
||||||
maxToRenderPerBatch?: number | undefined;
|
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;
|
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;
|
onEndReachedThreshold?: number | null | undefined;
|
||||||
|
|
||||||
onLayout?: ((event: LayoutChangeEvent) => void) | undefined;
|
onLayout?: ((event: LayoutChangeEvent) => void) | undefined;
|
||||||
|
@ -287,6 +297,23 @@ export interface VirtualizedListWithoutRenderItemProps<ItemT>
|
||||||
}) => void)
|
}) => void)
|
||||||
| undefined;
|
| 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
|
* Called when the viewability of rows changes, as defined by the
|
||||||
* `viewabilityConfig` prop.
|
* `viewabilityConfig` prop.
|
||||||
|
|
|
@ -50,7 +50,7 @@ import * as React from 'react';
|
||||||
|
|
||||||
export type {RenderItemProps, RenderItemType, Separators};
|
export type {RenderItemProps, RenderItemType, Separators};
|
||||||
|
|
||||||
const ON_END_REACHED_EPSILON = 0.001;
|
const ON_EDGE_REACHED_EPSILON = 0.001;
|
||||||
|
|
||||||
let _usedIndexForKey = false;
|
let _usedIndexForKey = false;
|
||||||
let _keylessItemComponentName: string = '';
|
let _keylessItemComponentName: string = '';
|
||||||
|
@ -90,11 +90,21 @@ function maxToRenderPerBatchOrDefault(maxToRenderPerBatch: ?number) {
|
||||||
return maxToRenderPerBatch ?? 10;
|
return maxToRenderPerBatch ?? 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// onStartReachedThresholdOrDefault(this.props.onStartReachedThreshold)
|
||||||
|
function onStartReachedThresholdOrDefault(onStartReachedThreshold: ?number) {
|
||||||
|
return onStartReachedThreshold ?? 2;
|
||||||
|
}
|
||||||
|
|
||||||
// onEndReachedThresholdOrDefault(this.props.onEndReachedThreshold)
|
// onEndReachedThresholdOrDefault(this.props.onEndReachedThreshold)
|
||||||
function onEndReachedThresholdOrDefault(onEndReachedThreshold: ?number) {
|
function onEndReachedThresholdOrDefault(onEndReachedThreshold: ?number) {
|
||||||
return onEndReachedThreshold ?? 2;
|
return onEndReachedThreshold ?? 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getScrollingThreshold(visibleLength, onEndReachedThreshold)
|
||||||
|
function getScrollingThreshold(threshold: number, visibleLength: number) {
|
||||||
|
return (threshold * visibleLength) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
// scrollEventThrottleOrDefault(this.props.scrollEventThrottle)
|
// scrollEventThrottleOrDefault(this.props.scrollEventThrottle)
|
||||||
function scrollEventThrottleOrDefault(scrollEventThrottle: ?number) {
|
function scrollEventThrottleOrDefault(scrollEventThrottle: ?number) {
|
||||||
return scrollEventThrottle ?? 50;
|
return scrollEventThrottle ?? 50;
|
||||||
|
@ -1114,6 +1124,7 @@ export default class VirtualizedList extends StateSafePureComponent<
|
||||||
zoomScale: 1,
|
zoomScale: 1,
|
||||||
};
|
};
|
||||||
_scrollRef: ?React.ElementRef<any> = null;
|
_scrollRef: ?React.ElementRef<any> = null;
|
||||||
|
_sentStartForContentLength = 0;
|
||||||
_sentEndForContentLength = 0;
|
_sentEndForContentLength = 0;
|
||||||
_totalCellLength = 0;
|
_totalCellLength = 0;
|
||||||
_totalCellsMeasured = 0;
|
_totalCellsMeasured = 0;
|
||||||
|
@ -1301,7 +1312,7 @@ export default class VirtualizedList extends StateSafePureComponent<
|
||||||
}
|
}
|
||||||
this.props.onLayout && this.props.onLayout(e);
|
this.props.onLayout && this.props.onLayout(e);
|
||||||
this._scheduleCellsToRenderUpdate();
|
this._scheduleCellsToRenderUpdate();
|
||||||
this._maybeCallOnEndReached();
|
this._maybeCallOnEdgeReached();
|
||||||
};
|
};
|
||||||
|
|
||||||
_onLayoutEmpty = (e: LayoutEvent) => {
|
_onLayoutEmpty = (e: LayoutEvent) => {
|
||||||
|
@ -1410,35 +1421,86 @@ export default class VirtualizedList extends StateSafePureComponent<
|
||||||
return !horizontalOrDefault(this.props.horizontal) ? metrics.y : metrics.x;
|
return !horizontalOrDefault(this.props.horizontal) ? metrics.y : metrics.x;
|
||||||
}
|
}
|
||||||
|
|
||||||
_maybeCallOnEndReached() {
|
_maybeCallOnEdgeReached() {
|
||||||
const {data, getItemCount, onEndReached, onEndReachedThreshold} =
|
const {
|
||||||
this.props;
|
data,
|
||||||
|
getItemCount,
|
||||||
|
onStartReached,
|
||||||
|
onStartReachedThreshold,
|
||||||
|
onEndReached,
|
||||||
|
onEndReachedThreshold,
|
||||||
|
initialScrollIndex,
|
||||||
|
} = this.props;
|
||||||
const {contentLength, visibleLength, offset} = this._scrollMetrics;
|
const {contentLength, visibleLength, offset} = this._scrollMetrics;
|
||||||
|
let distanceFromStart = offset;
|
||||||
let distanceFromEnd = contentLength - visibleLength - 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
|
// 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.
|
// be at the edge of the list with a distance approximating 0 but not quite there.
|
||||||
if (distanceFromEnd < ON_END_REACHED_EPSILON) {
|
if (distanceFromStart < ON_EDGE_REACHED_EPSILON) {
|
||||||
|
distanceFromStart = 0;
|
||||||
|
}
|
||||||
|
if (distanceFromEnd < ON_EDGE_REACHED_EPSILON) {
|
||||||
distanceFromEnd = 0;
|
distanceFromEnd = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: T121172172 Look into why we're "defaulting" to a threshold of 2 when oERT is not present
|
// TODO: T121172172 Look into why we're "defaulting" to a threshold of 2px
|
||||||
const threshold =
|
// when oERT is not present (different from 2 viewports used elsewhere)
|
||||||
onEndReachedThreshold != null ? onEndReachedThreshold * visibleLength : 2;
|
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 (
|
if (
|
||||||
onEndReached &&
|
onEndReached &&
|
||||||
this.state.cellsAroundViewport.last === getItemCount(data) - 1 &&
|
this.state.cellsAroundViewport.last === getItemCount(data) - 1 &&
|
||||||
distanceFromEnd <= threshold &&
|
isWithinEndThreshold &&
|
||||||
this._scrollMetrics.contentLength !== this._sentEndForContentLength
|
this._scrollMetrics.contentLength !== this._sentEndForContentLength
|
||||||
) {
|
) {
|
||||||
// Only call onEndReached once for a given content length
|
|
||||||
this._sentEndForContentLength = this._scrollMetrics.contentLength;
|
this._sentEndForContentLength = this._scrollMetrics.contentLength;
|
||||||
onEndReached({distanceFromEnd});
|
onEndReached({distanceFromEnd});
|
||||||
} else if (distanceFromEnd > threshold) {
|
}
|
||||||
// If the user scrolls away from the end and back again cause
|
|
||||||
// an onEndReached to be triggered again
|
// Next check if the user just scrolled within the start threshold
|
||||||
this._sentEndForContentLength = 0;
|
// 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._scrollMetrics.contentLength = this._selectLength({height, width});
|
||||||
this._scheduleCellsToRenderUpdate();
|
this._scheduleCellsToRenderUpdate();
|
||||||
this._maybeCallOnEndReached();
|
this._maybeCallOnEdgeReached();
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Translates metrics from a scroll event in a parent VirtualizedList into
|
/* Translates metrics from a scroll event in a parent VirtualizedList into
|
||||||
|
@ -1551,7 +1613,7 @@ export default class VirtualizedList extends StateSafePureComponent<
|
||||||
if (!this.props) {
|
if (!this.props) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._maybeCallOnEndReached();
|
this._maybeCallOnEdgeReached();
|
||||||
if (velocity !== 0) {
|
if (velocity !== 0) {
|
||||||
this._fillRateHelper.activate();
|
this._fillRateHelper.activate();
|
||||||
}
|
}
|
||||||
|
@ -1564,28 +1626,34 @@ export default class VirtualizedList extends StateSafePureComponent<
|
||||||
const {offset, visibleLength, velocity} = this._scrollMetrics;
|
const {offset, visibleLength, velocity} = this._scrollMetrics;
|
||||||
const itemCount = this.props.getItemCount(this.props.data);
|
const itemCount = this.props.getItemCount(this.props.data);
|
||||||
let hiPri = false;
|
let hiPri = false;
|
||||||
|
const onStartReachedThreshold = onStartReachedThresholdOrDefault(
|
||||||
|
this.props.onStartReachedThreshold,
|
||||||
|
);
|
||||||
const onEndReachedThreshold = onEndReachedThresholdOrDefault(
|
const onEndReachedThreshold = onEndReachedThresholdOrDefault(
|
||||||
this.props.onEndReachedThreshold,
|
this.props.onEndReachedThreshold,
|
||||||
);
|
);
|
||||||
const scrollingThreshold = (onEndReachedThreshold * visibleLength) / 2;
|
|
||||||
// Mark as high priority if we're close to the start of the first item
|
// 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
|
// But only if there are items before the first rendered item
|
||||||
if (first > 0) {
|
if (first > 0) {
|
||||||
const distTop =
|
const distTop =
|
||||||
offset - this.__getFrameMetricsApprox(first, this.props).offset;
|
offset - this.__getFrameMetricsApprox(first, this.props).offset;
|
||||||
hiPri =
|
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
|
// 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
|
// 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 =
|
const distBottom =
|
||||||
this.__getFrameMetricsApprox(last, this.props).offset -
|
this.__getFrameMetricsApprox(last, this.props).offset -
|
||||||
(offset + visibleLength);
|
(offset + visibleLength);
|
||||||
hiPri =
|
hiPri =
|
||||||
hiPri ||
|
|
||||||
distBottom < 0 ||
|
distBottom < 0 ||
|
||||||
(velocity > 2 && distBottom < scrollingThreshold);
|
(velocity > 2 &&
|
||||||
|
distBottom <
|
||||||
|
getScrollingThreshold(onEndReachedThreshold, visibleLength));
|
||||||
}
|
}
|
||||||
// Only trigger high-priority updates if we've actually rendered cells,
|
// Only trigger high-priority updates if we've actually rendered cells,
|
||||||
// and with that size estimate, accurately compute how many cells we should render.
|
// and with that size estimate, accurately compute how many cells we should render.
|
||||||
|
|
|
@ -170,16 +170,15 @@ type OptionalProps = {|
|
||||||
*/
|
*/
|
||||||
maxToRenderPerBatch?: ?number,
|
maxToRenderPerBatch?: ?number,
|
||||||
/**
|
/**
|
||||||
* Called once when the scroll position gets within `onEndReachedThreshold` of the rendered
|
* Called once when the scroll position gets within within `onEndReachedThreshold`
|
||||||
* content.
|
* from the logical end of the list.
|
||||||
*/
|
*/
|
||||||
onEndReached?: ?(info: {distanceFromEnd: number, ...}) => void,
|
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.
|
* 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
|
* 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
|
* within half the visible length of the list.
|
||||||
* to the very end of the list.
|
|
||||||
*/
|
*/
|
||||||
onEndReachedThreshold?: ?number,
|
onEndReachedThreshold?: ?number,
|
||||||
/**
|
/**
|
||||||
|
@ -198,6 +197,18 @@ type OptionalProps = {|
|
||||||
averageItemLength: number,
|
averageItemLength: number,
|
||||||
...
|
...
|
||||||
}) => void,
|
}) => 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
|
* Called when the viewability of rows changes, as defined by the
|
||||||
* `viewabilityConfig` prop.
|
* `viewabilityConfig` prop.
|
||||||
|
|
|
@ -356,6 +356,168 @@ describe('VirtualizedList', () => {
|
||||||
expect(scrollRef.measureLayout).toBeInstanceOf(jest.fn().constructor);
|
expect(scrollRef.measureLayout).toBeInstanceOf(jest.fn().constructor);
|
||||||
expect(scrollRef.measureInWindow).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}) => <item value={item.key} />,
|
||||||
|
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(<VirtualizedList {...props} />);
|
||||||
|
|
||||||
|
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}) => <item value={item.key} />,
|
||||||
|
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(<VirtualizedList {...props} />);
|
||||||
|
|
||||||
|
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}) => <item value={item.key} />,
|
||||||
|
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(<VirtualizedList {...props} />);
|
||||||
|
|
||||||
|
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', () => {
|
it('does not call onEndReached when onContentSizeChange happens after onLayout', () => {
|
||||||
const ITEM_HEIGHT = 40;
|
const ITEM_HEIGHT = 40;
|
||||||
const layout = {width: 300, height: 600};
|
const layout = {width: 300, height: 600};
|
||||||
|
|
|
@ -110,11 +110,17 @@ export default (BaseFlatListExample: React.AbstractComponent<
|
||||||
FlatList<string>,
|
FlatList<string>,
|
||||||
>);
|
>);
|
||||||
|
|
||||||
|
const ITEM_INNER_HEIGHT = 70;
|
||||||
|
const ITEM_MARGIN = 8;
|
||||||
|
export const ITEM_HEIGHT: number = ITEM_INNER_HEIGHT + ITEM_MARGIN * 2;
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
item: {
|
item: {
|
||||||
backgroundColor: 'pink',
|
backgroundColor: 'pink',
|
||||||
padding: 20,
|
paddingHorizontal: 20,
|
||||||
marginVertical: 8,
|
height: ITEM_INNER_HEIGHT,
|
||||||
|
marginVertical: ITEM_MARGIN,
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
|
|
|
@ -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<?FlatList<string>>(null);
|
||||||
|
|
||||||
|
const onTest = () => {
|
||||||
|
ref.current?.scrollToOffset({offset: 0});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BaseFlatListExample
|
||||||
|
ref={ref}
|
||||||
|
exampleProps={exampleProps}
|
||||||
|
testOutput={output}
|
||||||
|
onTest={onTest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ({
|
||||||
|
title: 'onStartReached',
|
||||||
|
name: 'onStartReached',
|
||||||
|
description:
|
||||||
|
'Scroll to start of list or tap Test button to see `onStartReached` triggered.',
|
||||||
|
render: function (): React.Element<typeof FlatList_onStartReached> {
|
||||||
|
return <FlatList_onStartReached />;
|
||||||
|
},
|
||||||
|
}: RNTesterModuleExample);
|
|
@ -10,6 +10,7 @@
|
||||||
|
|
||||||
import type {RNTesterModule} from '../../types/RNTesterTypes';
|
import type {RNTesterModule} from '../../types/RNTesterTypes';
|
||||||
import BasicExample from './FlatList-basic';
|
import BasicExample from './FlatList-basic';
|
||||||
|
import OnStartReachedExample from './FlatList-onStartReached';
|
||||||
import OnEndReachedExample from './FlatList-onEndReached';
|
import OnEndReachedExample from './FlatList-onEndReached';
|
||||||
import ContentInsetExample from './FlatList-contentInset';
|
import ContentInsetExample from './FlatList-contentInset';
|
||||||
import InvertedExample from './FlatList-inverted';
|
import InvertedExample from './FlatList-inverted';
|
||||||
|
@ -28,6 +29,7 @@ export default ({
|
||||||
showIndividualExamples: true,
|
showIndividualExamples: true,
|
||||||
examples: [
|
examples: [
|
||||||
BasicExample,
|
BasicExample,
|
||||||
|
OnStartReachedExample,
|
||||||
OnEndReachedExample,
|
OnEndReachedExample,
|
||||||
ContentInsetExample,
|
ContentInsetExample,
|
||||||
InvertedExample,
|
InvertedExample,
|
||||||
|
|
Загрузка…
Ссылка в новой задаче