Render section footer in <SectionList> sections with no data
Summary: Fixes https://github.com/facebook/react-native/issues/13784 The section footer was only rendered with the last item of the section. However, that meant in sections where no items were rendered, no section footer would be rendered. This patch makes sure that when there are no items the section footer is rendered with the section header in addition to adding tests asserting the existance of section footers in empty lists. One potential point of contention is whether or not a section separator (as defined by the `SectionSeparatorComponent` prop to `<SectionList>`) should be rendered in an empty list. I did not include a section separator for empty lists, but let me know if you think one should be included. See the test plan below for an image of an empty section rendered without a section separator. I was also running into a lint error, `no-alert`, in `SectionListExample.js` around line 135 that blocked me from publishing. This error looks to be triggered when the `alert()` global function is called, so to fix the error I added an import for the `Alert` module and called the `alert()` function on that module. To help debug the `scrollToLocation()` behavior that was modified as a part of this PR I added three buttons (can be seen in the test plan image) which scroll to arbitrary points in the list. Reviewed By: sahrens Differential Revision: D5084095 fbshipit-source-id: 4c98bebc1c3f1ceaa5a634fa144685d83d1072df
This commit is contained in:
parent
f91e376515
commit
f702cbecba
|
@ -145,7 +145,7 @@ class VirtualizedSectionList<SectionT: SectionBase>
|
|||
}) {
|
||||
let index = params.itemIndex + 1;
|
||||
for (let ii = 0; ii < params.sectionIndex; ii++) {
|
||||
index += this.props.sections[ii].data.length + 1;
|
||||
index += this.props.sections[ii].data.length + 2;
|
||||
}
|
||||
const toIndexParams = {
|
||||
...params,
|
||||
|
@ -169,6 +169,7 @@ class VirtualizedSectionList<SectionT: SectionBase>
|
|||
section: SectionT,
|
||||
key: string, // Key of the section or combined key for section + item
|
||||
index: ?number, // Relative index within the section
|
||||
header?: ?boolean, // True if this is the section header
|
||||
leadingItem?: ?Item,
|
||||
leadingSection?: ?SectionT,
|
||||
trailingItem?: ?Item,
|
||||
|
@ -179,11 +180,25 @@ class VirtualizedSectionList<SectionT: SectionBase>
|
|||
for (let ii = 0; ii < this.props.sections.length; ii++) {
|
||||
const section = this.props.sections[ii];
|
||||
const key = section.key || String(ii);
|
||||
itemIndex -= 1; // The section itself is an item
|
||||
if (itemIndex >= section.data.length) {
|
||||
itemIndex -= section.data.length;
|
||||
itemIndex -= 1; // The section adds an item for the header
|
||||
if (itemIndex >= section.data.length + 1) {
|
||||
itemIndex -= section.data.length + 1; // The section adds an item for the footer.
|
||||
} else if (itemIndex === -1) {
|
||||
return {section, key, index: null, trailingSection: this.props.sections[ii + 1]};
|
||||
return {
|
||||
section,
|
||||
key: key + ':header',
|
||||
index: null,
|
||||
header: true,
|
||||
trailingSection: this.props.sections[ii + 1],
|
||||
};
|
||||
} else if (itemIndex === section.data.length) {
|
||||
return {
|
||||
section,
|
||||
key: key + ':footer',
|
||||
index: null,
|
||||
header: false,
|
||||
trailingSection: this.props.sections[ii + 1],
|
||||
};
|
||||
} else {
|
||||
const keyExtractor = section.keyExtractor || defaultKeyExtractor;
|
||||
return {
|
||||
|
@ -232,8 +247,14 @@ class VirtualizedSectionList<SectionT: SectionBase>
|
|||
}
|
||||
const infoIndex = info.index;
|
||||
if (infoIndex == null) {
|
||||
const {renderSectionHeader} = this.props;
|
||||
return renderSectionHeader ? renderSectionHeader({section: info.section}) : null;
|
||||
const {section} = info;
|
||||
if (info.header === true) {
|
||||
const {renderSectionHeader} = this.props;
|
||||
return renderSectionHeader ? renderSectionHeader({section}) : null;
|
||||
} else {
|
||||
const {renderSectionFooter} = this.props;
|
||||
return renderSectionFooter ? renderSectionFooter({section}) : null;
|
||||
}
|
||||
} else {
|
||||
const renderItem = info.section.renderItem || this.props.renderItem;
|
||||
const SeparatorComponent = this._getSeparatorComponent(index, info);
|
||||
|
@ -254,10 +275,6 @@ class VirtualizedSectionList<SectionT: SectionBase>
|
|||
prevCellKey={(this._subExtractor(index - 1) || {}).key}
|
||||
ref={(ref) => {this._cellRefs[info.key] = ref;}}
|
||||
renderItem={renderItem}
|
||||
renderSectionFooter={infoIndex === info.section.data.length - 1
|
||||
? this.props.renderSectionFooter
|
||||
: undefined
|
||||
}
|
||||
section={info.section}
|
||||
trailingItem={info.trailingItem}
|
||||
trailingSection={info.trailingSection}
|
||||
|
@ -296,7 +313,7 @@ class VirtualizedSectionList<SectionT: SectionBase>
|
|||
const itemCount = props.sections.reduce(
|
||||
(v, section) => {
|
||||
stickyHeaderIndices.push(v + offset);
|
||||
return v + section.data.length + 1;
|
||||
return v + section.data.length + 2; // Add two for the section header and footer.
|
||||
},
|
||||
0
|
||||
);
|
||||
|
@ -345,7 +362,6 @@ class ItemWithSeparator extends React.Component {
|
|||
onUpdateSeparator: (cellKey: string, newProps: Object) => void,
|
||||
prevCellKey?: ?string,
|
||||
renderItem: Function,
|
||||
renderSectionFooter: ?Function,
|
||||
section: Object,
|
||||
leadingItem: ?Item,
|
||||
leadingSection: ?Object,
|
||||
|
@ -406,9 +422,8 @@ class ItemWithSeparator extends React.Component {
|
|||
const leadingSeparator = LeadingSeparatorComponent &&
|
||||
<LeadingSeparatorComponent {...this.state.leadingSeparatorProps} />;
|
||||
const separator = SeparatorComponent && <SeparatorComponent {...this.state.separatorProps} />;
|
||||
const footer = this.props.renderSectionFooter && this.props.renderSectionFooter({section});
|
||||
return (leadingSeparator || separator || footer)
|
||||
? <View>{leadingSeparator}{element}{separator}{footer}</View>
|
||||
return (leadingSeparator || separator)
|
||||
? <View>{leadingSeparator}{element}{separator}</View>
|
||||
: element;
|
||||
}
|
||||
}
|
||||
|
@ -419,12 +434,16 @@ function getItem(sections: ?$ReadOnlyArray<Item>, index: number): ?Item {
|
|||
}
|
||||
let itemIdx = index - 1;
|
||||
for (let ii = 0; ii < sections.length; ii++) {
|
||||
if (itemIdx === -1) {
|
||||
return sections[ii]; // The section itself is the item
|
||||
if (itemIdx === -1 || itemIdx === sections[ii].data.length) {
|
||||
// We intend for there to be overflow by one on both ends of the list.
|
||||
// This will be for headers and footers. When returning a header or footer
|
||||
// item the section itself is the item.
|
||||
return sections[ii];
|
||||
} else if (itemIdx < sections[ii].data.length) {
|
||||
// If we are in the bounds of the list's data then return the item.
|
||||
return sections[ii].data[itemIdx];
|
||||
} else {
|
||||
itemIdx -= (sections[ii].data.length + 1);
|
||||
itemIdx -= (sections[ii].data.length + 2); // Add two for the header and footer
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
|
|
@ -39,6 +39,7 @@ describe('SectionList', () => {
|
|||
it('renders all the bells and whistles', () => {
|
||||
const component = ReactTestRenderer.create(
|
||||
<SectionList
|
||||
initialNumToRender={Infinity}
|
||||
ItemSeparatorComponent={(props) => <defaultItemSeparator v={propStr(props)} />}
|
||||
ListEmptyComponent={(props) => <empty v={propStr(props)} />}
|
||||
ListFooterComponent={(props) => <footer v={propStr(props)} />}
|
||||
|
@ -70,6 +71,27 @@ describe('SectionList', () => {
|
|||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
it('renders a footer when there is no data', () => {
|
||||
const component = ReactTestRenderer.create(
|
||||
<SectionList
|
||||
sections={[{key: 's1', data: []}]}
|
||||
renderItem={({item}) => <item v={item.key} />}
|
||||
renderSectionHeader={(props) => <sectionHeader v={propStr(props)} />}
|
||||
renderSectionFooter={(props) => <sectionFooter v={propStr(props)} />}
|
||||
/>
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
it('renders a footer when there is no data and no header', () => {
|
||||
const component = ReactTestRenderer.create(
|
||||
<SectionList
|
||||
sections={[{key: 's1', data: []}]}
|
||||
renderItem={({item}) => <item v={item.key} />}
|
||||
renderSectionFooter={(props) => <sectionFooter v={propStr(props)} />}
|
||||
/>
|
||||
);
|
||||
expect(component).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
function propStr(props) {
|
||||
|
|
|
@ -79,6 +79,138 @@ exports[`SectionList rendering empty section headers is fine 1`] = `
|
|||
v="i2"
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
onLayout={[Function]}
|
||||
/>
|
||||
</View>
|
||||
</RCTScrollView>
|
||||
`;
|
||||
|
||||
exports[`SectionList renders a footer when there is no data 1`] = `
|
||||
<RCTScrollView
|
||||
ItemSeparatorComponent={undefined}
|
||||
data={
|
||||
Array [
|
||||
Object {
|
||||
"data": Array [],
|
||||
"key": "s1",
|
||||
},
|
||||
]
|
||||
}
|
||||
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]}
|
||||
onViewableItemsChanged={undefined}
|
||||
renderItem={[Function]}
|
||||
renderScrollComponent={[Function]}
|
||||
renderSectionFooter={[Function]}
|
||||
renderSectionHeader={[Function]}
|
||||
scrollEventThrottle={50}
|
||||
sections={
|
||||
Array [
|
||||
Object {
|
||||
"data": Array [],
|
||||
"key": "s1",
|
||||
},
|
||||
]
|
||||
}
|
||||
stickyHeaderIndices={
|
||||
Array [
|
||||
0,
|
||||
]
|
||||
}
|
||||
stickySectionHeadersEnabled={true}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={21}
|
||||
>
|
||||
<View>
|
||||
<View
|
||||
onLayout={[Function]}
|
||||
>
|
||||
<sectionHeader
|
||||
v="section:s1"
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
onLayout={[Function]}
|
||||
>
|
||||
<sectionFooter
|
||||
v="section:s1"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</RCTScrollView>
|
||||
`;
|
||||
|
||||
exports[`SectionList renders a footer when there is no data and no header 1`] = `
|
||||
<RCTScrollView
|
||||
ItemSeparatorComponent={undefined}
|
||||
data={
|
||||
Array [
|
||||
Object {
|
||||
"data": Array [],
|
||||
"key": "s1",
|
||||
},
|
||||
]
|
||||
}
|
||||
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]}
|
||||
onViewableItemsChanged={undefined}
|
||||
renderItem={[Function]}
|
||||
renderScrollComponent={[Function]}
|
||||
renderSectionFooter={[Function]}
|
||||
scrollEventThrottle={50}
|
||||
sections={
|
||||
Array [
|
||||
Object {
|
||||
"data": Array [],
|
||||
"key": "s1",
|
||||
},
|
||||
]
|
||||
}
|
||||
stickyHeaderIndices={
|
||||
Array [
|
||||
0,
|
||||
]
|
||||
}
|
||||
stickySectionHeadersEnabled={true}
|
||||
updateCellsBatchingPeriod={50}
|
||||
windowSize={21}
|
||||
>
|
||||
<View>
|
||||
<View
|
||||
onLayout={[Function]}
|
||||
/>
|
||||
<View
|
||||
onLayout={[Function]}
|
||||
>
|
||||
<sectionFooter
|
||||
v="section:s1"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</RCTScrollView>
|
||||
`;
|
||||
|
@ -134,7 +266,7 @@ exports[`SectionList renders all the bells and whistles 1`] = `
|
|||
getItem={[Function]}
|
||||
getItemCount={[Function]}
|
||||
horizontal={false}
|
||||
initialNumToRender={10}
|
||||
initialNumToRender={Infinity}
|
||||
keyExtractor={[Function]}
|
||||
maxToRenderPerBatch={10}
|
||||
onContentSizeChange={[Function]}
|
||||
|
@ -201,8 +333,8 @@ exports[`SectionList renders all the bells and whistles 1`] = `
|
|||
stickyHeaderIndices={
|
||||
Array [
|
||||
1,
|
||||
4,
|
||||
7,
|
||||
5,
|
||||
9,
|
||||
]
|
||||
}
|
||||
stickySectionHeadersEnabled={true}
|
||||
|
@ -250,11 +382,15 @@ exports[`SectionList renders all the bells and whistles 1`] = `
|
|||
<sectionSeparator
|
||||
v="highlighted:false,leadingItem:i2s1,leadingSection:undefined,section:s1,trailingItem:undefined,trailingSection:s2"
|
||||
/>
|
||||
<sectionFooter
|
||||
v="section:s1"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
onLayout={[Function]}
|
||||
>
|
||||
<sectionFooter
|
||||
v="section:s1"
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
onLayout={[Function]}
|
||||
>
|
||||
|
@ -287,11 +423,15 @@ exports[`SectionList renders all the bells and whistles 1`] = `
|
|||
<sectionSeparator
|
||||
v="highlighted:false,leadingItem:i2s2,leadingSection:s1,section:s2,trailingItem:undefined,trailingSection:s3"
|
||||
/>
|
||||
<sectionFooter
|
||||
v="section:s2"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
onLayout={[Function]}
|
||||
>
|
||||
<sectionFooter
|
||||
v="section:s2"
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
onLayout={[Function]}
|
||||
>
|
||||
|
@ -324,11 +464,15 @@ exports[`SectionList renders all the bells and whistles 1`] = `
|
|||
<sectionSeparator
|
||||
v="highlighted:false,leadingItem:i2s3,leadingSection:s2,section:s3,trailingItem:undefined,trailingSection:undefined"
|
||||
/>
|
||||
<sectionFooter
|
||||
v="section:s3"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View
|
||||
onLayout={[Function]}
|
||||
>
|
||||
<sectionFooter
|
||||
v="section:s3"
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
onLayout={[Function]}
|
||||
>
|
||||
|
|
|
@ -14,7 +14,9 @@
|
|||
const React = require('react');
|
||||
const ReactNative = require('react-native');
|
||||
const {
|
||||
Alert,
|
||||
Animated,
|
||||
Button,
|
||||
SectionList,
|
||||
StyleSheet,
|
||||
Text,
|
||||
|
@ -84,6 +86,15 @@ class SectionListExample extends React.PureComponent {
|
|||
{useNativeDriver: true},
|
||||
);
|
||||
|
||||
_sectionListRef: any;
|
||||
_captureRef = (ref) => { this._sectionListRef = ref; };
|
||||
|
||||
_scrollToLocation(sectionIndex: number, itemIndex: number) {
|
||||
this._sectionListRef
|
||||
.getNode()
|
||||
.scrollToLocation({ sectionIndex, itemIndex });
|
||||
}
|
||||
|
||||
render() {
|
||||
const filterRegex = new RegExp(String(this.state.filterText), 'i');
|
||||
const filter = (item) => (
|
||||
|
@ -118,9 +129,16 @@ class SectionListExample extends React.PureComponent {
|
|||
{renderSmallSwitchOption(this, 'debug')}
|
||||
<Spindicator value={this._scrollPos} />
|
||||
</View>
|
||||
<View style={styles.scrollToRow}>
|
||||
<Text>scroll to:</Text>
|
||||
<Button title="Item A" onPress={() => this._scrollToLocation(2, 1)}/>
|
||||
<Button title="Item B" onPress={() => this._scrollToLocation(3, 6)}/>
|
||||
<Button title="Item C" onPress={() => this._scrollToLocation(6, 3)}/>
|
||||
</View>
|
||||
</View>
|
||||
<SeparatorComponent />
|
||||
<AnimatedSectionList
|
||||
ref={this._captureRef}
|
||||
ListHeaderComponent={HeaderComponent}
|
||||
ListFooterComponent={FooterComponent}
|
||||
SectionSeparatorComponent={(info) =>
|
||||
|
@ -131,7 +149,7 @@ class SectionListExample extends React.PureComponent {
|
|||
}
|
||||
debug={this.state.debug}
|
||||
enableVirtualization={this.state.virtualized}
|
||||
onRefresh={() => alert('onRefresh: nothing to refresh :P')}
|
||||
onRefresh={() => Alert.alert('onRefresh: nothing to refresh :P')}
|
||||
onScroll={this._scrollSinkY}
|
||||
onViewableItemsChanged={this._onViewableItemsChanged}
|
||||
refreshing={false}
|
||||
|
@ -140,6 +158,10 @@ class SectionListExample extends React.PureComponent {
|
|||
renderSectionFooter={renderSectionFooter}
|
||||
stickySectionHeadersEnabled
|
||||
sections={[
|
||||
{
|
||||
key: 'empty section',
|
||||
data: [],
|
||||
},
|
||||
{
|
||||
renderItem: renderStackedItem,
|
||||
key: 's1',
|
||||
|
@ -216,6 +238,11 @@ const styles = StyleSheet.create({
|
|||
searchRow: {
|
||||
paddingHorizontal: 10,
|
||||
},
|
||||
scrollToRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
separatorText: {
|
||||
color: 'gray',
|
||||
alignSelf: 'center',
|
||||
|
|
Loading…
Reference in New Issue