mirror of
https://github.com/status-im/react-native.git
synced 2025-01-13 19:15:05 +00:00
minViewTime and waitForScroll viewability config support
Summary: It's pretty common to want to wait until the user scrolls a view to consider any items viewable, so `waitForScroll` enables that behavior. It's also pretty common to want to ignore items that are quickly scrolled out of view, so we add `minViewTime` to handle that case. Reviewed By: bvaughn Differential Revision: D4595659 fbshipit-source-id: 07bc8e89db63cb68d75bdd9bedde3183c38a3034
This commit is contained in:
parent
b00c1fa3b6
commit
dc30203734
@ -47,6 +47,12 @@ const {
|
||||
renderSmallSwitchOption,
|
||||
} = require('./ListExampleShared');
|
||||
|
||||
const VIEWABILITY_CONFIG = {
|
||||
minimumViewTime: 3000,
|
||||
viewAreaCoveragePercentThreshold: 0,
|
||||
waitForInteraction: true,
|
||||
};
|
||||
|
||||
class FlatListExample extends React.PureComponent {
|
||||
static title = '<FlatList>';
|
||||
static description = 'Performant, scrollable list of data.';
|
||||
@ -66,6 +72,9 @@ class FlatListExample extends React.PureComponent {
|
||||
_onChangeScrollToIndex = (text) => {
|
||||
this._listRef.scrollToIndex({viewPosition: 0.5, index: Number(text)});
|
||||
};
|
||||
componentDidUpdate() {
|
||||
this._listRef.recordInteraction(); // e.g. flipping logViewable switch
|
||||
}
|
||||
render() {
|
||||
const filterRegex = new RegExp(String(this.state.filterText), 'i');
|
||||
const filter = (item) => (filterRegex.test(item.text) || filterRegex.test(item.title));
|
||||
@ -114,6 +123,7 @@ class FlatListExample extends React.PureComponent {
|
||||
ref={this._captureRef}
|
||||
refreshing={false}
|
||||
shouldItemUpdate={this._shouldItemUpdate}
|
||||
viewabilityConfig={VIEWABILITY_CONFIG}
|
||||
/>
|
||||
</UIExplorerPage>
|
||||
);
|
||||
@ -154,6 +164,7 @@ class FlatListExample extends React.PureComponent {
|
||||
}
|
||||
};
|
||||
_pressItem = (key: number) => {
|
||||
this._listRef.recordInteraction();
|
||||
pressItem(this, key);
|
||||
};
|
||||
_listRef: FlatList<*>;
|
||||
|
@ -197,6 +197,15 @@ class FlatList<ItemT> extends React.PureComponent<DefaultProps, Props<ItemT>, vo
|
||||
this._listRef.scrollToOffset(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tells the list an interaction has occured, which should trigger viewability calculations, e.g.
|
||||
* if waitForInteractions is true and the user has not scrolled. This is typically called by taps
|
||||
* on items or by navigation actions.
|
||||
*/
|
||||
recordInteraction() {
|
||||
this._listRef.recordInteraction();
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this._checkProps(this.props);
|
||||
}
|
||||
@ -253,8 +262,8 @@ class FlatList<ItemT> extends React.PureComponent<DefaultProps, Props<ItemT>, vo
|
||||
}
|
||||
};
|
||||
|
||||
_getItemCount = (data: Array<ItemT>): number => {
|
||||
return Math.floor(data.length / this.props.numColumns);
|
||||
_getItemCount = (data?: ?Array<ItemT>): number => {
|
||||
return data ? Math.ceil(data.length / this.props.numColumns) : 0;
|
||||
};
|
||||
|
||||
_keyExtractor = (items: ItemT | Array<ItemT>, index: number): string => {
|
||||
|
@ -15,13 +15,13 @@ const invariant = require('invariant');
|
||||
|
||||
export type Viewable = {item: any, key: string, index: ?number, isViewable: boolean, section?: any};
|
||||
|
||||
export type ViewabilityConfig = {
|
||||
export type ViewabilityConfig = {|
|
||||
/**
|
||||
* Minimum amount of time (in milliseconds) that an item must be physically viewable before the
|
||||
* viewability callback will be fired. A high number means that scrolling through content without
|
||||
* stopping will not mark the content as viewable.
|
||||
*/
|
||||
minViewTime?: number,
|
||||
minimumViewTime?: number,
|
||||
|
||||
/**
|
||||
* Percent of viewport that must be covered for a partially occluded item to count as
|
||||
@ -38,27 +38,48 @@ export type ViewabilityConfig = {
|
||||
itemVisiblePercentThreshold?: number,
|
||||
|
||||
/**
|
||||
* Nothing is considered viewable until the user scrolls (tbd: or taps) the screen after render.
|
||||
* Nothing is considered viewable until the user scrolls or `recordInteraction` is called after
|
||||
* render.
|
||||
*/
|
||||
waitForInteraction?: boolean,
|
||||
}
|
||||
|
||||
/**
|
||||
* Criteria to filter out certain scroll events so they don't count as interactions. By default,
|
||||
* any non-zero scroll offset will be considered an interaction.
|
||||
*/
|
||||
scrollInteractionFilter?: {|
|
||||
minimumOffset?: number, // scrolls with an offset less than this are ignored.
|
||||
minimumElapsed?: number, // scrolls that happen before this are ignored.
|
||||
|},
|
||||
|};
|
||||
|
||||
/**
|
||||
* A row is said to be in a "viewable" state when either of the following
|
||||
* is true:
|
||||
* - Occupying >= viewablePercentThreshold of the viewport
|
||||
* An item is said to be in a "viewable" state when any of the following
|
||||
* is true for longer than `minViewTime` milliseconds (after an interaction if `waitForInteraction`
|
||||
* is true):
|
||||
*
|
||||
* - Occupying >= `viewAreaCoveragePercentThreshold` of the view area XOR fraction of the item
|
||||
* visible in the view area >= `itemVisiblePercentThreshold`.
|
||||
* - Entirely visible on screen
|
||||
*/
|
||||
class ViewabilityHelper {
|
||||
_config: ViewabilityConfig;
|
||||
_hasInteracted: boolean = false;
|
||||
_lastUpdateTime: number = 0;
|
||||
_timers: Set<number> = new Set();
|
||||
_viewableIndices: Array<number> = [];
|
||||
_viewableItems: Map<string, Viewable> = new Map();
|
||||
|
||||
constructor(config: ViewabilityConfig = {viewAreaCoveragePercentThreshold: 0}) {
|
||||
invariant(
|
||||
config.scrollInteractionFilter == null || config.waitForInteraction,
|
||||
'scrollInteractionFilter only works in conjunction with waitForInteraction',
|
||||
);
|
||||
this._config = config;
|
||||
}
|
||||
|
||||
remove() {
|
||||
// clear all timeouts...
|
||||
dispose() {
|
||||
this._timers.forEach(clearTimeout);
|
||||
}
|
||||
|
||||
computeViewableItems(
|
||||
@ -123,6 +144,26 @@ class ViewabilityHelper {
|
||||
onViewableItemsChanged: ({viewableItems: Array<Viewable>, changed: Array<Viewable>}) => void,
|
||||
renderRange?: {first: number, last: number}, // Optional optimization to reduce the scan size
|
||||
): void {
|
||||
const updateTime = Date.now();
|
||||
if (this._lastUpdateTime === 0 && getFrameMetrics(0)) {
|
||||
// Only count updates after the first item is rendered and has a frame.
|
||||
this._lastUpdateTime = updateTime;
|
||||
}
|
||||
const updateElapsed = this._lastUpdateTime ? updateTime - this._lastUpdateTime : 0;
|
||||
if (this._config.waitForInteraction && !this._hasInteracted && scrollOffset !== 0) {
|
||||
const filter = this._config.scrollInteractionFilter;
|
||||
if (filter) {
|
||||
if ((filter.minimumOffset == null || scrollOffset >= filter.minimumOffset) &&
|
||||
(filter.minimumElapsed == null || updateElapsed >= filter.minimumElapsed)) {
|
||||
this._hasInteracted = true;
|
||||
}
|
||||
} else {
|
||||
this._hasInteracted = true;
|
||||
}
|
||||
}
|
||||
if (this._config.waitForInteraction && !this._hasInteracted) {
|
||||
return;
|
||||
}
|
||||
let viewableIndices = [];
|
||||
if (itemCount) {
|
||||
viewableIndices = this.computeViewableItems(
|
||||
@ -133,9 +174,40 @@ class ViewabilityHelper {
|
||||
renderRange,
|
||||
);
|
||||
}
|
||||
if (this._viewableIndices.length === viewableIndices.length &&
|
||||
this._viewableIndices.every((v, ii) => v === viewableIndices[ii])) {
|
||||
// We might get a lot of scroll events where visibility doesn't change and we don't want to do
|
||||
// extra work in those cases.
|
||||
return;
|
||||
}
|
||||
this._viewableIndices = viewableIndices;
|
||||
this._lastUpdateTime = updateTime;
|
||||
if (this._config.minViewTime && updateElapsed < this._config.minViewTime) {
|
||||
const handle = setTimeout(
|
||||
() => {
|
||||
this._timers.delete(handle);
|
||||
this._onUpdateSync(viewableIndices, onViewableItemsChanged, createViewable);
|
||||
},
|
||||
this._config.minViewTime,
|
||||
);
|
||||
this._timers.add(handle);
|
||||
} else {
|
||||
this._onUpdateSync(viewableIndices, onViewableItemsChanged, createViewable);
|
||||
}
|
||||
}
|
||||
|
||||
recordInteraction() {
|
||||
this._hasInteracted = true;
|
||||
}
|
||||
|
||||
_onUpdateSync(viewableIndicesToCheck, onViewableItemsChanged, createViewable) {
|
||||
// Filter out indices that have gone out of view since this call was scheduled.
|
||||
viewableIndicesToCheck = viewableIndicesToCheck.filter(
|
||||
(ii) => this._viewableIndices.includes(ii)
|
||||
);
|
||||
const prevItems = this._viewableItems;
|
||||
const nextItems = new Map(
|
||||
viewableIndices.map(ii => {
|
||||
viewableIndicesToCheck.map(ii => {
|
||||
const viewable = createViewable(ii, true);
|
||||
return [viewable.key, viewable];
|
||||
})
|
||||
@ -153,8 +225,8 @@ class ViewabilityHelper {
|
||||
}
|
||||
}
|
||||
if (changed.length > 0) {
|
||||
onViewableItemsChanged({viewableItems: Array.from(nextItems.values()), changed});
|
||||
this._viewableItems = nextItems;
|
||||
onViewableItemsChanged({viewableItems: Array.from(nextItems.values()), changed});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -176,6 +176,11 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, *> {
|
||||
);
|
||||
}
|
||||
|
||||
recordInteraction() {
|
||||
this._viewabilityHelper.recordInteraction();
|
||||
this._updateViewableItems(this.props.data);
|
||||
}
|
||||
|
||||
static defaultProps = {
|
||||
disableVirtualization: false,
|
||||
getItem: (data: any, index: number) => data[index],
|
||||
@ -249,6 +254,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, *> {
|
||||
componentWillUnmount() {
|
||||
this._updateViewableItems(null);
|
||||
this._updateCellsToRenderBatcher.dispose();
|
||||
this._viewabilityHelper.dispose();
|
||||
}
|
||||
|
||||
componentWillReceiveProps(newProps: Props) {
|
||||
@ -509,6 +515,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, *> {
|
||||
const velocity = dOffset / dt;
|
||||
this._scrollMetrics = {contentLength, dt, offset, timestamp, velocity, visibleLength};
|
||||
const {data, getItemCount, onEndReached, onEndReachedThreshold, windowSize} = this.props;
|
||||
this._updateViewableItems(data);
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
@ -106,6 +106,12 @@ describe('computeViewableItems', function() {
|
||||
.toEqual([2]);
|
||||
expect(helper.computeViewableItems(data.length, 600, 200, getFrameMetrics))
|
||||
.toEqual([3]);
|
||||
|
||||
helper = new ViewabilityHelper({viewAreaCoveragePercentThreshold: 10});
|
||||
expect(helper.computeViewableItems(data.length, 30, 200, getFrameMetrics))
|
||||
.toEqual([0, 1, 2]);
|
||||
expect(helper.computeViewableItems(data.length, 31, 200, getFrameMetrics))
|
||||
.toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it(
|
||||
@ -123,11 +129,20 @@ describe('computeViewableItems', function() {
|
||||
.toEqual([0]);
|
||||
expect(helper.computeViewableItems(data.length, 1, 50, getFrameMetrics))
|
||||
.toEqual([0, 1]);
|
||||
|
||||
helper = new ViewabilityHelper({itemVisiblePercentThreshold: 100});
|
||||
expect(helper.computeViewableItems(data.length, 0, 250, getFrameMetrics))
|
||||
.toEqual([0, 1, 2]);
|
||||
expect(helper.computeViewableItems(data.length, 1, 250, getFrameMetrics))
|
||||
.toEqual([1, 2]);
|
||||
|
||||
helper = new ViewabilityHelper({itemVisiblePercentThreshold: 10});
|
||||
expect(helper.computeViewableItems(data.length, 184, 20, getFrameMetrics))
|
||||
.toEqual([1]);
|
||||
expect(helper.computeViewableItems(data.length, 185, 20, getFrameMetrics))
|
||||
.toEqual([1, 2]);
|
||||
expect(helper.computeViewableItems(data.length, 186, 20, getFrameMetrics))
|
||||
.toEqual([2]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -231,4 +246,122 @@ describe('onUpdate', function() {
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
'minViewTime delays callback',
|
||||
function() {
|
||||
const helper = new ViewabilityHelper({minViewTime: 350, viewAreaCoveragePercentThreshold: 0});
|
||||
rowFrames = {
|
||||
a: {y: 0, height: 200},
|
||||
b: {y: 200, height: 200},
|
||||
};
|
||||
data = [{key: 'a'}, {key: 'b'}];
|
||||
const onViewableItemsChanged = jest.fn();
|
||||
helper.onUpdate(
|
||||
data.length,
|
||||
0,
|
||||
200,
|
||||
getFrameMetrics,
|
||||
createViewable,
|
||||
onViewableItemsChanged,
|
||||
);
|
||||
expect(onViewableItemsChanged).not.toBeCalled();
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(onViewableItemsChanged.mock.calls.length).toBe(1);
|
||||
expect(onViewableItemsChanged.mock.calls[0][0]).toEqual({
|
||||
changed: [{isViewable: true, key: 'a'}],
|
||||
viewableItems: [{isViewable: true, key: 'a'}],
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'minViewTime skips briefly visible items',
|
||||
function() {
|
||||
const helper = new ViewabilityHelper({minViewTime: 350, viewAreaCoveragePercentThreshold: 0});
|
||||
rowFrames = {
|
||||
a: {y: 0, height: 250},
|
||||
b: {y: 250, height: 200},
|
||||
};
|
||||
data = [{key: 'a'}, {key: 'b'}];
|
||||
const onViewableItemsChanged = jest.fn();
|
||||
helper.onUpdate(
|
||||
data.length,
|
||||
0,
|
||||
200,
|
||||
getFrameMetrics,
|
||||
createViewable,
|
||||
onViewableItemsChanged,
|
||||
);
|
||||
helper.onUpdate(
|
||||
data.length,
|
||||
300, // scroll past item 'a'
|
||||
200,
|
||||
getFrameMetrics,
|
||||
createViewable,
|
||||
onViewableItemsChanged,
|
||||
);
|
||||
|
||||
jest.runAllTimers();
|
||||
|
||||
expect(onViewableItemsChanged.mock.calls.length).toBe(1);
|
||||
expect(onViewableItemsChanged.mock.calls[0][0]).toEqual({
|
||||
changed: [{isViewable: true, key: 'b'}],
|
||||
viewableItems: [{isViewable: true, key: 'b'}],
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it(
|
||||
'waitForInteraction blocks callback until scroll',
|
||||
function() {
|
||||
const helper = new ViewabilityHelper({
|
||||
waitForInteraction: true,
|
||||
viewAreaCoveragePercentThreshold: 0,
|
||||
scrollInteractionFilter: {
|
||||
minimumOffset: 20,
|
||||
},
|
||||
});
|
||||
rowFrames = {
|
||||
a: {y: 0, height: 200},
|
||||
b: {y: 200, height: 200},
|
||||
};
|
||||
data = [{key: 'a'}, {key: 'b'}];
|
||||
const onViewableItemsChanged = jest.fn();
|
||||
helper.onUpdate(
|
||||
data.length,
|
||||
0,
|
||||
100,
|
||||
getFrameMetrics,
|
||||
createViewable,
|
||||
onViewableItemsChanged,
|
||||
);
|
||||
expect(onViewableItemsChanged).not.toBeCalled();
|
||||
helper.onUpdate(
|
||||
data.length,
|
||||
10, // not far enough to meet minimumOffset
|
||||
100,
|
||||
getFrameMetrics,
|
||||
createViewable,
|
||||
onViewableItemsChanged,
|
||||
);
|
||||
expect(onViewableItemsChanged).not.toBeCalled();
|
||||
helper.onUpdate(
|
||||
data.length,
|
||||
20,
|
||||
100,
|
||||
getFrameMetrics,
|
||||
createViewable,
|
||||
onViewableItemsChanged,
|
||||
);
|
||||
expect(onViewableItemsChanged.mock.calls.length).toBe(1);
|
||||
expect(onViewableItemsChanged.mock.calls[0][0]).toEqual({
|
||||
changed: [{isViewable: true, key: 'a'}],
|
||||
viewableItems: [{isViewable: true, key: 'a'}],
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
});
|
||||
|
Loading…
x
Reference in New Issue
Block a user