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
This commit is contained in:
Vince Oppedisano 2017-09-05 18:33:44 -07:00 committed by Facebook Github Bot
parent 64be88398d
commit ad733ad430
5 changed files with 166 additions and 46 deletions

View File

@ -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<ItemT> = {
@ -191,6 +195,11 @@ type OptionalProps<ItemT> = {
* 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<ViewabilityConfigCallbackPair>,
};
type Props<ItemT> = RequiredProps<ItemT> &
OptionalProps<ItemT> &
@ -405,11 +414,47 @@ class FlatList<ItemT> extends React.PureComponent<Props<ItemT>, 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<ViewabilityConfigCallbackPair> = [];
_captureRef = ref => {
/* $FlowFixMe(>=0.53.0 site=react_native_fb,react_native_oss) This comment
@ -426,6 +471,8 @@ class FlatList<ItemT> extends React.PureComponent<Props<ItemT>, void> {
legacyImplementation,
numColumns,
columnWrapperStyle,
onViewableItemsChanged,
viewabilityConfigCallbackPairs,
} = props;
invariant(
!getItem && !getItemCount,
@ -454,6 +501,11 @@ class FlatList<ItemT> extends React.PureComponent<Props<ItemT>, void> {
this._hasWarnedLegacy = true;
}
}
invariant(
!(onViewableItemsChanged && viewabilityConfigCallbackPairs),
'FlatList does not support setting both onViewableItemsChanged and ' +
'viewabilityConfigCallbackPairs.',
);
}
_getItem = (data: Array<ItemT>, index: number) => {
@ -500,23 +552,32 @@ class FlatList<ItemT> extends React.PureComponent<Props<ItemT>, 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<ViewToken>,
changed: Array<ViewToken>,
}) => void,
) {
return (info: {
viewableItems: Array<ViewToken>,
changed: Array<ViewToken>,
}) => {
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<ItemT> extends React.PureComponent<Props<ItemT>, void> {
getItemCount={this._getItemCount}
keyExtractor={this._keyExtractor}
ref={this._captureRef}
onViewableItemsChanged={
this.props.onViewableItemsChanged && this._onViewableItemsChanged
}
viewabilityConfigCallbackPairs={this._virtualizedListPairs}
/>
);
}

View File

@ -22,6 +22,14 @@ export type ViewToken = {
section?: any,
};
export type ViewabilityConfigCallbackPair = {
viewabilityConfig: ViewabilityConfig,
onViewableItemsChanged: (info: {
viewableItems: Array<ViewToken>,
changed: Array<ViewToken>,
}) => 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,
});
}
}

View File

@ -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<any>;
type ViewabilityHelperCallbackTuple = {
viewabilityHelper: ViewabilityHelper,
onViewableItemsChanged: (info: {
viewableItems: Array<ViewToken>,
changed: Array<ViewToken>,
}) => 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<ViewabilityConfigCallbackPair>,
/**
* 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<Props, State> {
}
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<Props, State> {
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<Props, State> {
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<Props, State> {
_totalCellLength = 0;
_totalCellsMeasured = 0;
_updateCellsToRenderBatcher: Batchinator;
_viewabilityHelper: ViewabilityHelper;
_viewabilityTuples: Array<ViewabilityHelperCallbackTuple> = [];
_captureScrollRef = ref => {
this._scrollRef = ref;
@ -1062,7 +1095,9 @@ class VirtualizedList extends React.PureComponent<Props, State> {
}
_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<Props, State> {
};
_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,
);
});
}
}

View File

@ -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'}],
});
});

View File

@ -42,7 +42,6 @@ exports[`FlatList renders all the bells and whistles 1`] = `
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined}
refreshControl={
<RefreshControlMock
onRefresh={[Function]}
@ -55,6 +54,7 @@ exports[`FlatList renders all the bells and whistles 1`] = `
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
viewabilityConfigCallbackPairs={Array []}
windowSize={21}
>
<RCTRefreshControl />
@ -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}
>
<View />
@ -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}
>
<View />
@ -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}
>
<View>