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:
Spencer Ahrens 2017-02-28 02:08:53 -08:00 committed by Facebook Github Bot
parent b00c1fa3b6
commit dc30203734
5 changed files with 245 additions and 13 deletions

View File

@ -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<*>;

View File

@ -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 => {

View File

@ -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});
}
}
}

View File

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

View File

@ -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'}],
});
}
);
});