Add basic nested VirtualizedList support

Summary:
This uses `context` to render inner lists of the same orientation to a plain `View` without
virtualization instead of rendering nested `ScrollView`s trying to scroll in the same direction,
which can cause problems.

Reviewed By: bvaughn

Differential Revision: D5174942

fbshipit-source-id: 989150294098de837b0ffb401c7f5679a3928a03
This commit is contained in:
Spencer Ahrens 2017-06-12 22:32:56 -07:00 committed by Facebook Github Bot
parent 2c32acb755
commit 63f7efcd32
5 changed files with 243 additions and 58 deletions

View File

@ -13,6 +13,7 @@
const Batchinator = require('Batchinator'); const Batchinator = require('Batchinator');
const FillRateHelper = require('FillRateHelper'); const FillRateHelper = require('FillRateHelper');
const PropTypes = require('prop-types');
const React = require('React'); const React = require('React');
const ReactNative = require('ReactNative'); const ReactNative = require('ReactNative');
const RefreshControl = require('RefreshControl'); const RefreshControl = require('RefreshControl');
@ -139,7 +140,7 @@ type OptionalProps = {
/** /**
* Render a custom scroll component, e.g. with a differently styled `RefreshControl`. * Render a custom scroll component, e.g. with a differently styled `RefreshControl`.
*/ */
renderScrollComponent: (props: Object) => React.Element<any>, renderScrollComponent?: (props: Object) => React.Element<any>,
/** /**
* Amount of time between low-pri item render batches, e.g. for rendering items quite a ways off * Amount of time between low-pri item render batches, e.g. for rendering items quite a ways off
* screen. Similar fill rate/responsiveness tradeoff as `maxToRenderPerBatch`. * screen. Similar fill rate/responsiveness tradeoff as `maxToRenderPerBatch`.
@ -301,35 +302,32 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
}, },
maxToRenderPerBatch: 10, maxToRenderPerBatch: 10,
onEndReachedThreshold: 2, // multiples of length onEndReachedThreshold: 2, // multiples of length
renderScrollComponent: (props: Props) => {
if (props.onRefresh) {
invariant(
typeof props.refreshing === 'boolean',
'`refreshing` prop must be set as a boolean in order to use `onRefresh`, but got `' +
JSON.stringify(props.refreshing) + '`',
);
return (
<ScrollView
{...props}
refreshControl={
<RefreshControl
refreshing={props.refreshing}
onRefresh={props.onRefresh}
progressViewOffset={props.progressViewOffset}
/>
}
/>
);
} else {
return <ScrollView {...props} />;
}
},
scrollEventThrottle: 50, scrollEventThrottle: 50,
updateCellsBatchingPeriod: 50, updateCellsBatchingPeriod: 50,
windowSize: 21, // multiples of length windowSize: 21, // multiples of length
}; };
static contextTypes = {
virtualizedList: PropTypes.shape({
horizontal: PropTypes.bool,
}),
};
static childContextTypes = {
virtualizedList: PropTypes.shape({
horizontal: PropTypes.bool,
}),
};
getChildContext() {
return {
virtualizedList: {
horizontal: this.props.horizontal,
// TODO: support nested virtualization and onViewableItemsChanged
},
};
}
state: State; state: State;
constructor(props: Props, context: Object) { constructor(props: Props, context: Object) {
@ -339,6 +337,11 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
'Components based on VirtualizedList must be wrapped with Animated.createAnimatedComponent ' + 'Components based on VirtualizedList must be wrapped with Animated.createAnimatedComponent ' +
'to support native onScroll events with useNativeDriver', 'to support native onScroll events with useNativeDriver',
); );
invariant(
!(this._isNestedWithSameOrientation() && props.onViewableItemsChanged),
'Nesting lists that scroll in the same direction does not support onViewableItemsChanged' +
'on the inner list.'
);
this._fillRateHelper = new FillRateHelper(this._getFrameMetrics); this._fillRateHelper = new FillRateHelper(this._getFrameMetrics);
this._updateCellsToRenderBatcher = new Batchinator( this._updateCellsToRenderBatcher = new Batchinator(
@ -431,6 +434,15 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
}); });
}; };
_isVirtualizationDisabled(): bool {
return this.props.disableVirtualization || this._isNestedWithSameOrientation();
}
_isNestedWithSameOrientation(): bool {
const nestedContext = this.context.virtualizedList;
return !!(nestedContext && !!nestedContext.horizontal === !!this.props.horizontal);
}
render() { render() {
if (__DEV__) { if (__DEV__) {
const flatStyles = flattenStyle(this.props.contentContainerStyle); const flatStyles = flattenStyle(this.props.contentContainerStyle);
@ -442,7 +454,8 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
} }
const {ListEmptyComponent, ListFooterComponent, ListHeaderComponent} = this.props; const {ListEmptyComponent, ListFooterComponent, ListHeaderComponent} = this.props;
const {data, disableVirtualization, horizontal} = this.props; const {data, horizontal} = this.props;
const isVirtualizationDisabled = this._isVirtualizationDisabled();
const cells = []; const cells = [];
const stickyIndicesFromProps = new Set(this.props.stickyHeaderIndices); const stickyIndicesFromProps = new Set(this.props.stickyHeaderIndices);
const stickyHeaderIndices = []; const stickyHeaderIndices = [];
@ -466,7 +479,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
const {first, last} = this.state; const {first, last} = this.state;
this._pushCells(cells, stickyHeaderIndices, stickyIndicesFromProps, 0, lastInitialIndex); this._pushCells(cells, stickyHeaderIndices, stickyIndicesFromProps, 0, lastInitialIndex);
const firstAfterInitial = Math.max(lastInitialIndex + 1, first); const firstAfterInitial = Math.max(lastInitialIndex + 1, first);
if (!disableVirtualization && first > lastInitialIndex + 1) { if (!isVirtualizationDisabled && first > lastInitialIndex + 1) {
let insertedStickySpacer = false; let insertedStickySpacer = false;
if (stickyIndicesFromProps.size > 0) { if (stickyIndicesFromProps.size > 0) {
const stickyOffset = ListHeaderComponent ? 1 : 0; const stickyOffset = ListHeaderComponent ? 1 : 0;
@ -507,7 +520,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
); );
this._hasWarned.keys = true; this._hasWarned.keys = true;
} }
if (!disableVirtualization && last < itemCount - 1) { if (!isVirtualizationDisabled && last < itemCount - 1) {
const lastFrame = this._getFrameMetricsApprox(last); const lastFrame = this._getFrameMetricsApprox(last);
// Without getItemLayout, we limit our tail spacer to the _highestMeasuredFrameIndex to // Without getItemLayout, we limit our tail spacer to the _highestMeasuredFrameIndex to
// prevent the user for hyperscrolling into un-measured area because otherwise content will // prevent the user for hyperscrolling into un-measured area because otherwise content will
@ -543,18 +556,21 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
</View> </View>
); );
} }
const ret = React.cloneElement( const scrollProps = {
this.props.renderScrollComponent(this.props), ...this.props,
{
onContentSizeChange: this._onContentSizeChange, onContentSizeChange: this._onContentSizeChange,
onLayout: this._onLayout, onLayout: this._onLayout,
onScroll: this._onScroll, onScroll: this._onScroll,
onScrollBeginDrag: this._onScrollBeginDrag, onScrollBeginDrag: this._onScrollBeginDrag,
onScrollEndDrag: this._onScrollEndDrag, onScrollEndDrag: this._onScrollEndDrag,
onMomentumScrollEnd: this._onMomentumScrollEnd, onMomentumScrollEnd: this._onMomentumScrollEnd,
ref: this._captureScrollRef,
scrollEventThrottle: this.props.scrollEventThrottle, // TODO: Android support scrollEventThrottle: this.props.scrollEventThrottle, // TODO: Android support
stickyHeaderIndices, stickyHeaderIndices,
};
const ret = React.cloneElement(
(this.props.renderScrollComponent || this._defaultRenderScrollComponent)(scrollProps),
{
ref: this._captureScrollRef,
}, },
cells, cells,
); );
@ -601,6 +617,32 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
); );
} }
_defaultRenderScrollComponent = (props) => {
if (this._isNestedWithSameOrientation()) {
return <View {...props} />;
} else if (props.onRefresh) {
invariant(
typeof props.refreshing === 'boolean',
'`refreshing` prop must be set as a boolean in order to use `onRefresh`, but got `' +
JSON.stringify(props.refreshing) + '`',
);
return (
<ScrollView
{...props}
refreshControl={
<RefreshControl
refreshing={props.refreshing}
onRefresh={props.onRefresh}
progressViewOffset={props.progressViewOffset}
/>
}
/>
);
} else {
return <ScrollView {...props} />;
}
};
_onCellLayout(e, cellKey, index) { _onCellLayout(e, cellKey, index) {
const layout = e.nativeEvent.layout; const layout = e.nativeEvent.layout;
const next = { const next = {
@ -816,14 +858,15 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
}; };
_updateCellsToRender = () => { _updateCellsToRender = () => {
const {data, disableVirtualization, getItemCount, onEndReachedThreshold} = this.props; const {data, getItemCount, onEndReachedThreshold} = this.props;
const isVirtualizationDisabled = this._isVirtualizationDisabled();
this._updateViewableItems(data); this._updateViewableItems(data);
if (!data) { if (!data) {
return; return;
} }
this.setState((state) => { this.setState((state) => {
let newState; let newState;
if (!disableVirtualization) { if (!isVirtualizationDisabled) {
newState = computeWindowedRenderLimits( newState = computeWindowedRenderLimits(
this.props, state, this._getFrameMetricsApprox, this._scrollMetrics, this.props, state, this._getFrameMetricsApprox, this._scrollMetrics,
); );

View File

@ -135,4 +135,26 @@ describe('VirtualizedList', () => {
expect(component).toMatchSnapshot(); expect(component).toMatchSnapshot();
infos[1].separators.unhighlight(); infos[1].separators.unhighlight();
}); });
it('handles nested lists', () => {
const component = ReactTestRenderer.create(
<VirtualizedList
data={[{key: 'outer0'}, {key: 'outer1'}]}
renderItem={(outerInfo) => (
<VirtualizedList
data={[{key: outerInfo.item.key + ':inner0'}, {key: outerInfo.item.key + ':inner1'}]}
horizontal={outerInfo.item.key === 'outer1'}
renderItem={(innerInfo) => {
return <item title={innerInfo.item.key} />;
}}
getItem={(data, index) => data[index]}
getItemCount={(data) => data.length}
/>
)}
getItem={(data, index) => data[index]}
getItemCount={(data) => data.length}
/>
);
expect(component).toMatchSnapshot();
});
}); });

View File

@ -52,7 +52,6 @@ exports[`FlatList renders all the bells and whistles 1`] = `
} }
refreshing={false} refreshing={false}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}
@ -156,7 +155,6 @@ exports[`FlatList renders empty list 1`] = `
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined} onViewableItemsChanged={undefined}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}
@ -186,7 +184,6 @@ exports[`FlatList renders null list 1`] = `
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined} onViewableItemsChanged={undefined}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}
@ -228,7 +225,6 @@ exports[`FlatList renders simple list 1`] = `
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined} onViewableItemsChanged={undefined}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}

View File

@ -34,7 +34,6 @@ exports[`SectionList rendering empty section headers is fine 1`] = `
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined} onViewableItemsChanged={undefined}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
renderSectionHeader={[Function]} renderSectionHeader={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
sections={ sections={
@ -113,7 +112,6 @@ exports[`SectionList renders a footer when there is no data 1`] = `
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined} onViewableItemsChanged={undefined}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
renderSectionFooter={[Function]} renderSectionFooter={[Function]}
renderSectionHeader={[Function]} renderSectionHeader={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
@ -180,7 +178,6 @@ exports[`SectionList renders a footer when there is no data and no header 1`] =
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined} onViewableItemsChanged={undefined}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
renderSectionFooter={[Function]} renderSectionFooter={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
sections={ sections={
@ -287,7 +284,6 @@ exports[`SectionList renders all the bells and whistles 1`] = `
} }
refreshing={false} refreshing={false}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
renderSectionFooter={[Function]} renderSectionFooter={[Function]}
renderSectionHeader={[Function]} renderSectionHeader={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
@ -505,7 +501,6 @@ exports[`SectionList renders empty list 1`] = `
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined} onViewableItemsChanged={undefined}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
sections={Array []} sections={Array []}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}

View File

@ -1,5 +1,144 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`VirtualizedList handles nested lists 1`] = `
<RCTScrollView
data={
Array [
Object {
"key": "outer0",
},
Object {
"key": "outer1",
},
]
}
disableVirtualization={false}
getItem={[Function]}
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
onMomentumScrollEnd={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
renderItem={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
windowSize={21}
>
<View>
<View
onLayout={[Function]}
>
<View
data={
Array [
Object {
"key": "outer0:inner0",
},
Object {
"key": "outer0:inner1",
},
]
}
disableVirtualization={false}
getItem={[Function]}
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
onMomentumScrollEnd={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
renderItem={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
windowSize={21}
>
<View
onLayout={[Function]}
>
<item
title="outer0:inner0"
/>
</View>
<View
onLayout={[Function]}
>
<item
title="outer0:inner1"
/>
</View>
</View>
</View>
<View
onLayout={[Function]}
>
<RCTScrollView
data={
Array [
Object {
"key": "outer1:inner0",
},
Object {
"key": "outer1:inner1",
},
]
}
disableVirtualization={false}
getItem={[Function]}
getItemCount={[Function]}
horizontal={true}
initialNumToRender={10}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
onEndReachedThreshold={2}
onLayout={[Function]}
onMomentumScrollEnd={[Function]}
onScroll={[Function]}
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
renderItem={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
windowSize={21}
>
<View>
<View
onLayout={[Function]}
>
<item
title="outer1:inner0"
/>
</View>
<View
onLayout={[Function]}
>
<item
title="outer1:inner1"
/>
</View>
</View>
</RCTScrollView>
</View>
</View>
</RCTScrollView>
`;
exports[`VirtualizedList handles separators correctly 1`] = ` exports[`VirtualizedList handles separators correctly 1`] = `
<RCTScrollView <RCTScrollView
ItemSeparatorComponent={[Function]} ItemSeparatorComponent={[Function]}
@ -31,7 +170,6 @@ exports[`VirtualizedList handles separators correctly 1`] = `
onScrollBeginDrag={[Function]} onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}
@ -110,7 +248,6 @@ exports[`VirtualizedList handles separators correctly 2`] = `
onScrollBeginDrag={[Function]} onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}
@ -189,7 +326,6 @@ exports[`VirtualizedList handles separators correctly 3`] = `
onScrollBeginDrag={[Function]} onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}
@ -288,7 +424,6 @@ exports[`VirtualizedList renders all the bells and whistles 1`] = `
} }
refreshing={false} refreshing={false}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}
@ -367,7 +502,6 @@ exports[`VirtualizedList renders empty list 1`] = `
onScrollBeginDrag={[Function]} onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}
@ -398,7 +532,6 @@ exports[`VirtualizedList renders empty list with empty component 1`] = `
onScrollBeginDrag={[Function]} onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}
@ -449,7 +582,6 @@ exports[`VirtualizedList renders list with empty component 1`] = `
onScrollBeginDrag={[Function]} onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}
@ -485,7 +617,6 @@ exports[`VirtualizedList renders null list 1`] = `
onScrollBeginDrag={[Function]} onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}
@ -525,7 +656,6 @@ exports[`VirtualizedList renders simple list 1`] = `
onScrollBeginDrag={[Function]} onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}
@ -581,7 +711,6 @@ exports[`VirtualizedList test getItem functionality where data is not an Array 1
onScrollBeginDrag={[Function]} onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]} onScrollEndDrag={[Function]}
renderItem={[Function]} renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50} scrollEventThrottle={50}
stickyHeaderIndices={Array []} stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50} updateCellsBatchingPeriod={50}