configurable Viewability

Reviewed By: yungsters

Differential Revision: D4577395

fbshipit-source-id: 9b9099f5bd5f8fe20b5c24eab7e43f298ba665d9
This commit is contained in:
Spencer Ahrens
2017-02-21 17:05:25 -08:00
committed by Facebook Github Bot
parent fa34035def
commit f2687bf4b6
5 changed files with 305 additions and 60 deletions

View File

@@ -40,7 +40,7 @@ const VirtualizedList = require('VirtualizedList');
const invariant = require('invariant');
import type {StyleObj} from 'StyleSheetTypes';
import type {Viewable} from 'ViewabilityHelper';
import type {ViewabilityConfig, Viewable} from 'ViewabilityHelper';
import type {Props as VirtualizedListProps} from 'VirtualizedList';
type Item = any;
@@ -128,6 +128,10 @@ type OptionalProps<ItemT> = {
prevProps: {item: ItemT, index: number},
nextProps: {item: ItemT, index: number}
) => boolean,
/**
* See ViewabilityHelper for flow type and comments.
*/
viewabilityConfig?: ViewabilityConfig,
};
type Props<ItemT> = RequiredProps<ItemT> & OptionalProps<ItemT> & VirtualizedListProps;

View File

@@ -15,21 +15,69 @@ const invariant = require('invariant');
export type Viewable = {item: any, key: string, index: ?number, isViewable: boolean, section?: any};
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,
/**
* Percent of viewport that must be covered for a partially occluded item to count as
* "viewable", 0-100. Fully visible items are always considered viewable. A value of 0 means
* that a single pixel in the viewport makes the item viewable, and a value of 100 means that
* an item must be either entirely visible or cover the entire viewport to count as viewable.
*/
viewAreaCoveragePercentThreshold?: number,
/**
* Similar to `viewAreaPercentThreshold`, but considers the percent of the item that is visible,
* rather than the fraction of the viewable area it covers.
*/
itemVisiblePercentThreashold?: number,
/**
* Nothing is considered viewable until the user scrolls (tbd: or taps) the screen after render.
*/
waitForInteraction?: boolean,
}
/**
* A row is said to be in a "viewable" state when either of the following
* is true:
* - Occupying >= viewablePercentThreshold of the viewport
* - Entirely visible on screen
*/
const ViewabilityHelper = {
class ViewabilityHelper {
_config: ViewabilityConfig;
_viewableItems: Map<string, Viewable> = new Map();
constructor(config: ViewabilityConfig = {viewAreaCoveragePercentThreshold: 0}) {
this._config = config;
}
remove() {
// clear all timeouts...
}
computeViewableItems(
viewablePercentThreshold: number,
itemCount: number,
scrollOffset: number,
viewportHeight: number,
getFrameMetrics: (index: number) => ?{length: number, offset: number},
renderRange?: {first: number, last: number}, // Optional optimization to reduce the scan size
): Array<number> {
const {itemVisiblePercentThreashold, viewAreaCoveragePercentThreshold} = this._config;
const viewAreaMode = viewAreaCoveragePercentThreshold != null;
const viewablePercentThreshold = viewAreaMode ?
viewAreaCoveragePercentThreshold :
itemVisiblePercentThreashold;
invariant(
viewablePercentThreshold != null &&
(itemVisiblePercentThreashold != null) !== (viewAreaCoveragePercentThreshold != null),
'Must set exactly one of itemVisiblePercentThreashold or viewAreaCoveragePercentThreshold',
);
const viewableIndices = [];
if (itemCount === 0) {
return viewableIndices;
@@ -50,10 +98,12 @@ const ViewabilityHelper = {
if ((top < viewportHeight) && (bottom > 0)) {
firstVisible = idx;
if (_isViewable(
viewAreaMode,
viewablePercentThreshold,
top,
bottom,
viewportHeight
viewportHeight,
metrics.length,
)) {
viewableIndices.push(idx);
}
@@ -62,29 +112,78 @@ const ViewabilityHelper = {
}
}
return viewableIndices;
},
};
}
onUpdate(
itemCount: number,
scrollOffset: number,
viewportHeight: number,
getFrameMetrics: (index: number) => ?{length: number, offset: number},
createViewable: (index: number, isViewable: boolean) => Viewable,
onViewableItemsChanged: ({viewableItems: Array<Viewable>, changed: Array<Viewable>}) => void,
renderRange?: {first: number, last: number}, // Optional optimization to reduce the scan size
): void {
let viewableIndices = [];
if (itemCount) {
viewableIndices = this.computeViewableItems(
itemCount,
scrollOffset,
viewportHeight,
getFrameMetrics,
renderRange,
);
}
const prevItems = this._viewableItems;
const nextItems = new Map(
viewableIndices.map(ii => {
const viewable = createViewable(ii, true);
return [viewable.key, viewable];
})
);
const changed = [];
for (const [key, viewable] of nextItems) {
if (!prevItems.has(key)) {
changed.push(viewable);
}
}
for (const [key, viewable] of prevItems) {
if (!nextItems.has(key)) {
changed.push({...viewable, isViewable: false});
}
}
if (changed.length > 0) {
onViewableItemsChanged({viewableItems: Array.from(nextItems.values()), changed});
this._viewableItems = nextItems;
}
}
}
function _isViewable(
viewAreaMode: boolean,
viewablePercentThreshold: number,
top: number,
bottom: number,
viewportHeight: number
viewportHeight: number,
itemLength: number,
): bool {
return _isEntirelyVisible(top, bottom, viewportHeight) ||
_getPercentOccupied(top, bottom, viewportHeight) >=
viewablePercentThreshold;
if (_isEntirelyVisible(top, bottom, viewportHeight)) {
return true;
} else {
const pixels = _getPixelsVisible(top, bottom, viewportHeight);
const percent = 100 * (viewAreaMode ? pixels / viewportHeight : pixels / itemLength);
return percent >= viewablePercentThreshold;
}
}
function _getPercentOccupied(
function _getPixelsVisible(
top: number,
bottom: number,
viewportHeight: number
): number {
let visibleHeight = Math.min(bottom, viewportHeight) - Math.max(top, 0);
visibleHeight = Math.max(0, visibleHeight);
return Math.max(0, visibleHeight * 100 / viewportHeight);
const visibleHeight = Math.min(bottom, viewportHeight) - Math.max(top, 0);
return Math.max(0, visibleHeight);
}
function _isEntirelyVisible(

View File

@@ -44,7 +44,7 @@ const invariant = require('fbjs/lib/invariant');
const {computeWindowedRenderLimits} = require('VirtualizeUtils');
import type {Viewable} from 'ViewabilityHelper';
import type {ViewabilityConfig, Viewable} from 'ViewabilityHelper';
type Item = any;
type ItemComponentType = ReactClass<{item: Item, index: number}>;
@@ -117,13 +117,7 @@ type OptionalProps = {
nextProps: {item: Item, index: number}
) => boolean,
updateCellsBatchingPeriod: number,
/**
* Percent of viewport that must be covered for a partially occluded item to count as
* "viewable", 0-100. Fully visible items are always considered viewable. A value of 0 means
* that a single pixel in the viewport makes the item viewable, and a value of 100 means that
* an item must be either entirely visible or cover the entire viewport to count as viewable.
*/
viewablePercentThreshold: number,
viewabilityConfig?: ViewabilityConfig,
windowSize: number, // units of visible length
};
export type Props = RequiredProps & OptionalProps;
@@ -245,6 +239,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, *> {
this._updateCellsToRender,
this.props.updateCellsBatchingPeriod,
);
this._viewabilityHelper = new ViewabilityHelper(this.props.viewabilityConfig);
this.state = {
first: 0,
last: Math.min(this.props.getItemCount(this.props.data), this.props.initialNumToRender) - 1,
@@ -381,8 +376,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, *> {
_totalCellLength = 0;
_totalCellsMeasured = 0;
_updateCellsToRenderBatcher: Batchinator;
_viewableKeys: {[key: string]: boolean} = {};
_viewableItems: Array<Viewable> = [];
_viewabilityHelper: ViewabilityHelper;
_captureScrollRef = (ref) => {
this._scrollRef = ref;
@@ -571,12 +565,12 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, *> {
});
};
_createViewable(index: number, isViewable: boolean): Viewable {
_createViewable = (index: number, isViewable: boolean): Viewable => {
const {data, getItem, keyExtractor} = this.props;
const item = getItem(data, index);
invariant(item, 'Missing item for index ' + index);
return {index, item, key: keyExtractor(item, index), isViewable};
}
};
_getFrameMetricsApprox = (index: number): {length: number, offset: number} => {
const frame = this._getFrameMetrics(index);
@@ -609,37 +603,19 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, *> {
};
_updateViewableItems(data: any) {
const {getItemCount, onViewableItemsChanged, viewablePercentThreshold} = this.props;
const {getItemCount, onViewableItemsChanged} = this.props;
if (!onViewableItemsChanged) {
return;
}
let viewableIndices = [];
if (data) {
viewableIndices = ViewabilityHelper.computeViewableItems(
viewablePercentThreshold,
getItemCount(data),
this._scrollMetrics.offset,
this._scrollMetrics.visibleLength,
this._getFrameMetrics,
this.state,
);
}
const viewableKeys = {};
const viewableItems = viewableIndices.map((ii) => {
const viewable = this._createViewable(ii, true);
viewableKeys[viewable.key] = true;
return viewable;
});
const changed = viewableItems.filter(v => !this._viewableKeys[v.key])
.concat(
this._viewableItems.filter(v => !viewableKeys[v.key])
.map(v => ({...v, isViewable: false}))
);
if (changed.length > 0) {
onViewableItemsChanged({viewableItems, changed});
this._viewableItems = viewableItems;
this._viewableKeys = viewableKeys;
}
this._viewabilityHelper.onUpdate(
getItemCount(data),
this._scrollMetrics.offset,
this._scrollMetrics.visibleLength,
this._getFrameMetrics,
this._createViewable,
onViewableItemsChanged,
this.state,
);
}
}

View File

@@ -186,6 +186,7 @@ class WindowedListView extends React.Component {
_viewableRows: Array<number> = [];
_cellsInProgress: Set<string> = new Set();
_scrollRef: ?ScrollView;
_viewabilityHelper: ViewabilityHelper;
static defaultProps = {
initialNumToRender: 10,
@@ -207,6 +208,9 @@ class WindowedListView extends React.Component {
() => this._computeRowsToRender(this.props),
this.props.recomputeRowsBatchingPeriod,
);
this._viewabilityHelper = new ViewabilityHelper({
viewAreaCoveragePercentThreshold: this.props.viewablePercentThreshold,
});
this.state = {
firstRow: 0,
lastRow: Math.min(this.props.data.length, this.props.initialNumToRender) - 1,
@@ -272,8 +276,7 @@ class WindowedListView extends React.Component {
this._computeRowsToRenderBatcher.schedule();
}
if (this.props.onViewableRowsChanged && Object.keys(this._rowFrames).length) {
const viewableRows = ViewabilityHelper.computeViewableItems(
this.props.viewablePercentThreshold,
const viewableRows = this._viewabilityHelper.computeViewableItems(
this.props.data.length,
e.nativeEvent.contentOffset.y,
e.nativeEvent.layoutMeasurement.height,

View File

@@ -18,9 +18,13 @@ function getFrameMetrics(index: number) {
const frame = rowFrames[data[index].key];
return {length: frame.height, offset: frame.y};
}
function createViewable(index: number, isViewable: boolean) {
return {key: data[index].key, isViewable};
}
describe('computeViewableItems', function() {
it('returns all 4 entirely visible rows as viewable', function() {
const helper = new ViewabilityHelper({viewAreaCoveragePercentThreshold: 50});
rowFrames = {
a: {y: 0, height: 50},
b: {y: 50, height: 50},
@@ -28,13 +32,14 @@ describe('computeViewableItems', function() {
d: {y: 150, height: 50},
};
data = [{key: 'a'}, {key: 'b'}, {key: 'c'}, {key: 'd'}];
expect(ViewabilityHelper.computeViewableItems(50, data.length, 0, 200, getFrameMetrics))
expect(helper.computeViewableItems(data.length, 0, 200, getFrameMetrics))
.toEqual([0, 1, 2, 3]);
});
it(
'returns top 2 rows as viewable (1. entirely visible and 2. majority)',
function() {
const helper = new ViewabilityHelper({viewAreaCoveragePercentThreshold: 50});
rowFrames = {
a: {y: 0, height: 50},
b: {y: 50, height: 150},
@@ -42,13 +47,14 @@ describe('computeViewableItems', function() {
d: {y: 250, height: 50},
};
data = [{key: 'a'}, {key: 'b'}, {key: 'c'}, {key: 'd'}];
expect(ViewabilityHelper.computeViewableItems(50, data.length, 0, 200, getFrameMetrics))
expect(helper.computeViewableItems(data.length, 0, 200, getFrameMetrics))
.toEqual([0, 1]);
});
it(
'returns only 2nd row as viewable (majority)',
function() {
const helper = new ViewabilityHelper({viewAreaCoveragePercentThreshold: 50});
rowFrames = {
a: {y: 0, height: 50},
b: {y: 50, height: 150},
@@ -56,16 +62,173 @@ describe('computeViewableItems', function() {
d: {y: 250, height: 50},
};
data = [{key: 'a'}, {key: 'b'}, {key: 'c'}, {key: 'd'}];
expect(ViewabilityHelper.computeViewableItems(50, data.length, 25, 200, getFrameMetrics))
expect(helper.computeViewableItems(data.length, 25, 200, getFrameMetrics))
.toEqual([1]);
});
it(
'handles empty input',
function() {
const helper = new ViewabilityHelper({viewAreaCoveragePercentThreshold: 50});
rowFrames = {};
data = [];
expect(ViewabilityHelper.computeViewableItems(50, data.length, 0, 200, getFrameMetrics))
expect(helper.computeViewableItems(data.length, 0, 200, getFrameMetrics))
.toEqual([]);
});
it(
'handles different view area coverage percent thresholds',
function() {
rowFrames = {
a: {y: 0, height: 50},
b: {y: 50, height: 150},
c: {y: 200, height: 500},
d: {y: 700, height: 50},
};
data = [{key: 'a'}, {key: 'b'}, {key: 'c'}, {key: 'd'}];
let helper = new ViewabilityHelper({viewAreaCoveragePercentThreshold: 0});
expect(helper.computeViewableItems(data.length, 0, 50, getFrameMetrics))
.toEqual([0]);
expect(helper.computeViewableItems(data.length, 1, 50, getFrameMetrics))
.toEqual([0, 1]);
expect(helper.computeViewableItems(data.length, 199, 50, getFrameMetrics))
.toEqual([1, 2]);
expect(helper.computeViewableItems(data.length, 250, 50, getFrameMetrics))
.toEqual([2]);
helper = new ViewabilityHelper({viewAreaCoveragePercentThreshold: 100});
expect(helper.computeViewableItems(data.length, 0, 200, getFrameMetrics))
.toEqual([0, 1]);
expect(helper.computeViewableItems(data.length, 1, 200, getFrameMetrics))
.toEqual([1]);
expect(helper.computeViewableItems(data.length, 400, 200, getFrameMetrics))
.toEqual([2]);
expect(helper.computeViewableItems(data.length, 600, 200, getFrameMetrics))
.toEqual([3]);
});
it(
'handles different item visible percent thresholds',
function() {
rowFrames = {
a: {y: 0, height: 50},
b: {y: 50, height: 150},
c: {y: 200, height: 50},
d: {y: 250, height: 50},
};
data = [{key: 'a'}, {key: 'b'}, {key: 'c'}, {key: 'd'}];
let helper = new ViewabilityHelper({itemVisiblePercentThreashold: 0});
expect(helper.computeViewableItems(data.length, 0, 50, getFrameMetrics))
.toEqual([0]);
expect(helper.computeViewableItems(data.length, 1, 50, getFrameMetrics))
.toEqual([0, 1]);
helper = new ViewabilityHelper({itemVisiblePercentThreashold: 100});
expect(helper.computeViewableItems(data.length, 0, 250, getFrameMetrics))
.toEqual([0, 1, 2]);
expect(helper.computeViewableItems(data.length, 1, 250, getFrameMetrics))
.toEqual([1, 2]);
});
});
describe('onUpdate', function() {
it(
'returns 1 visible row as viewable then scrolls away',
function() {
const helper = new ViewabilityHelper();
rowFrames = {
a: {y: 0, height: 50},
};
data = [{key: 'a'}];
const onViewableItemsChanged = jest.fn();
helper.onUpdate(
data.length,
0,
200,
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'}],
});
helper.onUpdate(
data.length,
0,
200,
getFrameMetrics,
createViewable,
onViewableItemsChanged,
);
expect(onViewableItemsChanged.mock.calls.length).toBe(1); // nothing changed!
helper.onUpdate(
data.length,
100,
200,
getFrameMetrics,
createViewable,
onViewableItemsChanged,
);
expect(onViewableItemsChanged.mock.calls.length).toBe(2);
expect(onViewableItemsChanged.mock.calls[1][0]).toEqual({
changed: [{isViewable: false, key: 'a'}],
viewableItems: [],
});
},
);
it(
'returns 1st visible row then 1st and 2nd then just 2nd',
function() {
const helper = new ViewabilityHelper();
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.mock.calls.length).toBe(1);
expect(onViewableItemsChanged.mock.calls[0][0]).toEqual({
changed: [{isViewable: true, key: 'a'}],
viewableItems: [{isViewable: true, key: 'a'}],
});
helper.onUpdate(
data.length,
100,
200,
getFrameMetrics,
createViewable,
onViewableItemsChanged,
);
expect(onViewableItemsChanged.mock.calls.length).toBe(2);
// Both visible with 100px overlap each
expect(onViewableItemsChanged.mock.calls[1][0]).toEqual({
changed: [{isViewable: true, key: 'b'}],
viewableItems: [{isViewable: true, key: 'a'}, {isViewable: true, key: 'b'}],
});
helper.onUpdate(
data.length,
200,
200,
getFrameMetrics,
createViewable,
onViewableItemsChanged,
);
expect(onViewableItemsChanged.mock.calls.length).toBe(3);
expect(onViewableItemsChanged.mock.calls[2][0]).toEqual({
changed: [{isViewable: false, key: 'a'}],
viewableItems: [{isViewable: true, key: 'b'}],
});
},
);
});