mirror of
https://github.com/status-im/react-native.git
synced 2025-01-28 02:04:55 +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,
|
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<*>;
|
||||||
|
@ -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 => {
|
||||||
|
@ -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});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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'}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user