Skip to content

Commit

Permalink
Support sticky headers for inverted Lists
Browse files Browse the repository at this point in the history
Summary:
Sticky headers for inverted lists should still stick at the top of the list instead of the bottom.

Tested by adding the inverted prop to the SectionList example in RNTester.

It does add a prop to ScrollView but it's very specific to the inverted list implementation, not sure if it should be documented.

[GENERAL][ENHANCEMENT][LISTS] -  Support sticky headers for inverted Lists
Closes facebook#17762

Differential Revision: D6830784

Pulled By: sahrens

fbshipit-source-id: 6841fdd46e04b30547659d85ff54c3a21c61a8a2
  • Loading branch information
janicduplessis authored and facebook-github-bot committed Jan 29, 2018
1 parent 429fcc8 commit ecaca80
Show file tree
Hide file tree
Showing 6 changed files with 124 additions and 36 deletions.
24 changes: 22 additions & 2 deletions Libraries/Components/ScrollView/ScrollView.js
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,11 @@ const ScrollView = createReactClass({
'black',
'white',
]),
/**
* If sticky headers should stick at the bottom instead of the top of the
* ScrollView. This is usually used with inverted ScrollViews.
*/
invertStickyHeaders: PropTypes.bool,
/**
* When true, the ScrollView will try to lock to only vertical or horizontal
* scrolling while dragging. The default value is false.
Expand Down Expand Up @@ -499,7 +504,10 @@ const ScrollView = createReactClass({
_stickyHeaderRefs: (new Map(): Map<number, ScrollViewStickyHeader>),
_headerLayoutYs: (new Map(): Map<string, number>),
getInitialState: function() {
return this.scrollResponderMixinGetInitialState();
return {
...this.scrollResponderMixinGetInitialState(),
layoutHeight: null,
};
},

componentWillMount: function() {
Expand Down Expand Up @@ -676,6 +684,15 @@ const ScrollView = createReactClass({
this.scrollResponderHandleScroll(e);
},

_handleLayout: function(e: Object) {
if (this.props.invertStickyHeaders) {
this.setState({ layoutHeight: e.nativeEvent.layout.height });
}
if (this.props.onLayout) {
this.props.onLayout(e);
}
},

_handleContentOnLayout: function(e: Object) {
const {width, height} = e.nativeEvent.layout;
this.props.onContentSizeChange && this.props.onContentSizeChange(width, height);
Expand Down Expand Up @@ -761,7 +778,9 @@ const ScrollView = createReactClass({
this._headerLayoutYs.get(this._getKeyForIndex(nextIndex, childArray))
}
onLayout={(event) => this._onStickyHeaderLayout(index, event, key)}
scrollAnimatedValue={this._scrollAnimatedValue}>
scrollAnimatedValue={this._scrollAnimatedValue}
inverted={this.props.invertStickyHeaders}
scrollViewHeight={this.state.layoutHeight}>
{child}
</ScrollViewStickyHeader>
);
Expand Down Expand Up @@ -808,6 +827,7 @@ const ScrollView = createReactClass({
// Override the onContentSizeChange from props, since this event can
// bubble up from TextInputs
onContentSizeChange: null,
onLayout: this._handleLayout,
onMomentumScrollBegin: this.scrollResponderHandleMomentumScrollBegin,
onMomentumScrollEnd: this.scrollResponderHandleMomentumScrollEnd,
onResponderGrant: this.scrollResponderHandleResponderGrant,
Expand Down
113 changes: 79 additions & 34 deletions Libraries/Components/ScrollView/ScrollViewStickyHeader.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,41 +8,48 @@
*
* @providesModule ScrollViewStickyHeader
* @flow
* @format
*/
'use strict';

const Animated = require('Animated');
const React = require('React');
const StyleSheet = require('StyleSheet');

import type {LayoutEvent} from 'CoreEventTypes';

type Props = {
children?: React.Element<any>,
nextHeaderLayoutY: ?number,
onLayout: (event: Object) => void,
onLayout: (event: LayoutEvent) => void,
scrollAnimatedValue: Animated.Value,
// Will cause sticky headers to stick at the bottom of the ScrollView instead
// of the top.
inverted: ?boolean,
// The height of the parent ScrollView. Currently only set when inverted.
scrollViewHeight: ?number,
};

class ScrollViewStickyHeader extends React.Component<Props, {
type 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,
};
}
};

class ScrollViewStickyHeader extends React.Component<Props, State> {
state = {
measured: false,
layoutY: 0,
layoutHeight: 0,
nextHeaderLayoutY: this.props.nextHeaderLayoutY,
};

setNextHeaderY(y: number) {
this.setState({ nextHeaderLayoutY: y });
this.setState({nextHeaderLayoutY: y});
}

_onLayout = (event) => {
_onLayout = event => {
this.setState({
measured: true,
layoutY: event.nativeEvent.layout.y,
Expand All @@ -57,32 +64,70 @@ class ScrollViewStickyHeader extends React.Component<Props, {
};

render() {
const {inverted, scrollViewHeight} = this.props;
const {measured, layoutHeight, layoutY, nextHeaderLayoutY} = this.state;
const inputRange: Array<number> = [-1, 0];
const outputRange: Array<number> = [0, 0];

if (measured) {
// The interpolation looks like:
// - Negative scroll: no translation
// - From 0 to the y of the header: no translation. This will cause the header
// to scroll normally until it reaches the top of the scroll view.
// - From header y to when the next header y hits the bottom edge of the header: translate
// equally to scroll. This will cause the header to stay at the top of the scroll view.
// - Past the collision with the next header y: no more translation. This will cause the
// header to continue scrolling up and make room for the next sticky header.
// In the case that there is no next header just translate equally to
// scroll indefinitely.
inputRange.push(layoutY);
outputRange.push(0);
// 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);
if (inverted) {
// The interpolation looks like:
// - Negative scroll: no translation
// - `stickStartPoint` is the point at which the header will start sticking.
// It is calculated using the ScrollView viewport height so it is a the bottom.
// - Headers that are in the initial viewport will never stick, `stickStartPoint`
// will be negative.
// - From 0 to `stickStartPoint` no translation. This will cause the header
// to scroll normally until it reaches the top of the scroll view.
// - From `stickStartPoint` to when the next header y hits the bottom edge of the header: translate
// equally to scroll. This will cause the header to stay at the top of the scroll view.
// - Past the collision with the next header y: no more translation. This will cause the
// header to continue scrolling up and make room for the next sticky header.
// In the case that there is no next header just translate equally to
// scroll indefinitely.
if (scrollViewHeight != null) {
const stickStartPoint = layoutY + layoutHeight - scrollViewHeight;
if (stickStartPoint > 0) {
inputRange.push(stickStartPoint);
outputRange.push(0);
inputRange.push(stickStartPoint + 1);
outputRange.push(1);
// If the next sticky header has not loaded yet (probably windowing) or is the last
// we can just keep it sticked forever.
const collisionPoint =
(nextHeaderLayoutY || 0) - layoutHeight - scrollViewHeight;
if (collisionPoint > stickStartPoint) {
inputRange.push(collisionPoint, collisionPoint + 1);
outputRange.push(
collisionPoint - stickStartPoint,
collisionPoint - stickStartPoint,
);
}
}
}
} else {
inputRange.push(layoutY + 1);
outputRange.push(1);
// The interpolation looks like:
// - Negative scroll: no translation
// - From 0 to the y of the header: no translation. This will cause the header
// to scroll normally until it reaches the top of the scroll view.
// - From header y to when the next header y hits the bottom edge of the header: translate
// equally to scroll. This will cause the header to stay at the top of the scroll view.
// - Past the collision with the next header y: no more translation. This will cause the
// header to continue scrolling up and make room for the next sticky header.
// In the case that there is no next header just translate equally to
// scroll indefinitely.
inputRange.push(layoutY);
outputRange.push(0);
// If the next sticky header has not loaded yet (probably windowing) or is the last
// we can just keep it sticked forever.
const collisionPoint = (nextHeaderLayoutY || 0) - layoutHeight;
if (collisionPoint >= layoutY) {
inputRange.push(collisionPoint, collisionPoint + 1);
outputRange.push(collisionPoint - layoutY, collisionPoint - layoutY);
} else {
inputRange.push(layoutY + 1);
outputRange.push(1);
}
}
}

Expand Down
1 change: 1 addition & 0 deletions Libraries/Lists/VirtualizedList.js
Original file line number Diff line number Diff line change
Expand Up @@ -898,6 +898,7 @@ class VirtualizedList extends React.PureComponent<Props, State> {
onScrollEndDrag: this._onScrollEndDrag,
onMomentumScrollEnd: this._onMomentumScrollEnd,
scrollEventThrottle: this.props.scrollEventThrottle, // TODO: Android support
invertStickyHeaders: this.props.inverted,
stickyHeaderIndices,
};
if (inversionStyle) {
Expand Down
4 changes: 4 additions & 0 deletions Libraries/Lists/__tests__/__snapshots__/FlatList-test.js.snap
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ exports[`FlatList renders all the bells and whistles 1`] = `
getItemLayout={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
numColumns={2}
Expand Down Expand Up @@ -148,6 +149,7 @@ exports[`FlatList renders empty list 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
numColumns={1}
Expand Down Expand Up @@ -177,6 +179,7 @@ exports[`FlatList renders null list 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
numColumns={1}
Expand Down Expand Up @@ -218,6 +221,7 @@ exports[`FlatList renders simple list 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
numColumns={1}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ exports[`SectionList rendering empty section headers is fine 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -105,6 +106,7 @@ exports[`SectionList renders a footer when there is no data 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -173,6 +175,7 @@ exports[`SectionList renders a footer when there is no data and no header 1`] =
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -272,6 +275,7 @@ exports[`SectionList renders all the bells and whistles 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={Infinity}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -512,6 +516,7 @@ exports[`SectionList renders empty list 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ exports[`VirtualizedList handles nested lists 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -53,6 +54,7 @@ exports[`VirtualizedList handles nested lists 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -106,6 +108,7 @@ exports[`VirtualizedList handles nested lists 1`] = `
getItemCount={[Function]}
horizontal={true}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -180,6 +183,7 @@ exports[`VirtualizedList handles separators correctly 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -261,6 +265,7 @@ exports[`VirtualizedList handles separators correctly 2`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -342,6 +347,7 @@ exports[`VirtualizedList handles separators correctly 3`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -434,6 +440,7 @@ exports[`VirtualizedList renders all the bells and whistles 1`] = `
getItemLayout={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={true}
inverted={true}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
Expand Down Expand Up @@ -622,6 +629,7 @@ exports[`VirtualizedList renders empty list 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -652,6 +660,7 @@ exports[`VirtualizedList renders empty list with empty component 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -705,6 +714,7 @@ exports[`VirtualizedList renders list with empty component 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -741,6 +751,7 @@ exports[`VirtualizedList renders null list 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -780,6 +791,7 @@ exports[`VirtualizedList renders simple list 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down Expand Up @@ -838,6 +850,7 @@ exports[`VirtualizedList test getItem functionality where data is not an Array 1
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
Expand Down

0 comments on commit ecaca80

Please sign in to comment.