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, renderSmallSwitchOption,
} = require('./ListExampleShared'); } = require('./ListExampleShared');
const VIEWABILITY_CONFIG = {
minimumViewTime: 3000,
viewAreaCoveragePercentThreshold: 0,
waitForInteraction: true,
};
class FlatListExample extends React.PureComponent { class FlatListExample extends React.PureComponent {
static title = '<FlatList>'; static title = '<FlatList>';
static description = 'Performant, scrollable list of data.'; static description = 'Performant, scrollable list of data.';
@ -66,6 +72,9 @@ class FlatListExample extends React.PureComponent {
_onChangeScrollToIndex = (text) => { _onChangeScrollToIndex = (text) => {
this._listRef.scrollToIndex({viewPosition: 0.5, index: Number(text)}); this._listRef.scrollToIndex({viewPosition: 0.5, index: Number(text)});
}; };
componentDidUpdate() {
this._listRef.recordInteraction(); // e.g. flipping logViewable switch
}
render() { render() {
const filterRegex = new RegExp(String(this.state.filterText), 'i'); const filterRegex = new RegExp(String(this.state.filterText), 'i');
const filter = (item) => (filterRegex.test(item.text) || filterRegex.test(item.title)); const filter = (item) => (filterRegex.test(item.text) || filterRegex.test(item.title));
@ -114,6 +123,7 @@ class FlatListExample extends React.PureComponent {
ref={this._captureRef} ref={this._captureRef}
refreshing={false} refreshing={false}
shouldItemUpdate={this._shouldItemUpdate} shouldItemUpdate={this._shouldItemUpdate}
viewabilityConfig={VIEWABILITY_CONFIG}
/> />
</UIExplorerPage> </UIExplorerPage>
); );
@ -154,6 +164,7 @@ class FlatListExample extends React.PureComponent {
} }
}; };
_pressItem = (key: number) => { _pressItem = (key: number) => {
this._listRef.recordInteraction();
pressItem(this, key); pressItem(this, key);
}; };
_listRef: FlatList<*>; _listRef: FlatList<*>;

View File

@ -197,6 +197,15 @@ class FlatList<ItemT> extends React.PureComponent<DefaultProps, Props<ItemT>, vo
this._listRef.scrollToOffset(params); 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() { componentWillMount() {
this._checkProps(this.props); this._checkProps(this.props);
} }
@ -253,8 +262,8 @@ class FlatList<ItemT> extends React.PureComponent<DefaultProps, Props<ItemT>, vo
} }
}; };
_getItemCount = (data: Array<ItemT>): number => { _getItemCount = (data?: ?Array<ItemT>): number => {
return Math.floor(data.length / this.props.numColumns); return data ? Math.ceil(data.length / this.props.numColumns) : 0;
}; };
_keyExtractor = (items: ItemT | Array<ItemT>, index: number): string => { _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 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 * 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 * viewability callback will be fired. A high number means that scrolling through content without
* stopping will not mark the content as viewable. * 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 * Percent of viewport that must be covered for a partially occluded item to count as
@ -38,27 +38,48 @@ export type ViewabilityConfig = {
itemVisiblePercentThreshold?: number, 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, 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 * An item is said to be in a "viewable" state when any of the following
* is true: * is true for longer than `minViewTime` milliseconds (after an interaction if `waitForInteraction`
* - Occupying >= viewablePercentThreshold of the viewport * is true):
*
* - Occupying >= `viewAreaCoveragePercentThreshold` of the view area XOR fraction of the item
* visible in the view area >= `itemVisiblePercentThreshold`.
* - Entirely visible on screen * - Entirely visible on screen
*/ */
class ViewabilityHelper { class ViewabilityHelper {
_config: ViewabilityConfig; _config: ViewabilityConfig;
_hasInteracted: boolean = false;
_lastUpdateTime: number = 0;
_timers: Set<number> = new Set();
_viewableIndices: Array<number> = [];
_viewableItems: Map<string, Viewable> = new Map(); _viewableItems: Map<string, Viewable> = new Map();
constructor(config: ViewabilityConfig = {viewAreaCoveragePercentThreshold: 0}) { constructor(config: ViewabilityConfig = {viewAreaCoveragePercentThreshold: 0}) {
invariant(
config.scrollInteractionFilter == null || config.waitForInteraction,
'scrollInteractionFilter only works in conjunction with waitForInteraction',
);
this._config = config; this._config = config;
} }
remove() { dispose() {
// clear all timeouts... this._timers.forEach(clearTimeout);
} }
computeViewableItems( computeViewableItems(
@ -123,6 +144,26 @@ class ViewabilityHelper {
onViewableItemsChanged: ({viewableItems: Array<Viewable>, changed: Array<Viewable>}) => void, onViewableItemsChanged: ({viewableItems: Array<Viewable>, changed: Array<Viewable>}) => void,
renderRange?: {first: number, last: number}, // Optional optimization to reduce the scan size renderRange?: {first: number, last: number}, // Optional optimization to reduce the scan size
): void { ): 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 = []; let viewableIndices = [];
if (itemCount) { if (itemCount) {
viewableIndices = this.computeViewableItems( viewableIndices = this.computeViewableItems(
@ -133,9 +174,40 @@ class ViewabilityHelper {
renderRange, 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 prevItems = this._viewableItems;
const nextItems = new Map( const nextItems = new Map(
viewableIndices.map(ii => { viewableIndicesToCheck.map(ii => {
const viewable = createViewable(ii, true); const viewable = createViewable(ii, true);
return [viewable.key, viewable]; return [viewable.key, viewable];
}) })
@ -153,8 +225,8 @@ class ViewabilityHelper {
} }
} }
if (changed.length > 0) { if (changed.length > 0) {
onViewableItemsChanged({viewableItems: Array.from(nextItems.values()), changed});
this._viewableItems = nextItems; 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 = { static defaultProps = {
disableVirtualization: false, disableVirtualization: false,
getItem: (data: any, index: number) => data[index], getItem: (data: any, index: number) => data[index],
@ -249,6 +254,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, *> {
componentWillUnmount() { componentWillUnmount() {
this._updateViewableItems(null); this._updateViewableItems(null);
this._updateCellsToRenderBatcher.dispose(); this._updateCellsToRenderBatcher.dispose();
this._viewabilityHelper.dispose();
} }
componentWillReceiveProps(newProps: Props) { componentWillReceiveProps(newProps: Props) {
@ -509,6 +515,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, *> {
const velocity = dOffset / dt; const velocity = dOffset / dt;
this._scrollMetrics = {contentLength, dt, offset, timestamp, velocity, visibleLength}; this._scrollMetrics = {contentLength, dt, offset, timestamp, velocity, visibleLength};
const {data, getItemCount, onEndReached, onEndReachedThreshold, windowSize} = this.props; const {data, getItemCount, onEndReached, onEndReachedThreshold, windowSize} = this.props;
this._updateViewableItems(data);
if (!data) { if (!data) {
return; return;
} }

View File

@ -106,6 +106,12 @@ describe('computeViewableItems', function() {
.toEqual([2]); .toEqual([2]);
expect(helper.computeViewableItems(data.length, 600, 200, getFrameMetrics)) expect(helper.computeViewableItems(data.length, 600, 200, getFrameMetrics))
.toEqual([3]); .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( it(
@ -123,11 +129,20 @@ describe('computeViewableItems', function() {
.toEqual([0]); .toEqual([0]);
expect(helper.computeViewableItems(data.length, 1, 50, getFrameMetrics)) expect(helper.computeViewableItems(data.length, 1, 50, getFrameMetrics))
.toEqual([0, 1]); .toEqual([0, 1]);
helper = new ViewabilityHelper({itemVisiblePercentThreshold: 100}); helper = new ViewabilityHelper({itemVisiblePercentThreshold: 100});
expect(helper.computeViewableItems(data.length, 0, 250, getFrameMetrics)) expect(helper.computeViewableItems(data.length, 0, 250, getFrameMetrics))
.toEqual([0, 1, 2]); .toEqual([0, 1, 2]);
expect(helper.computeViewableItems(data.length, 1, 250, getFrameMetrics)) expect(helper.computeViewableItems(data.length, 1, 250, getFrameMetrics))
.toEqual([1, 2]); .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'}],
});
}
);
}); });