From ad733ad430a4b1d69b89041be8b227e2dfdb5501 Mon Sep 17 00:00:00 2001 From: Vince Oppedisano Date: Tue, 5 Sep 2017 18:33:44 -0700 Subject: [PATCH] Extend FlatList to support multiple viewability configs Summary: FlatList only supports one viewability configuration and callback. This change extends FlatList and VirtualizedList to support multiple viewability configurations and corresponding callbacks. Reviewed By: sahrens Differential Revision: D5720860 fbshipit-source-id: 9d24946362fa9001d44d4980c85f7d2627e45a33 --- Libraries/Lists/FlatList.js | 101 ++++++++++++++---- Libraries/Lists/ViewabilityHelper.js | 9 ++ Libraries/Lists/VirtualizedList.js | 77 +++++++++---- .../Lists/__tests__/ViewabilityHelper-test.js | 17 +++ .../__snapshots__/FlatList-test.js.snap | 8 +- 5 files changed, 166 insertions(+), 46 deletions(-) diff --git a/Libraries/Lists/FlatList.js b/Libraries/Lists/FlatList.js index 6fc108e7c2af2d..5c31320acf6b9b 100644 --- a/Libraries/Lists/FlatList.js +++ b/Libraries/Lists/FlatList.js @@ -20,7 +20,11 @@ const VirtualizedList = require('VirtualizedList'); const invariant = require('fbjs/lib/invariant'); import type {StyleObj} from 'StyleSheetTypes'; -import type {ViewabilityConfig, ViewToken} from 'ViewabilityHelper'; +import type { + ViewabilityConfig, + ViewToken, + ViewabilityConfigCallbackPair, +} from 'ViewabilityHelper'; import type {Props as VirtualizedListProps} from 'VirtualizedList'; type RequiredProps = { @@ -191,6 +195,11 @@ type OptionalProps = { * See `ViewabilityHelper` for flow type and further documentation. */ viewabilityConfig?: ViewabilityConfig, + /** + * List of ViewabilityConfig/onViewableItemsChanged pairs. A specific onViewableItemsChanged + * will be called when its corresponding ViewabilityConfig's conditions are met. + */ + viewabilityConfigCallbackPairs?: Array, }; type Props = RequiredProps & OptionalProps & @@ -405,11 +414,47 @@ class FlatList extends React.PureComponent, void> { 'Changing numColumns on the fly is not supported. Change the key prop on FlatList when ' + 'changing the number of columns to force a fresh render of the component.', ); + invariant( + nextProps.onViewableItemsChanged === this.props.onViewableItemsChanged, + 'Changing onViewableItemsChanged on the fly is not supported', + ); + invariant( + nextProps.viewabilityConfig === this.props.viewabilityConfig, + 'Changing viewabilityConfig on the fly is not supported', + ); + invariant( + nextProps.viewabilityConfigCallbackPairs === + this.props.viewabilityConfigCallbackPairs, + 'Changing viewabilityConfigCallbackPairs on the fly is not supported', + ); + this._checkProps(nextProps); } + constructor(props: Props<*>) { + super(props); + if (this.props.viewabilityConfigCallbackPairs) { + this._virtualizedListPairs = this.props.viewabilityConfigCallbackPairs.map( + pair => ({ + viewabilityConfig: pair.viewabilityConfig, + onViewableItemsChanged: this._createOnViewableItemsChanged( + pair.onViewableItemsChanged, + ), + }), + ); + } else if (this.props.onViewableItemsChanged) { + this._virtualizedListPairs.push({ + viewabilityConfig: this.props.viewabilityConfig, + onViewableItemsChanged: this._createOnViewableItemsChanged( + this.props.onViewableItemsChanged, + ), + }); + } + } + _hasWarnedLegacy = false; _listRef: VirtualizedList; + _virtualizedListPairs: Array = []; _captureRef = ref => { /* $FlowFixMe(>=0.53.0 site=react_native_fb,react_native_oss) This comment @@ -426,6 +471,8 @@ class FlatList extends React.PureComponent, void> { legacyImplementation, numColumns, columnWrapperStyle, + onViewableItemsChanged, + viewabilityConfigCallbackPairs, } = props; invariant( !getItem && !getItemCount, @@ -454,6 +501,11 @@ class FlatList extends React.PureComponent, void> { this._hasWarnedLegacy = true; } } + invariant( + !(onViewableItemsChanged && viewabilityConfigCallbackPairs), + 'FlatList does not support setting both onViewableItemsChanged and ' + + 'viewabilityConfigCallbackPairs.', + ); } _getItem = (data: Array, index: number) => { @@ -500,23 +552,32 @@ class FlatList extends React.PureComponent, void> { }); } - _onViewableItemsChanged = info => { - const {numColumns, onViewableItemsChanged} = this.props; - if (!onViewableItemsChanged) { - return; - } - if (numColumns > 1) { - const changed = []; - const viewableItems = []; - info.viewableItems.forEach(v => - this._pushMultiColumnViewable(viewableItems, v), - ); - info.changed.forEach(v => this._pushMultiColumnViewable(changed, v)); - onViewableItemsChanged({viewableItems, changed}); - } else { - onViewableItemsChanged(info); - } - }; + _createOnViewableItemsChanged( + onViewableItemsChanged: ?(info: { + viewableItems: Array, + changed: Array, + }) => void, + ) { + return (info: { + viewableItems: Array, + changed: Array, + }) => { + const {numColumns} = this.props; + if (onViewableItemsChanged) { + if (numColumns > 1) { + const changed = []; + const viewableItems = []; + info.viewableItems.forEach(v => + this._pushMultiColumnViewable(viewableItems, v), + ); + info.changed.forEach(v => this._pushMultiColumnViewable(changed, v)); + onViewableItemsChanged({viewableItems, changed}); + } else { + onViewableItemsChanged(info); + } + } + }; + } _renderItem = (info: Object) => { const {renderItem, numColumns, columnWrapperStyle} = this.props; @@ -561,9 +622,7 @@ class FlatList extends React.PureComponent, void> { getItemCount={this._getItemCount} keyExtractor={this._keyExtractor} ref={this._captureRef} - onViewableItemsChanged={ - this.props.onViewableItemsChanged && this._onViewableItemsChanged - } + viewabilityConfigCallbackPairs={this._virtualizedListPairs} /> ); } diff --git a/Libraries/Lists/ViewabilityHelper.js b/Libraries/Lists/ViewabilityHelper.js index 7108b2b5d2a5b9..370c20fec3b830 100644 --- a/Libraries/Lists/ViewabilityHelper.js +++ b/Libraries/Lists/ViewabilityHelper.js @@ -22,6 +22,14 @@ export type ViewToken = { section?: any, }; +export type ViewabilityConfigCallbackPair = { + viewabilityConfig: ViewabilityConfig, + onViewableItemsChanged: (info: { + viewableItems: Array, + changed: Array, + }) => void, +}; + export type ViewabilityConfig = {| /** * Minimum amount of time (in milliseconds) that an item must be physically viewable before the @@ -256,6 +264,7 @@ class ViewabilityHelper { onViewableItemsChanged({ viewableItems: Array.from(nextItems.values()), changed, + viewabilityConfig: this._config, }); } } diff --git a/Libraries/Lists/VirtualizedList.js b/Libraries/Lists/VirtualizedList.js index b40f96d00d1a02..c1c22756d7c092 100644 --- a/Libraries/Lists/VirtualizedList.js +++ b/Libraries/Lists/VirtualizedList.js @@ -31,12 +31,24 @@ const warning = require('fbjs/lib/warning'); const {computeWindowedRenderLimits} = require('VirtualizeUtils'); import type {StyleObj} from 'StyleSheetTypes'; -import type {ViewabilityConfig, ViewToken} from 'ViewabilityHelper'; +import type { + ViewabilityConfig, + ViewToken, + ViewabilityConfigCallbackPair, +} from 'ViewabilityHelper'; type Item = any; type renderItemType = (info: any) => ?React.Element; +type ViewabilityHelperCallbackTuple = { + viewabilityHelper: ViewabilityHelper, + onViewableItemsChanged: (info: { + viewableItems: Array, + changed: Array, + }) => void, +}; + type RequiredProps = { renderItem: renderItemType, /** @@ -161,6 +173,11 @@ type OptionalProps = { */ updateCellsBatchingPeriod: number, viewabilityConfig?: ViewabilityConfig, + /** + * List of ViewabilityConfig/onViewableItemsChanged pairs. A specific onViewableItemsChanged + * will be called when its corresponding ViewabilityConfig's conditions are met. + */ + viewabilityConfigCallbackPairs?: Array, /** * Determines the maximum number of items rendered outside of the visible area, in units of * visible lengths. So if your list fills the screen, then `windowSize={21}` (the default) will @@ -311,7 +328,9 @@ class VirtualizedList extends React.PureComponent { } recordInteraction() { - this._viewabilityHelper.recordInteraction(); + this._viewabilityTuples.forEach(t => { + t.viewabilityHelper.recordInteraction(); + }); this._updateViewableItems(this.props.data); } @@ -415,9 +434,21 @@ class VirtualizedList extends React.PureComponent { this._updateCellsToRender, this.props.updateCellsBatchingPeriod, ); - this._viewabilityHelper = new ViewabilityHelper( - this.props.viewabilityConfig, - ); + + if (this.props.viewabilityConfigCallbackPairs) { + this._viewabilityTuples = this.props.viewabilityConfigCallbackPairs.map( + pair => ({ + viewabilityHelper: new ViewabilityHelper(pair.viewabilityConfig), + onViewableItemsChanged: pair.onViewableItemsChanged, + }), + ); + } else if (this.props.onViewableItemsChanged) { + this._viewabilityTuples.push({ + viewabilityHelper: new ViewabilityHelper(this.props.viewabilityConfig), + onViewableItemsChanged: this.props.onViewableItemsChanged, + }); + } + this.state = { first: this.props.initialScrollIndex || 0, last: @@ -444,7 +475,9 @@ class VirtualizedList extends React.PureComponent { componentWillUnmount() { this._updateViewableItems(null); this._updateCellsToRenderBatcher.dispose(); - this._viewabilityHelper.dispose(); + this._viewabilityTuples.forEach(tuple => { + tuple.viewabilityHelper.dispose(); + }); this._fillRateHelper.deactivateAndFlush(); clearTimeout(this._initialScrollIndexTimeout); } @@ -770,7 +803,7 @@ class VirtualizedList extends React.PureComponent { _totalCellLength = 0; _totalCellsMeasured = 0; _updateCellsToRenderBatcher: Batchinator; - _viewabilityHelper: ViewabilityHelper; + _viewabilityTuples: Array = []; _captureScrollRef = ref => { this._scrollRef = ref; @@ -1062,7 +1095,9 @@ class VirtualizedList extends React.PureComponent { } _onScrollBeginDrag = (e): void => { - this._viewabilityHelper.recordInteraction(); + this._viewabilityTuples.forEach(tuple => { + tuple.viewabilityHelper.recordInteraction(); + }); this.props.onScrollBeginDrag && this.props.onScrollBeginDrag(e); }; @@ -1195,19 +1230,19 @@ class VirtualizedList extends React.PureComponent { }; _updateViewableItems(data: any) { - const {getItemCount, onViewableItemsChanged} = this.props; - if (!onViewableItemsChanged) { - return; - } - this._viewabilityHelper.onUpdate( - getItemCount(data), - this._scrollMetrics.offset, - this._scrollMetrics.visibleLength, - this._getFrameMetrics, - this._createViewToken, - onViewableItemsChanged, - this.state, - ); + const {getItemCount} = this.props; + + this._viewabilityTuples.forEach(tuple => { + tuple.viewabilityHelper.onUpdate( + getItemCount(data), + this._scrollMetrics.offset, + this._scrollMetrics.visibleLength, + this._getFrameMetrics, + this._createViewToken, + tuple.onViewableItemsChanged, + this.state, + ); + }); } } diff --git a/Libraries/Lists/__tests__/ViewabilityHelper-test.js b/Libraries/Lists/__tests__/ViewabilityHelper-test.js index 9dbcb2c4de205f..829408212ad777 100644 --- a/Libraries/Lists/__tests__/ViewabilityHelper-test.js +++ b/Libraries/Lists/__tests__/ViewabilityHelper-test.js @@ -185,6 +185,7 @@ describe('onUpdate', function() { expect(onViewableItemsChanged.mock.calls.length).toBe(1); expect(onViewableItemsChanged.mock.calls[0][0]).toEqual({ changed: [{isViewable: true, key: 'a'}], + viewabilityConfig: {viewAreaCoveragePercentThreshold: 0}, viewableItems: [{isViewable: true, key: 'a'}], }); helper.onUpdate( @@ -207,6 +208,7 @@ describe('onUpdate', function() { expect(onViewableItemsChanged.mock.calls.length).toBe(2); expect(onViewableItemsChanged.mock.calls[1][0]).toEqual({ changed: [{isViewable: false, key: 'a'}], + viewabilityConfig: {viewAreaCoveragePercentThreshold: 0}, viewableItems: [], }); }); @@ -230,6 +232,7 @@ describe('onUpdate', function() { expect(onViewableItemsChanged.mock.calls.length).toBe(1); expect(onViewableItemsChanged.mock.calls[0][0]).toEqual({ changed: [{isViewable: true, key: 'a'}], + viewabilityConfig: {viewAreaCoveragePercentThreshold: 0}, viewableItems: [{isViewable: true, key: 'a'}], }); helper.onUpdate( @@ -244,6 +247,7 @@ describe('onUpdate', function() { // Both visible with 100px overlap each expect(onViewableItemsChanged.mock.calls[1][0]).toEqual({ changed: [{isViewable: true, key: 'b'}], + viewabilityConfig: {viewAreaCoveragePercentThreshold: 0}, viewableItems: [ {isViewable: true, key: 'a'}, {isViewable: true, key: 'b'}, @@ -260,6 +264,7 @@ describe('onUpdate', function() { expect(onViewableItemsChanged.mock.calls.length).toBe(3); expect(onViewableItemsChanged.mock.calls[2][0]).toEqual({ changed: [{isViewable: false, key: 'a'}], + viewabilityConfig: {viewAreaCoveragePercentThreshold: 0}, viewableItems: [{isViewable: true, key: 'b'}], }); }); @@ -290,6 +295,10 @@ describe('onUpdate', function() { expect(onViewableItemsChanged.mock.calls.length).toBe(1); expect(onViewableItemsChanged.mock.calls[0][0]).toEqual({ changed: [{isViewable: true, key: 'a'}], + viewabilityConfig: { + minimumViewTime: 350, + viewAreaCoveragePercentThreshold: 0, + }, viewableItems: [{isViewable: true, key: 'a'}], }); }); @@ -327,6 +336,10 @@ describe('onUpdate', function() { expect(onViewableItemsChanged.mock.calls.length).toBe(1); expect(onViewableItemsChanged.mock.calls[0][0]).toEqual({ changed: [{isViewable: true, key: 'b'}], + viewabilityConfig: { + minimumViewTime: 350, + viewAreaCoveragePercentThreshold: 0, + }, viewableItems: [{isViewable: true, key: 'b'}], }); }); @@ -365,6 +378,10 @@ describe('onUpdate', function() { expect(onViewableItemsChanged.mock.calls.length).toBe(1); expect(onViewableItemsChanged.mock.calls[0][0]).toEqual({ changed: [{isViewable: true, key: 'a'}], + viewabilityConfig: { + waitForInteraction: true, + viewAreaCoveragePercentThreshold: 0, + }, viewableItems: [{isViewable: true, key: 'a'}], }); }); diff --git a/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap b/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap index 59e71958420b23..7007b25c7e7551 100644 --- a/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap +++ b/Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap @@ -42,7 +42,6 @@ exports[`FlatList renders all the bells and whistles 1`] = ` onScroll={[Function]} onScrollBeginDrag={[Function]} onScrollEndDrag={[Function]} - onViewableItemsChanged={undefined} refreshControl={ @@ -158,11 +158,11 @@ exports[`FlatList renders empty list 1`] = ` onScroll={[Function]} onScrollBeginDrag={[Function]} onScrollEndDrag={[Function]} - onViewableItemsChanged={undefined} renderItem={[Function]} scrollEventThrottle={50} stickyHeaderIndices={Array []} updateCellsBatchingPeriod={50} + viewabilityConfigCallbackPairs={Array []} windowSize={21} > @@ -187,11 +187,11 @@ exports[`FlatList renders null list 1`] = ` onScroll={[Function]} onScrollBeginDrag={[Function]} onScrollEndDrag={[Function]} - onViewableItemsChanged={undefined} renderItem={[Function]} scrollEventThrottle={50} stickyHeaderIndices={Array []} updateCellsBatchingPeriod={50} + viewabilityConfigCallbackPairs={Array []} windowSize={21} > @@ -228,11 +228,11 @@ exports[`FlatList renders simple list 1`] = ` onScroll={[Function]} onScrollBeginDrag={[Function]} onScrollEndDrag={[Function]} - onViewableItemsChanged={undefined} renderItem={[Function]} scrollEventThrottle={50} stickyHeaderIndices={Array []} updateCellsBatchingPeriod={50} + viewabilityConfigCallbackPairs={Array []} windowSize={21} >