Skip to content

Commit

Permalink
Add basic nested VirtualizedList support
Browse files Browse the repository at this point in the history
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
  • Loading branch information
sahrens authored and facebook-github-bot committed Jun 13, 2017
1 parent 2c32acb commit 63f7efc
Show file tree
Hide file tree
Showing 5 changed files with 243 additions and 58 deletions.
121 changes: 82 additions & 39 deletions Libraries/Lists/VirtualizedList.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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) {
Expand All @@ -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(
Expand Down Expand Up @@ -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);
Expand All @@ -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 = [];
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
);
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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,
);
Expand Down
22 changes: 22 additions & 0 deletions Libraries/Lists/__tests__/VirtualizedList-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
4 changes: 0 additions & 4 deletions Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down Expand Up @@ -156,7 +155,6 @@ exports[`FlatList renders empty list 1`] = `
onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
Expand Down Expand Up @@ -186,7 +184,6 @@ exports[`FlatList renders null list 1`] = `
onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
Expand Down Expand Up @@ -228,7 +225,6 @@ exports[`FlatList renders simple list 1`] = `
onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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={
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -505,7 +501,6 @@ exports[`SectionList renders empty list 1`] = `
onScrollEndDrag={[Function]}
onViewableItemsChanged={undefined}
renderItem={[Function]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
sections={Array []}
stickyHeaderIndices={Array []}
Expand Down
Loading

0 comments on commit 63f7efc

Please sign in to comment.