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 FillRateHelper = require('FillRateHelper');
const PropTypes = require('prop-types');
const React = require('React');
const ReactNative = require('ReactNative');
const RefreshControl = require('RefreshControl');
@ -139,7 +140,7 @@ type OptionalProps = {
/**
* 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
* screen. Similar fill rate/responsiveness tradeoff as `maxToRenderPerBatch`.
@ -301,35 +302,32 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
},
maxToRenderPerBatch: 10,
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,
updateCellsBatchingPeriod: 50,
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;
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 ' +
'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._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() {
if (__DEV__) {
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 {data, disableVirtualization, horizontal} = this.props;
const {data, horizontal} = this.props;
const isVirtualizationDisabled = this._isVirtualizationDisabled();
const cells = [];
const stickyIndicesFromProps = new Set(this.props.stickyHeaderIndices);
const stickyHeaderIndices = [];
@ -466,7 +479,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
const {first, last} = this.state;
this._pushCells(cells, stickyHeaderIndices, stickyIndicesFromProps, 0, lastInitialIndex);
const firstAfterInitial = Math.max(lastInitialIndex + 1, first);
if (!disableVirtualization && first > lastInitialIndex + 1) {
if (!isVirtualizationDisabled && first > lastInitialIndex + 1) {
let insertedStickySpacer = false;
if (stickyIndicesFromProps.size > 0) {
const stickyOffset = ListHeaderComponent ? 1 : 0;
@ -507,7 +520,7 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
);
this._hasWarned.keys = true;
}
if (!disableVirtualization && last < itemCount - 1) {
if (!isVirtualizationDisabled && last < itemCount - 1) {
const lastFrame = this._getFrameMetricsApprox(last);
// Without getItemLayout, we limit our tail spacer to the _highestMeasuredFrameIndex to
// 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>
);
}
const scrollProps = {
...this.props,
onContentSizeChange: this._onContentSizeChange,
onLayout: this._onLayout,
onScroll: this._onScroll,
onScrollBeginDrag: this._onScrollBeginDrag,
onScrollEndDrag: this._onScrollEndDrag,
onMomentumScrollEnd: this._onMomentumScrollEnd,
scrollEventThrottle: this.props.scrollEventThrottle, // TODO: Android support
stickyHeaderIndices,
};
const ret = React.cloneElement(
this.props.renderScrollComponent(this.props),
(this.props.renderScrollComponent || this._defaultRenderScrollComponent)(scrollProps),
{
onContentSizeChange: this._onContentSizeChange,
onLayout: this._onLayout,
onScroll: this._onScroll,
onScrollBeginDrag: this._onScrollBeginDrag,
onScrollEndDrag: this._onScrollEndDrag,
onMomentumScrollEnd: this._onMomentumScrollEnd,
ref: this._captureScrollRef,
scrollEventThrottle: this.props.scrollEventThrottle, // TODO: Android support
stickyHeaderIndices,
},
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) {
const layout = e.nativeEvent.layout;
const next = {
@ -816,14 +858,15 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
};
_updateCellsToRender = () => {
const {data, disableVirtualization, getItemCount, onEndReachedThreshold} = this.props;
const {data, getItemCount, onEndReachedThreshold} = this.props;
const isVirtualizationDisabled = this._isVirtualizationDisabled();
this._updateViewableItems(data);
if (!data) {
return;
}
this.setState((state) => {
let newState;
if (!disableVirtualization) {
if (!isVirtualizationDisabled) {
newState = computeWindowedRenderLimits(
this.props, state, this._getFrameMetricsApprox, this._scrollMetrics,
);

View File

@ -135,4 +135,26 @@ describe('VirtualizedList', () => {
expect(component).toMatchSnapshot();
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}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
@ -156,7 +155,6 @@ exports[`FlatList renders empty list 1`] = `
onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
@ -186,7 +184,6 @@ exports[`FlatList renders null list 1`] = `
onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
@ -228,7 +225,6 @@ exports[`FlatList renders simple list 1`] = `
onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}

View File

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

View File

@ -1,5 +1,144 @@
// 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`] = `
<RCTScrollView
ItemSeparatorComponent={[Function]}
@ -31,7 +170,6 @@ exports[`VirtualizedList handles separators correctly 1`] = `
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
@ -110,7 +248,6 @@ exports[`VirtualizedList handles separators correctly 2`] = `
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
@ -189,7 +326,6 @@ exports[`VirtualizedList handles separators correctly 3`] = `
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
@ -288,7 +424,6 @@ exports[`VirtualizedList renders all the bells and whistles 1`] = `
}
refreshing={false}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
@ -367,7 +502,6 @@ exports[`VirtualizedList renders empty list 1`] = `
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
@ -398,7 +532,6 @@ exports[`VirtualizedList renders empty list with empty component 1`] = `
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
@ -449,7 +582,6 @@ exports[`VirtualizedList renders list with empty component 1`] = `
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
@ -485,7 +617,6 @@ exports[`VirtualizedList renders null list 1`] = `
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
@ -525,7 +656,6 @@ exports[`VirtualizedList renders simple list 1`] = `
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
@ -581,7 +711,6 @@ exports[`VirtualizedList test getItem functionality where data is not an Array 1
onScrollBeginDrag={[Function]}
onScrollEndDrag={[Function]}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}