Add ListEmptyComponent prop

Summary:
Hey there :)

Please let me know if the name `ListEmptyComponent` should be changed. I also thought about `ListNoItemsComponent`. Or maybe `ListPlaceholderComponent`?

- [x] Explain the **motivation** for making this change.
- [x] Provide a **test plan** demonstrating that the code is solid.
- [x] Match the **code formatting** of the rest of the codebase.
- [x] Target the `master` branch, NOT a "stable" branch.

In a FlatList, I wanted to show some placeholder when my data is empty (while keeping eventual Header/Footer/RefreshControl).
A way around this issue would be to do something like adding a `ListHeaderComponent` that checks if the list is empty, like so:
```js
ListHeaderComponent={() => (!data.length ? <Text style={styles.noDataText}>No data found</Text> : null)}
```
But I felt it was not easily readable as soon as you have an actual header.

This PR adds a `ListEmptyComponent` that is rendered when the list is empty.

I added tests for VirtualizedList, FlatList and SectionList and ran `yarn test -- -u`. I then checked that the snapshots changed like I wanted.
I also tested this against one of my project, though I had to manually add my changes because the project is on RN 0.43.

Here are the docs screenshots:
- [VirtualizedList](https://cloud.githubusercontent.com/assets/82368/25566000/0ebf2b82-2dd2-11e7-8b80-d8c505f1f2d6.png)
- [FlatList](https://cloud.githubusercontent.com/assets/82368/25566005/2842ab42-2dd2-11e7-81b4-32c74c2b4fc3.png)
- [SectionList](https://cloud.githubusercontent.com/assets/82368/25566010/368aec1e-2dd2-11e7-9425-3bb5e5803513.png)

Thanks for your work!
Closes https://github.com/facebook/react-native/pull/13718

Differential Revision: D4993711

Pulled By: sahrens

fbshipit-source-id: 055b40f709067071e40308bdf5a37cedaa223dc5
This commit is contained in:
Louis Lagrange 2017-05-04 00:08:14 -07:00 committed by Facebook Github Bot
parent 37f3ce1f2c
commit 264d67c424
9 changed files with 169 additions and 5 deletions

View File

@ -72,6 +72,11 @@ type OptionalProps<ItemT> = {
* `separators.updateProps`. * `separators.updateProps`.
*/ */
ItemSeparatorComponent?: ?ReactClass<any>, ItemSeparatorComponent?: ?ReactClass<any>,
/**
* Rendered when the list is empty. Can be a React Component Class, a render function, or
* a rendered element.
*/
ListEmptyComponent?: ?(ReactClass<any> | React.Element<any>),
/** /**
* Rendered at the bottom of all the items. Can be a React Component Class, a render function, or * Rendered at the bottom of all the items. Can be a React Component Class, a render function, or
* a rendered element. * a rendered element.

View File

@ -87,11 +87,18 @@ type OptionalProps<SectionT: SectionBase<any>> = {
*/ */
ItemSeparatorComponent?: ?ReactClass<any>, ItemSeparatorComponent?: ?ReactClass<any>,
/** /**
* Rendered at the very beginning of the list. * Rendered at the very beginning of the list. Can be a React Component Class, a render function, or
* a rendered element.
*/ */
ListHeaderComponent?: ?(ReactClass<any> | React.Element<any>), ListHeaderComponent?: ?(ReactClass<any> | React.Element<any>),
/** /**
* Rendered at the very end of the list. * Rendered when the list is empty. Can be a React Component Class, a render function, or
* a rendered element.
*/
ListEmptyComponent?: ?(ReactClass<any> | React.Element<any>),
/**
* Rendered at the very end of the list. Can be a React Component Class, a render function, or
* a rendered element.
*/ */
ListFooterComponent?: ?(ReactClass<any> | React.Element<any>), ListFooterComponent?: ?(ReactClass<any> | React.Element<any>),
/** /**

View File

@ -82,6 +82,21 @@ type OptionalProps = {
*/ */
initialScrollIndex?: ?number, initialScrollIndex?: ?number,
keyExtractor: (item: Item, index: number) => string, keyExtractor: (item: Item, index: number) => string,
/**
* Rendered when the list is empty. Can be a React Component Class, a render function, or
* a rendered element.
*/
ListEmptyComponent?: ?(ReactClass<any> | React.Element<any>),
/**
* Rendered at the bottom of all the items. Can be a React Component Class, a render function, or
* a rendered element.
*/
ListFooterComponent?: ?(ReactClass<any> | React.Element<any>),
/**
* Rendered at the top of all the items. Can be a React Component Class, a render function, or
* a rendered element.
*/
ListHeaderComponent?: ?(ReactClass<any> | React.Element<any>),
/** /**
* The maximum number of items to render in each incremental render batch. The more rendered at * The maximum number of items to render in each incremental render batch. The more rendered at
* once, the better the fill rate, but responsiveness my suffer because rendering content may * once, the better the fill rate, but responsiveness my suffer because rendering content may
@ -394,14 +409,14 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
}; };
render() { render() {
const {ListFooterComponent, ListHeaderComponent} = this.props; const {ListEmptyComponent, ListFooterComponent, ListHeaderComponent} = this.props;
const {data, disableVirtualization, horizontal} = this.props; const {data, disableVirtualization, horizontal} = this.props;
const cells = []; const cells = [];
const stickyIndicesFromProps = new Set(this.props.stickyHeaderIndices); const stickyIndicesFromProps = new Set(this.props.stickyHeaderIndices);
const stickyHeaderIndices = []; const stickyHeaderIndices = [];
if (ListHeaderComponent) { if (ListHeaderComponent) {
const element = React.isValidElement(ListHeaderComponent) const element = React.isValidElement(ListHeaderComponent)
? ListHeaderComponent ? ListHeaderComponent // $FlowFixMe
: <ListHeaderComponent />; : <ListHeaderComponent />;
cells.push( cells.push(
<View key="$header" onLayout={this._onLayoutHeader}> <View key="$header" onLayout={this._onLayoutHeader}>
@ -476,10 +491,19 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
<View key="$tail_spacer" style={{[spacerKey]: tailSpacerLength}} /> <View key="$tail_spacer" style={{[spacerKey]: tailSpacerLength}} />
); );
} }
} else if (ListEmptyComponent) {
const element = React.isValidElement(ListEmptyComponent)
? ListEmptyComponent // $FlowFixMe
: <ListEmptyComponent />;
cells.push(
<View key="$empty" onLayout={this._onLayoutEmpty}>
{element}
</View>
);
} }
if (ListFooterComponent) { if (ListFooterComponent) {
const element = React.isValidElement(ListFooterComponent) const element = React.isValidElement(ListFooterComponent)
? ListFooterComponent ? ListFooterComponent // $FlowFixMe
: <ListFooterComponent />; : <ListFooterComponent />;
cells.push( cells.push(
<View key="$footer" onLayout={this._onLayoutFooter}> <View key="$footer" onLayout={this._onLayoutFooter}>
@ -585,6 +609,10 @@ class VirtualizedList extends React.PureComponent<OptionalProps, Props, State> {
this._maybeCallOnEndReached(); this._maybeCallOnEndReached();
}; };
_onLayoutEmpty = (e) => {
this.props.onLayout && this.props.onLayout(e);
};
_onLayoutFooter = (e) => { _onLayoutFooter = (e) => {
this._footerLength = this._selectLength(e.nativeEvent.layout); this._footerLength = this._selectLength(e.nativeEvent.layout);
}; };

View File

@ -48,6 +48,7 @@ describe('FlatList', () => {
const component = ReactTestRenderer.create( const component = ReactTestRenderer.create(
<FlatList <FlatList
ItemSeparatorComponent={() => <separator />} ItemSeparatorComponent={() => <separator />}
ListEmptyComponent={() => <empty />}
ListFooterComponent={() => <footer />} ListFooterComponent={() => <footer />}
ListHeaderComponent={() => <header />} ListHeaderComponent={() => <header />}
data={new Array(5).fill().map((_, ii) => ({id: String(ii)}))} data={new Array(5).fill().map((_, ii) => ({id: String(ii)}))}

View File

@ -40,6 +40,7 @@ describe('SectionList', () => {
const component = ReactTestRenderer.create( const component = ReactTestRenderer.create(
<SectionList <SectionList
ItemSeparatorComponent={(props) => <defaultItemSeparator v={propStr(props)} />} ItemSeparatorComponent={(props) => <defaultItemSeparator v={propStr(props)} />}
ListEmptyComponent={(props) => <empty v={propStr(props)} />}
ListFooterComponent={(props) => <footer v={propStr(props)} />} ListFooterComponent={(props) => <footer v={propStr(props)} />}
ListHeaderComponent={(props) => <header v={propStr(props)} />} ListHeaderComponent={(props) => <header v={propStr(props)} />}
SectionSeparatorComponent={(props) => <sectionSeparator v={propStr(props)} />} SectionSeparatorComponent={(props) => <sectionSeparator v={propStr(props)} />}

View File

@ -54,10 +54,39 @@ describe('VirtualizedList', () => {
expect(component).toMatchSnapshot(); expect(component).toMatchSnapshot();
}); });
it('renders empty list with empty component', () => {
const component = ReactTestRenderer.create(
<VirtualizedList
data={[]}
ListEmptyComponent={() => <empty />}
ListFooterComponent={() => <footer />}
ListHeaderComponent={() => <header />}
getItem={(data, index) => data[index]}
getItemCount={(data) => data.length}
renderItem={({item}) => <item value={item.key} />}
/>
);
expect(component).toMatchSnapshot();
});
it('renders list with empty component', () => {
const component = ReactTestRenderer.create(
<VirtualizedList
data={[{key: 'hello'}]}
ListEmptyComponent={() => <empty />}
getItem={(data, index) => data[index]}
getItemCount={(data) => data.length}
renderItem={({item}) => <item value={item.key} />}
/>
);
expect(component).toMatchSnapshot();
});
it('renders all the bells and whistles', () => { it('renders all the bells and whistles', () => {
const component = ReactTestRenderer.create( const component = ReactTestRenderer.create(
<VirtualizedList <VirtualizedList
ItemSeparatorComponent={() => <separator />} ItemSeparatorComponent={() => <separator />}
ListEmptyComponent={() => <empty />}
ListFooterComponent={() => <footer />} ListFooterComponent={() => <footer />}
ListHeaderComponent={() => <header />} ListHeaderComponent={() => <header />}
data={new Array(5).fill().map((_, ii) => ({id: String(ii)}))} data={new Array(5).fill().map((_, ii) => ({id: String(ii)}))}

View File

@ -3,6 +3,7 @@
exports[`FlatList renders all the bells and whistles 1`] = ` exports[`FlatList renders all the bells and whistles 1`] = `
<RCTScrollView <RCTScrollView
ItemSeparatorComponent={[Function]} ItemSeparatorComponent={[Function]}
ListEmptyComponent={[Function]}
ListFooterComponent={[Function]} ListFooterComponent={[Function]}
ListHeaderComponent={[Function]} ListHeaderComponent={[Function]}
data={ data={

View File

@ -86,6 +86,7 @@ exports[`SectionList rendering empty section headers is fine 1`] = `
exports[`SectionList renders all the bells and whistles 1`] = ` exports[`SectionList renders all the bells and whistles 1`] = `
<RCTScrollView <RCTScrollView
ItemSeparatorComponent={undefined} ItemSeparatorComponent={undefined}
ListEmptyComponent={[Function]}
ListFooterComponent={[Function]} ListFooterComponent={[Function]}
ListHeaderComponent={[Function]} ListHeaderComponent={[Function]}
SectionSeparatorComponent={[Function]} SectionSeparatorComponent={[Function]}

View File

@ -241,6 +241,7 @@ exports[`VirtualizedList handles separators correctly 3`] = `
exports[`VirtualizedList renders all the bells and whistles 1`] = ` exports[`VirtualizedList renders all the bells and whistles 1`] = `
<RCTScrollView <RCTScrollView
ItemSeparatorComponent={[Function]} ItemSeparatorComponent={[Function]}
ListEmptyComponent={[Function]}
ListFooterComponent={[Function]} ListFooterComponent={[Function]}
ListHeaderComponent={[Function]} ListHeaderComponent={[Function]}
data={ data={
@ -375,6 +376,96 @@ exports[`VirtualizedList renders empty list 1`] = `
</RCTScrollView> </RCTScrollView>
`; `;
exports[`VirtualizedList renders empty list with empty component 1`] = `
<RCTScrollView
ListEmptyComponent={[Function]}
ListFooterComponent={[Function]}
ListHeaderComponent={[Function]}
data={Array []}
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]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
windowSize={21}
>
<View>
<View
onLayout={[Function]}
>
<header />
</View>
<View
onLayout={[Function]}
>
<empty />
</View>
<View
onLayout={[Function]}
>
<footer />
</View>
</View>
</RCTScrollView>
`;
exports[`VirtualizedList renders list with empty component 1`] = `
<RCTScrollView
ListEmptyComponent={[Function]}
data={
Array [
Object {
"key": "hello",
},
]
}
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]}
renderScrollComponent={[Function]}
scrollEventThrottle={50}
stickyHeaderIndices={Array []}
updateCellsBatchingPeriod={50}
windowSize={21}
>
<View>
<View
onLayout={[Function]}
>
<item
value="hello"
/>
</View>
</View>
</RCTScrollView>
`;
exports[`VirtualizedList renders null list 1`] = ` exports[`VirtualizedList renders null list 1`] = `
<RCTScrollView <RCTScrollView
data={undefined} data={undefined}