diff --git a/Examples/UIExplorer/js/FlatListExample.js b/Examples/UIExplorer/js/FlatListExample.js index d2d2fc028..bf98525b6 100644 --- a/Examples/UIExplorer/js/FlatListExample.js +++ b/Examples/UIExplorer/js/FlatListExample.js @@ -47,6 +47,12 @@ const { renderSmallSwitchOption, } = require('./ListExampleShared'); +const VIEWABILITY_CONFIG = { + minimumViewTime: 3000, + viewAreaCoveragePercentThreshold: 0, + waitForInteraction: true, +}; + class FlatListExample extends React.PureComponent { static title = ''; 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} /> ); @@ -154,6 +164,7 @@ class FlatListExample extends React.PureComponent { } }; _pressItem = (key: number) => { + this._listRef.recordInteraction(); pressItem(this, key); }; _listRef: FlatList<*>; diff --git a/Libraries/Experimental/FlatList.js b/Libraries/Experimental/FlatList.js index 05f96a240..d82c5ef1c 100644 --- a/Libraries/Experimental/FlatList.js +++ b/Libraries/Experimental/FlatList.js @@ -197,6 +197,15 @@ class FlatList extends React.PureComponent, 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 extends React.PureComponent, vo } }; - _getItemCount = (data: Array): number => { - return Math.floor(data.length / this.props.numColumns); + _getItemCount = (data?: ?Array): number => { + return data ? Math.ceil(data.length / this.props.numColumns) : 0; }; _keyExtractor = (items: ItemT | Array, index: number): string => { diff --git a/Libraries/Experimental/ViewabilityHelper.js b/Libraries/Experimental/ViewabilityHelper.js index 603bbedbb..0b26c514f 100644 --- a/Libraries/Experimental/ViewabilityHelper.js +++ b/Libraries/Experimental/ViewabilityHelper.js @@ -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 = new Set(); + _viewableIndices: Array = []; _viewableItems: Map = 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, changed: Array}) => 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}); } } } diff --git a/Libraries/Experimental/VirtualizedList.js b/Libraries/Experimental/VirtualizedList.js index 7f5b600cc..b90cd4e80 100644 --- a/Libraries/Experimental/VirtualizedList.js +++ b/Libraries/Experimental/VirtualizedList.js @@ -176,6 +176,11 @@ class VirtualizedList extends React.PureComponent { ); } + 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 { componentWillUnmount() { this._updateViewableItems(null); this._updateCellsToRenderBatcher.dispose(); + this._viewabilityHelper.dispose(); } componentWillReceiveProps(newProps: Props) { @@ -509,6 +515,7 @@ class VirtualizedList extends React.PureComponent { 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; } diff --git a/Libraries/Experimental/__tests__/ViewabilityHelper-test.js b/Libraries/Experimental/__tests__/ViewabilityHelper-test.js index 93430dc89..2e272900a 100644 --- a/Libraries/Experimental/__tests__/ViewabilityHelper-test.js +++ b/Libraries/Experimental/__tests__/ViewabilityHelper-test.js @@ -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'}], + }); + } + ); + });