Skip to content

Commit 72670bf

Browse files
sahrensfacebook-github-bot
authored andcommitted
support sticky headers
Summary: This adds support for both automagical sticky section headers in `SectionList` as well as the more free-form `stickyHeaderIndices` on `FlatList` or `VirtualizedList`. The basic concept is to take the initial `stickySectionHeaders` and remap them to the indices corresponding to the mounted subset in the render window. The main trick here is that the currently stuck header might itself be outside of the render window, so we need to search the gap to see if that's the case and render it (with spacers above and below it instead of one big spacer). In the `SectionList` we simply pre-compute the sticky headers at the same time as when we scan the sections to determine the flattened length and pass those to `VirtualizedList`. This also requires some updates to `ScrollView` to work in the churny environment of `VirtualizedList`. We propogate the keys on the children to the animated wrappers so that as items are removed and the indices of the remaining items change, react can keep proper track of them. We also fix the scroll back case where new headers are rendered from the top down and aren't updated with the `setNextLayoutY` callback because the `onLayout` call for the next header happened before it was mounted. This is done by just tracking all the layout values in a map and providing them to the sticky components at render time. This might also improve perf a little by property configuring the animations syncronously instead of waiting for the `onLayout` callback. We also need to protect against stale onLayout callbacks and other fun stuff. == Test Plan == https://www.facebook.com/groups/react.native.community/permalink/940332509435661/ Scroll a lot with and without debug mode on. Make sure spinner still spins and there are no crashes (lots of crashes during development due to the animated configuration being non-monotonic if anything stale values get through). Also made sure that tapping a row to change it's height would properly update the animation configurations so the collision point would still be correct. Reviewed By: yungsters Differential Revision: D4695065 fbshipit-source-id: 855c4e31c8f8b450d32150dbdb2e07f1a9f9f98e
1 parent 7861fdd commit 72670bf

File tree

8 files changed

+204
-81
lines changed

8 files changed

+204
-81
lines changed

Examples/UIExplorer/js/SectionListExample.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ class SectionListExample extends React.PureComponent {
7979

8080
state = {
8181
data: genItemData(1000),
82+
debug: false,
8283
filterText: '',
8384
logViewable: false,
8485
virtualized: true,
@@ -96,6 +97,16 @@ class SectionListExample extends React.PureComponent {
9697
filterRegex.test(item.text) || filterRegex.test(item.title)
9798
);
9899
const filteredData = this.state.data.filter(filter);
100+
const filteredSectionData = [];
101+
let startIndex = 0;
102+
const endIndex = filteredData.length - 1;
103+
for (let ii = 10; ii <= endIndex + 10; ii += 10) {
104+
filteredSectionData.push({
105+
key: `${filteredData[startIndex].key} - ${filteredData[Math.min(ii - 1, endIndex)].key}`,
106+
data: filteredData.slice(startIndex, ii),
107+
});
108+
startIndex = ii;
109+
}
99110
return (
100111
<UIExplorerPage
101112
noSpacer={true}
@@ -111,6 +122,7 @@ class SectionListExample extends React.PureComponent {
111122
<View style={styles.optionSection}>
112123
{renderSmallSwitchOption(this, 'virtualized')}
113124
{renderSmallSwitchOption(this, 'logViewable')}
125+
{renderSmallSwitchOption(this, 'debug')}
114126
<Spindicator value={this._scrollPos} />
115127
</View>
116128
</View>
@@ -124,6 +136,7 @@ class SectionListExample extends React.PureComponent {
124136
ItemSeparatorComponent={() =>
125137
<CustomSeparatorComponent text="ITEM SEPARATOR" />
126138
}
139+
debug={this.state.debug}
127140
enableVirtualization={this.state.virtualized}
128141
onRefresh={() => alert('onRefresh: nothing to refresh :P')}
129142
onScroll={this._scrollSinkY}
@@ -139,7 +152,7 @@ class SectionListExample extends React.PureComponent {
139152
{noImage: true, title: '1st item', text: 'Section s2', key: '0'},
140153
{noImage: true, title: '2nd item', text: 'Section s2', key: '1'},
141154
]},
142-
{key: 'Filtered Items', data: filteredData},
155+
...filteredSectionData,
143156
]}
144157
viewabilityConfig={VIEWABILITY_CONFIG}
145158
/>

Libraries/Components/ScrollView/ScrollView.js

Lines changed: 55 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -383,14 +383,15 @@ const ScrollView = React.createClass({
383383
_scrollAnimatedValue: (new Animated.Value(0): Animated.Value),
384384
_scrollAnimatedValueAttachment: (null: ?{detach: () => void}),
385385
_stickyHeaderRefs: (new Map(): Map<number, ScrollViewStickyHeader>),
386-
386+
_headerLayoutYs: (new Map(): Map<string, number>),
387387
getInitialState: function() {
388388
return this.scrollResponderMixinGetInitialState();
389389
},
390390

391391
componentWillMount: function() {
392392
this._scrollAnimatedValue = new Animated.Value(0);
393393
this._stickyHeaderRefs = new Map();
394+
this._headerLayoutYs = new Map();
394395
},
395396

396397
componentDidMount: function() {
@@ -482,6 +483,11 @@ const ScrollView = React.createClass({
482483
this.scrollTo({x, y, animated: false});
483484
},
484485

486+
_getKeyForIndex: function(index, childArray) {
487+
const child = childArray[index];
488+
return child && child.key;
489+
},
490+
485491
_updateAnimatedNodeAttachment: function() {
486492
if (this.props.stickyHeaderIndices && this.props.stickyHeaderIndices.length > 0) {
487493
if (!this._scrollAnimatedValueAttachment) {
@@ -498,21 +504,34 @@ const ScrollView = React.createClass({
498504
}
499505
},
500506

501-
_setStickyHeaderRef: function(index, ref) {
502-
this._stickyHeaderRefs.set(index, ref);
507+
_setStickyHeaderRef: function(key, ref) {
508+
if (ref) {
509+
this._stickyHeaderRefs.set(key, ref);
510+
} else {
511+
this._stickyHeaderRefs.delete(key);
512+
}
503513
},
504514

505-
_onStickyHeaderLayout: function(index, event) {
515+
_onStickyHeaderLayout: function(index, event, key) {
506516
if (!this.props.stickyHeaderIndices) {
507517
return;
508518
}
519+
const childArray = React.Children.toArray(this.props.children);
520+
if (key !== this._getKeyForIndex(index, childArray)) {
521+
// ignore stale layout update
522+
return;
523+
}
509524

510-
const previousHeaderIndex = this.props.stickyHeaderIndices[
511-
this.props.stickyHeaderIndices.indexOf(index) - 1
512-
];
525+
const layoutY = event.nativeEvent.layout.y;
526+
this._headerLayoutYs.set(key, layoutY);
527+
528+
const indexOfIndex = this.props.stickyHeaderIndices.indexOf(index);
529+
const previousHeaderIndex = this.props.stickyHeaderIndices[indexOfIndex - 1];
513530
if (previousHeaderIndex != null) {
514-
const previousHeader = this._stickyHeaderRefs.get(previousHeaderIndex);
515-
previousHeader && previousHeader.setNextHeaderY(event.nativeEvent.layout.y);
531+
const previousHeader = this._stickyHeaderRefs.get(
532+
this._getKeyForIndex(previousHeaderIndex, childArray)
533+
);
534+
previousHeader && previousHeader.setNextHeaderY(layoutY);
516535
}
517536
},
518537

@@ -599,27 +618,33 @@ const ScrollView = React.createClass({
599618
};
600619
}
601620

602-
const {stickyHeaderIndices} = this.props;
603-
const hasStickyHeaders = stickyHeaderIndices && stickyHeaderIndices.length > 0;
604-
const children = stickyHeaderIndices && hasStickyHeaders ?
605-
React.Children.toArray(this.props.children).map((child, index) => {
606-
const stickyHeaderIndex = stickyHeaderIndices.indexOf(index);
607-
if (child && stickyHeaderIndex >= 0) {
608-
return (
609-
<ScrollViewStickyHeader
610-
key={index}
611-
ref={(ref) => this._setStickyHeaderRef(index, ref)}
612-
onLayout={(event) => this._onStickyHeaderLayout(index, event)}
613-
scrollAnimatedValue={this._scrollAnimatedValue}>
614-
{child}
615-
</ScrollViewStickyHeader>
616-
);
617-
} else {
618-
return child;
619-
}
620-
}) :
621-
this.props.children;
622-
const contentContainer =
621+
const {stickyHeaderIndices} = this.props;
622+
const hasStickyHeaders = stickyHeaderIndices && stickyHeaderIndices.length > 0;
623+
const childArray = hasStickyHeaders && React.Children.toArray(this.props.children);
624+
const children = hasStickyHeaders ?
625+
childArray.map((child, index) => {
626+
const indexOfIndex = child ? stickyHeaderIndices.indexOf(index) : -1;
627+
if (indexOfIndex > -1) {
628+
const key = child.key;
629+
const nextIndex = stickyHeaderIndices[indexOfIndex + 1];
630+
return (
631+
<ScrollViewStickyHeader
632+
key={key}
633+
ref={(ref) => this._setStickyHeaderRef(key, ref)}
634+
nextHeaderLayoutY={
635+
this._headerLayoutYs.get(this._getKeyForIndex(nextIndex, childArray))
636+
}
637+
onLayout={(event) => this._onStickyHeaderLayout(index, event, key)}
638+
scrollAnimatedValue={this._scrollAnimatedValue}>
639+
{child}
640+
</ScrollViewStickyHeader>
641+
);
642+
} else {
643+
return child;
644+
}
645+
}) :
646+
this.props.children;
647+
const contentContainer =
623648
<ScrollContentContainerViewClass
624649
{...contentSizeChangeProps}
625650
ref={this._setInnerViewRef}

Libraries/Components/ScrollView/ScrollViewStickyHeader.js

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,30 @@ const StyleSheet = require('StyleSheet');
1717

1818
type Props = {
1919
children?: React.Element<*>,
20-
scrollAnimatedValue: Animated.Value,
20+
nextHeaderLayoutY: ?number,
2121
onLayout: (event: Object) => void,
22+
scrollAnimatedValue: Animated.Value,
2223
};
2324

2425
class ScrollViewStickyHeader extends React.Component {
2526
props: Props;
26-
state = {
27-
measured: false,
28-
layoutY: 0,
29-
layoutHeight: 0,
30-
nextHeaderLayoutY: (null: ?number),
27+
state: {
28+
measured: boolean,
29+
layoutY: number,
30+
layoutHeight: number,
31+
nextHeaderLayoutY: ?number,
3132
};
3233

34+
constructor(props: Props, context: Object) {
35+
super(props, context);
36+
this.state = {
37+
measured: false,
38+
layoutY: 0,
39+
layoutHeight: 0,
40+
nextHeaderLayoutY: props.nextHeaderLayoutY,
41+
};
42+
}
43+
3344
setNextHeaderY(y: number) {
3445
this.setState({ nextHeaderLayoutY: y });
3546
}
@@ -65,8 +76,10 @@ class ScrollViewStickyHeader extends React.Component {
6576
// scroll indefinetly.
6677
const inputRange = [-1, 0, layoutY];
6778
const outputRange: Array<number> = [0, 0, 0];
68-
if (nextHeaderLayoutY != null) {
69-
const collisionPoint = nextHeaderLayoutY - layoutHeight;
79+
// Sometimes headers jump around so we make sure we don't violate the monotonic inputRange
80+
// condition.
81+
const collisionPoint = (nextHeaderLayoutY || 0) - layoutHeight;
82+
if (collisionPoint >= layoutY) {
7083
inputRange.push(collisionPoint, collisionPoint + 1);
7184
outputRange.push(collisionPoint - layoutY, collisionPoint - layoutY);
7285
} else {

Libraries/CustomComponents/Lists/SectionList.js

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
'use strict';
3434

3535
const MetroListView = require('MetroListView');
36+
const Platform = require('Platform');
3637
const React = require('React');
3738
const VirtualizedSectionList = require('VirtualizedSectionList');
3839

@@ -52,9 +53,7 @@ type SectionBase<SectionItemT> = {
5253
keyExtractor?: (item: SectionItemT) => string,
5354

5455
// TODO: support more optional/override props
55-
// FooterComponent?: ?ReactClass<*>,
56-
// HeaderComponent?: ?ReactClass<*>,
57-
// onViewableItemsChanged?: ({viewableItems: Array<ViewToken>, changed: Array<ViewToken>}) => void,
56+
// onViewableItemsChanged?: ...
5857
};
5958

6059
type RequiredProps<SectionT: SectionBase<any>> = {
@@ -102,7 +101,10 @@ type OptionalProps<SectionT: SectionBase<any>> = {
102101
* Called when the viewability of rows changes, as defined by the
103102
* `viewabilityConfig` prop.
104103
*/
105-
onViewableItemsChanged?: ?(info: {viewableItems: Array<ViewToken>, changed: Array<ViewToken>}) => void,
104+
onViewableItemsChanged?: ?(info: {
105+
viewableItems: Array<ViewToken>,
106+
changed: Array<ViewToken>,
107+
}) => void,
106108
/**
107109
* Set this true while waiting for new data from a refresh.
108110
*/
@@ -114,13 +116,23 @@ type OptionalProps<SectionT: SectionBase<any>> = {
114116
prevProps: {item: Item, index: number},
115117
nextProps: {item: Item, index: number}
116118
) => boolean,
119+
/**
120+
* Makes section headers stick to the top of the screen until the next one pushes it off. Only
121+
* enabled by default on iOS because that is the platform standard there.
122+
*/
123+
stickySectionHeadersEnabled?: boolean,
117124
};
118125

119126
type Props<SectionT> = RequiredProps<SectionT>
120127
& OptionalProps<SectionT>
121128
& VirtualizedSectionListProps<SectionT>;
122129

123-
type DefaultProps = typeof VirtualizedSectionList.defaultProps;
130+
const defaultProps = {
131+
...VirtualizedSectionList.defaultProps,
132+
stickySectionHeadersEnabled: Platform.OS === 'ios',
133+
};
134+
135+
type DefaultProps = typeof defaultProps;
124136

125137
/**
126138
* A performant interface for rendering sectioned lists, supporting the most handy features:
@@ -136,7 +148,8 @@ type DefaultProps = typeof VirtualizedSectionList.defaultProps;
136148
* - Pull to Refresh.
137149
* - Scroll loading.
138150
*
139-
* If you don't need section support and want a simpler interface, use [`<FlatList>`](/react-native/docs/flatlist.html).
151+
* If you don't need section support and want a simpler interface, use
152+
* [`<FlatList>`](/react-native/docs/flatlist.html).
140153
*
141154
* If you need _sticky_ section header support, use `ListView` for now.
142155
*
@@ -180,7 +193,7 @@ class SectionList<SectionT: SectionBase<any>>
180193
extends React.PureComponent<DefaultProps, Props<SectionT>, void>
181194
{
182195
props: Props<SectionT>;
183-
static defaultProps: DefaultProps = VirtualizedSectionList.defaultProps;
196+
static defaultProps: DefaultProps = defaultProps;
184197

185198
render() {
186199
const List = this.props.legacyImplementation ? MetroListView : VirtualizedSectionList;

0 commit comments

Comments
 (0)