Permalink
Browse files

configurable Viewability

Reviewed By: yungsters

Differential Revision: D4577395

fbshipit-source-id: 9b9099f5bd5f8fe20b5c24eab7e43f298ba665d9
  • Loading branch information...
sahrens authored and facebook-github-bot committed Feb 22, 2017
1 parent fa34035 commit f2687bf4b6201931454c8c43785d78054c856d80
@@ -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;
@@ -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(
@@ -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,
);
}
}
@@ -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,
Oops, something went wrong.

1 comment on commit f2687bf

@hramos

This comment has been minimized.

Show comment
Hide comment
@hramos

hramos Feb 22, 2017

Contributor

This commit is breaking open source tests in Circle and will be reverted in #12526.

Contributor

hramos commented on f2687bf Feb 22, 2017

This commit is breaking open source tests in Circle and will be reverted in #12526.

Please sign in to comment.