Permalink
Browse files

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
  • Loading branch information...
sahrens authored and facebook-github-bot committed Mar 22, 2017
1 parent 7861fdd commit 72670bf8d2b1556c2e8e5381477b652a1deaeda2
@@ -79,6 +79,7 @@ class SectionListExample extends React.PureComponent {
state = {
data: genItemData(1000),
debug: false,
filterText: '',
logViewable: false,
virtualized: true,
@@ -96,6 +97,16 @@ class SectionListExample extends React.PureComponent {
filterRegex.test(item.text) || filterRegex.test(item.title)
);
const filteredData = this.state.data.filter(filter);
const filteredSectionData = [];
let startIndex = 0;
const endIndex = filteredData.length - 1;
for (let ii = 10; ii <= endIndex + 10; ii += 10) {
filteredSectionData.push({
key: `${filteredData[startIndex].key} - ${filteredData[Math.min(ii - 1, endIndex)].key}`,
data: filteredData.slice(startIndex, ii),
});
startIndex = ii;
}
return (
<UIExplorerPage
noSpacer={true}
@@ -111,6 +122,7 @@ class SectionListExample extends React.PureComponent {
<View style={styles.optionSection}>
{renderSmallSwitchOption(this, 'virtualized')}
{renderSmallSwitchOption(this, 'logViewable')}
{renderSmallSwitchOption(this, 'debug')}
<Spindicator value={this._scrollPos} />
</View>
</View>
@@ -124,6 +136,7 @@ class SectionListExample extends React.PureComponent {
ItemSeparatorComponent={() =>
<CustomSeparatorComponent text="ITEM SEPARATOR" />
}
debug={this.state.debug}
enableVirtualization={this.state.virtualized}
onRefresh={() => alert('onRefresh: nothing to refresh :P')}
onScroll={this._scrollSinkY}
@@ -139,7 +152,7 @@ class SectionListExample extends React.PureComponent {
{noImage: true, title: '1st item', text: 'Section s2', key: '0'},
{noImage: true, title: '2nd item', text: 'Section s2', key: '1'},
]},
{key: 'Filtered Items', data: filteredData},
...filteredSectionData,
]}
viewabilityConfig={VIEWABILITY_CONFIG}
/>
@@ -383,14 +383,15 @@ const ScrollView = React.createClass({
_scrollAnimatedValue: (new Animated.Value(0): Animated.Value),
_scrollAnimatedValueAttachment: (null: ?{detach: () => void}),
_stickyHeaderRefs: (new Map(): Map<number, ScrollViewStickyHeader>),
_headerLayoutYs: (new Map(): Map<string, number>),
getInitialState: function() {
return this.scrollResponderMixinGetInitialState();
},
componentWillMount: function() {
this._scrollAnimatedValue = new Animated.Value(0);
this._stickyHeaderRefs = new Map();
this._headerLayoutYs = new Map();
},
componentDidMount: function() {
@@ -482,6 +483,11 @@ const ScrollView = React.createClass({
this.scrollTo({x, y, animated: false});
},
_getKeyForIndex: function(index, childArray) {
const child = childArray[index];
return child && child.key;
},
_updateAnimatedNodeAttachment: function() {
if (this.props.stickyHeaderIndices && this.props.stickyHeaderIndices.length > 0) {
if (!this._scrollAnimatedValueAttachment) {
@@ -498,21 +504,34 @@ const ScrollView = React.createClass({
}
},
_setStickyHeaderRef: function(index, ref) {
this._stickyHeaderRefs.set(index, ref);
_setStickyHeaderRef: function(key, ref) {
if (ref) {
this._stickyHeaderRefs.set(key, ref);
} else {
this._stickyHeaderRefs.delete(key);
}
},
_onStickyHeaderLayout: function(index, event) {
_onStickyHeaderLayout: function(index, event, key) {
if (!this.props.stickyHeaderIndices) {
return;
}
const childArray = React.Children.toArray(this.props.children);
if (key !== this._getKeyForIndex(index, childArray)) {
// ignore stale layout update
return;
}
const previousHeaderIndex = this.props.stickyHeaderIndices[
this.props.stickyHeaderIndices.indexOf(index) - 1
];
const layoutY = event.nativeEvent.layout.y;
this._headerLayoutYs.set(key, layoutY);
const indexOfIndex = this.props.stickyHeaderIndices.indexOf(index);
const previousHeaderIndex = this.props.stickyHeaderIndices[indexOfIndex - 1];
if (previousHeaderIndex != null) {
const previousHeader = this._stickyHeaderRefs.get(previousHeaderIndex);
previousHeader && previousHeader.setNextHeaderY(event.nativeEvent.layout.y);
const previousHeader = this._stickyHeaderRefs.get(
this._getKeyForIndex(previousHeaderIndex, childArray)
);
previousHeader && previousHeader.setNextHeaderY(layoutY);
}
},
@@ -599,27 +618,33 @@ const ScrollView = React.createClass({
};
}
const {stickyHeaderIndices} = this.props;
const hasStickyHeaders = stickyHeaderIndices && stickyHeaderIndices.length > 0;
const children = stickyHeaderIndices && hasStickyHeaders ?
React.Children.toArray(this.props.children).map((child, index) => {
const stickyHeaderIndex = stickyHeaderIndices.indexOf(index);
if (child && stickyHeaderIndex >= 0) {
return (
<ScrollViewStickyHeader
key={index}
ref={(ref) => this._setStickyHeaderRef(index, ref)}
onLayout={(event) => this._onStickyHeaderLayout(index, event)}
scrollAnimatedValue={this._scrollAnimatedValue}>
{child}
</ScrollViewStickyHeader>
);
} else {
return child;
}
}) :
this.props.children;
const contentContainer =
const {stickyHeaderIndices} = this.props;
const hasStickyHeaders = stickyHeaderIndices && stickyHeaderIndices.length > 0;
const childArray = hasStickyHeaders && React.Children.toArray(this.props.children);
const children = hasStickyHeaders ?
childArray.map((child, index) => {
const indexOfIndex = child ? stickyHeaderIndices.indexOf(index) : -1;
if (indexOfIndex > -1) {
const key = child.key;
const nextIndex = stickyHeaderIndices[indexOfIndex + 1];
return (
<ScrollViewStickyHeader
key={key}
ref={(ref) => this._setStickyHeaderRef(key, ref)}
nextHeaderLayoutY={
this._headerLayoutYs.get(this._getKeyForIndex(nextIndex, childArray))
}
onLayout={(event) => this._onStickyHeaderLayout(index, event, key)}
scrollAnimatedValue={this._scrollAnimatedValue}>
{child}
</ScrollViewStickyHeader>
);
} else {
return child;
}
}) :
this.props.children;
const contentContainer =
<ScrollContentContainerViewClass
{...contentSizeChangeProps}
ref={this._setInnerViewRef}
@@ -17,19 +17,30 @@ const StyleSheet = require('StyleSheet');
type Props = {
children?: React.Element<*>,
scrollAnimatedValue: Animated.Value,
nextHeaderLayoutY: ?number,
onLayout: (event: Object) => void,
scrollAnimatedValue: Animated.Value,
};
class ScrollViewStickyHeader extends React.Component {
props: Props;
state = {
measured: false,
layoutY: 0,
layoutHeight: 0,
nextHeaderLayoutY: (null: ?number),
state: {
measured: boolean,
layoutY: number,
layoutHeight: number,
nextHeaderLayoutY: ?number,
};
constructor(props: Props, context: Object) {
super(props, context);
this.state = {
measured: false,
layoutY: 0,
layoutHeight: 0,
nextHeaderLayoutY: props.nextHeaderLayoutY,
};
}
setNextHeaderY(y: number) {
this.setState({ nextHeaderLayoutY: y });
}
@@ -65,8 +76,10 @@ class ScrollViewStickyHeader extends React.Component {
// scroll indefinetly.
const inputRange = [-1, 0, layoutY];
const outputRange: Array<number> = [0, 0, 0];
if (nextHeaderLayoutY != null) {
const collisionPoint = nextHeaderLayoutY - layoutHeight;
// Sometimes headers jump around so we make sure we don't violate the monotonic inputRange
// condition.
const collisionPoint = (nextHeaderLayoutY || 0) - layoutHeight;
if (collisionPoint >= layoutY) {
inputRange.push(collisionPoint, collisionPoint + 1);
outputRange.push(collisionPoint - layoutY, collisionPoint - layoutY);
} else {
@@ -33,6 +33,7 @@
'use strict';
const MetroListView = require('MetroListView');
const Platform = require('Platform');
const React = require('React');
const VirtualizedSectionList = require('VirtualizedSectionList');
@@ -52,9 +53,7 @@ type SectionBase<SectionItemT> = {
keyExtractor?: (item: SectionItemT) => string,
// TODO: support more optional/override props
// FooterComponent?: ?ReactClass<*>,
// HeaderComponent?: ?ReactClass<*>,
// onViewableItemsChanged?: ({viewableItems: Array<ViewToken>, changed: Array<ViewToken>}) => void,
// onViewableItemsChanged?: ...
};
type RequiredProps<SectionT: SectionBase<any>> = {
@@ -102,7 +101,10 @@ type OptionalProps<SectionT: SectionBase<any>> = {
* Called when the viewability of rows changes, as defined by the
* `viewabilityConfig` prop.
*/
onViewableItemsChanged?: ?(info: {viewableItems: Array<ViewToken>, changed: Array<ViewToken>}) => void,
onViewableItemsChanged?: ?(info: {
viewableItems: Array<ViewToken>,
changed: Array<ViewToken>,
}) => void,
/**
* Set this true while waiting for new data from a refresh.
*/
@@ -114,13 +116,23 @@ type OptionalProps<SectionT: SectionBase<any>> = {
prevProps: {item: Item, index: number},
nextProps: {item: Item, index: number}
) => boolean,
/**
* Makes section headers stick to the top of the screen until the next one pushes it off. Only
* enabled by default on iOS because that is the platform standard there.

This comment has been minimized.

Show comment
Hide comment
@janicduplessis

janicduplessis Mar 22, 2017

Collaborator

@sahrens Just throwing this out here but what about we make sticky section headers the same for iOS and Android here (either enabled or disabled)? For ListView I preferred the different values for backwards compatibility but here I kind of prefer consistency.

@janicduplessis

janicduplessis Mar 22, 2017

Collaborator

@sahrens Just throwing this out here but what about we make sticky section headers the same for iOS and Android here (either enabled or disabled)? For ListView I preferred the different values for backwards compatibility but here I kind of prefer consistency.

This comment has been minimized.

Show comment
Hide comment
@sahrens

sahrens Mar 22, 2017

Contributor

I think it's better to be consistent with platform standards. I think people should have to explicitly opt-in to non-standard behavior. This is pretty common for other components, too, like buttons that use ripple on android and highlight on ios, etc.

@sahrens

sahrens Mar 22, 2017

Contributor

I think it's better to be consistent with platform standards. I think people should have to explicitly opt-in to non-standard behavior. This is pretty common for other components, too, like buttons that use ripple on android and highlight on ios, etc.

This comment has been minimized.

Show comment
Hide comment
@janicduplessis

janicduplessis Mar 22, 2017

Collaborator

I think the reason I see this differently here is that Android actually doesn't have anything that is high level like SectionList. iOS has UITableView which uses sticky headers by default so the pattern is definitely more popular there for that reason. Android just doesn't really have a standard, lists without sticky headers are probably more popular because implementing it is harder or require a 3rd party library.

@janicduplessis

janicduplessis Mar 22, 2017

Collaborator

I think the reason I see this differently here is that Android actually doesn't have anything that is high level like SectionList. iOS has UITableView which uses sticky headers by default so the pattern is definitely more popular there for that reason. Android just doesn't really have a standard, lists without sticky headers are probably more popular because implementing it is harder or require a 3rd party library.

*/
stickySectionHeadersEnabled?: boolean,
};
type Props<SectionT> = RequiredProps<SectionT>
& OptionalProps<SectionT>
& VirtualizedSectionListProps<SectionT>;
type DefaultProps = typeof VirtualizedSectionList.defaultProps;
const defaultProps = {
...VirtualizedSectionList.defaultProps,
stickySectionHeadersEnabled: Platform.OS === 'ios',
};
type DefaultProps = typeof defaultProps;
/**
* A performant interface for rendering sectioned lists, supporting the most handy features:
@@ -136,7 +148,8 @@ type DefaultProps = typeof VirtualizedSectionList.defaultProps;
* - Pull to Refresh.
* - Scroll loading.
*
* If you don't need section support and want a simpler interface, use [`<FlatList>`](/react-native/docs/flatlist.html).
* If you don't need section support and want a simpler interface, use
* [`<FlatList>`](/react-native/docs/flatlist.html).
*
* If you need _sticky_ section header support, use `ListView` for now.
*
@@ -180,7 +193,7 @@ class SectionList<SectionT: SectionBase<any>>
extends React.PureComponent<DefaultProps, Props<SectionT>, void>
{
props: Props<SectionT>;
static defaultProps: DefaultProps = VirtualizedSectionList.defaultProps;
static defaultProps: DefaultProps = defaultProps;
render() {
const List = this.props.legacyImplementation ? MetroListView : VirtualizedSectionList;
Oops, something went wrong.

7 comments on commit 72670bf

@janicduplessis

This comment has been minimized.

Show comment
Hide comment
@janicduplessis

janicduplessis Mar 22, 2017

Collaborator

🎉 🎉 🎉

Collaborator

janicduplessis replied Mar 22, 2017

🎉 🎉 🎉

@nihgwu

This comment has been minimized.

Show comment
Hide comment
@nihgwu

nihgwu Mar 22, 2017

Contributor

👍
Can't wait to test it on UIExplorer, unfortunately it still doesn't work well on Android, while pretty good on iOS

Contributor

nihgwu replied Mar 22, 2017

👍
Can't wait to test it on UIExplorer, unfortunately it still doesn't work well on Android, while pretty good on iOS

@nihgwu

This comment has been minimized.

Show comment
Hide comment
@nihgwu

nihgwu Mar 22, 2017

Contributor

@sahrens any plan to look into the stickySectionHeader issue on Android? You can see the SectionListexample in UIExplorer

Contributor

nihgwu replied Mar 22, 2017

@sahrens any plan to look into the stickySectionHeader issue on Android? You can see the SectionListexample in UIExplorer

@sahrens

This comment has been minimized.

Show comment
Hide comment
@sahrens

sahrens Mar 23, 2017

Contributor

What problems are you seeing on Android exactly? @janicduplessis is the master there.

Contributor

sahrens replied Mar 23, 2017

What problems are you seeing on Android exactly? @janicduplessis is the master there.

@janicduplessis

This comment has been minimized.

Show comment
Hide comment
@janicduplessis

janicduplessis Mar 23, 2017

Collaborator

Just tested and there are issues on Android, some headers just disappear. Might be an issue with view clipping or z-index.

Collaborator

janicduplessis replied Mar 23, 2017

Just tested and there are issues on Android, some headers just disappear. Might be an issue with view clipping or z-index.

@janicduplessis

This comment has been minimized.

Show comment
Hide comment
@janicduplessis

janicduplessis Mar 23, 2017

Collaborator

It works well in the ListView paging example so I guess it has to do with FlatList removing / adding views and z-index (view clipping is disabled on android anyway).

Collaborator

janicduplessis replied Mar 23, 2017

It works well in the ListView paging example so I guess it has to do with FlatList removing / adding views and z-index (view clipping is disabled on android anyway).

@janicduplessis

This comment has been minimized.

Show comment
Hide comment
@janicduplessis

janicduplessis Mar 23, 2017

Collaborator

Here's the fix for android bugs #13105

Collaborator

janicduplessis replied Mar 23, 2017

Here's the fix for android bugs #13105

Please sign in to comment.