Support sticky headers for inverted Lists

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 https://github.com/facebook/react-native/pull/17762

Differential Revision: D6830784

Pulled By: sahrens

fbshipit-source-id: 6841fdd46e04b30547659d85ff54c3a21c61a8a2
This commit is contained in:
Janic Duplessis 2018-01-29 11:43:50 -08:00 committed by Facebook Github Bot
parent 429fcc8cab
commit ecaca80d42
6 changed files with 124 additions and 36 deletions

View File

@ -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.
@ -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() {
@ -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);
@ -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>
);
@ -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,

View File

@ -8,6 +8,7 @@
*
* @providesModule ScrollViewStickyHeader
* @flow
* @format
*/
'use strict';
@ -15,34 +16,40 @@ 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,
@ -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);
}
}
}

View File

@ -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) {

View File

@ -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}
@ -148,6 +149,7 @@ exports[`FlatList renders empty list 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
numColumns={1}
@ -177,6 +179,7 @@ exports[`FlatList renders null list 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
numColumns={1}
@ -218,6 +221,7 @@ exports[`FlatList renders simple list 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
numColumns={1}

View File

@ -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]}
@ -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]}
@ -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]}
@ -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]}
@ -512,6 +516,7 @@ exports[`SectionList renders empty list 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}

View File

@ -17,6 +17,7 @@ exports[`VirtualizedList handles nested lists 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
@ -53,6 +54,7 @@ exports[`VirtualizedList handles nested lists 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
@ -106,6 +108,7 @@ exports[`VirtualizedList handles nested lists 1`] = `
getItemCount={[Function]}
horizontal={true}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
@ -180,6 +183,7 @@ exports[`VirtualizedList handles separators correctly 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
@ -261,6 +265,7 @@ exports[`VirtualizedList handles separators correctly 2`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
@ -342,6 +347,7 @@ exports[`VirtualizedList handles separators correctly 3`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
@ -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}
@ -622,6 +629,7 @@ exports[`VirtualizedList renders empty list 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
@ -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]}
@ -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]}
@ -741,6 +751,7 @@ exports[`VirtualizedList renders null list 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
@ -780,6 +791,7 @@ exports[`VirtualizedList renders simple list 1`] = `
getItemCount={[Function]}
horizontal={false}
initialNumToRender={10}
invertStickyHeaders={undefined}
keyExtractor={[Function]}
maxToRenderPerBatch={10}
onContentSizeChange={[Function]}
@ -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]}