Skip to content

Commit

Permalink
support sticky headers
Browse files Browse the repository at this point in the history
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 72670bf
Show file tree
Hide file tree
Showing 8 changed files with 204 additions and 81 deletions.
15 changes: 14 additions & 1 deletion Examples/UIExplorer/js/SectionListExample.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ class SectionListExample extends React.PureComponent {

state = {
data: genItemData(1000),
debug: false,
filterText: '',
logViewable: false,
virtualized: true,
Expand All @@ -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}
Expand All @@ -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>
Expand All @@ -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}
Expand All @@ -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}
/>
Expand Down
85 changes: 55 additions & 30 deletions Libraries/Components/ScrollView/ScrollView.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}
},

Expand Down Expand Up @@ -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}
Expand Down
29 changes: 21 additions & 8 deletions Libraries/Components/ScrollView/ScrollViewStickyHeader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Expand Down Expand Up @@ -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 {
Expand Down
27 changes: 20 additions & 7 deletions Libraries/CustomComponents/Lists/SectionList.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
'use strict';

const MetroListView = require('MetroListView');
const Platform = require('Platform');
const React = require('React');
const VirtualizedSectionList = require('VirtualizedSectionList');

Expand All @@ -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>> = {
Expand Down Expand Up @@ -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.
*/
Expand All @@ -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.

Copy link
@janicduplessis

janicduplessis Mar 22, 2017

Contributor

@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.

Copy link
@sahrens

sahrens Mar 22, 2017

Author 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.

Copy link
@janicduplessis

janicduplessis Mar 22, 2017

Contributor

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:
Expand All @@ -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.
*
Expand Down Expand Up @@ -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;
Expand Down
Loading

7 comments on commit 72670bf

@janicduplessis
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎉 🎉 🎉

@nihgwu
Copy link
Contributor

@nihgwu nihgwu commented on 72670bf Mar 22, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@nihgwu
Copy link
Contributor

@nihgwu nihgwu commented on 72670bf Mar 22, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@sahrens
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@janicduplessis
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@janicduplessis
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's the fix for android bugs #13105

Please sign in to comment.