Implement sticky headers in JS using Native Animated

Summary:
This re-implements sticky headers in JS to make it work on Android.

The only change that was needed was to expose a way to attach a an animated value to an event manually since we can't use the Animated wrapper and `Animated.event` to do it for us because this is implemented directly in the `ScrollView` component. Simply exposed `attachNativeEvent` that takes a ref, event name and event object mapping. This is what is used by `Animated.event`.

TODO:
- Need to check why momentum scrolling isn't triggering scroll events properly on Android.
- Remove native iOS implementation
- cleanup / fix flow

**Test plan**
Test the example list in UIExplorer, test the ListViewPaging example.
Closes https://github.com/facebook/react-native/pull/11315

Differential Revision: D4450278

Pulled By: sahrens

fbshipit-source-id: fec8da2cffce9807d74f8e518ebdefeb6a708667
This commit is contained in:
Janic Duplessis 2017-03-02 15:09:39 -08:00 committed by Facebook Github Bot
parent da04a6b1f3
commit 77b8c09727
5 changed files with 297 additions and 63 deletions

View File

@ -201,6 +201,7 @@ const styles = StyleSheet.create({
backgroundColor: '#eeeeee',
},
sectionHeader: {
backgroundColor: '#eeeeee',
padding: 5,
fontWeight: '500',
fontSize: 11,

View File

@ -16,12 +16,22 @@ var AnimatedImplementation = require('AnimatedImplementation');
var Image = require('Image');
var Text = require('Text');
var View = require('View');
var ScrollView = require('ScrollView');
module.exports = {
...AnimatedImplementation,
let AnimatedScrollView;
const Animated = {
View: AnimatedImplementation.createAnimatedComponent(View),
Text: AnimatedImplementation.createAnimatedComponent(Text),
Image: AnimatedImplementation.createAnimatedComponent(Image),
ScrollView: AnimatedImplementation.createAnimatedComponent(ScrollView),
get ScrollView() {
// Make this lazy to avoid circular reference.
if (!AnimatedScrollView) {
AnimatedScrollView = AnimatedImplementation.createAnimatedComponent(require('ScrollView'));
}
return AnimatedScrollView;
},
};
Object.assign((Animated: Object), AnimatedImplementation);
module.exports = ((Animated: any): (typeof AnimatedImplementation) & typeof Animated);

View File

@ -2151,9 +2151,53 @@ type EventConfig = {
useNativeDriver?: bool,
};
function attachNativeEvent(viewRef: any, eventName: string, argMapping: Array<?Mapping>) {
// Find animated values in `argMapping` and create an array representing their
// key path inside the `nativeEvent` object. Ex.: ['contentOffset', 'x'].
const eventMappings = [];
const traverse = (value, path) => {
if (value instanceof AnimatedValue) {
value.__makeNative();
eventMappings.push({
nativeEventPath: path,
animatedValueTag: value.__getNativeTag(),
});
} else if (typeof value === 'object') {
for (const key in value) {
traverse(value[key], path.concat(key));
}
}
};
invariant(
argMapping[0] && argMapping[0].nativeEvent,
'Native driven events only support animated values contained inside `nativeEvent`.'
);
// Assume that the event containing `nativeEvent` is always the first argument.
traverse(argMapping[0].nativeEvent, []);
const viewTag = ReactNative.findNodeHandle(viewRef);
eventMappings.forEach((mapping) => {
NativeAnimatedAPI.addAnimatedEventToView(viewTag, eventName, mapping);
});
return {
detach() {
NativeAnimatedAPI.removeAnimatedEventFromView(viewTag, eventName);
},
};
}
class AnimatedEvent {
_argMapping: Array<?Mapping>;
_listener: ?Function;
_attachedEvent: ?{
detach: () => void,
};
__isNative: bool;
constructor(
@ -2162,6 +2206,7 @@ class AnimatedEvent {
) {
this._argMapping = argMapping;
this._listener = config.listener;
this._attachedEvent = null;
this.__isNative = shouldUseNativeDriver(config);
if (__DEV__) {
@ -2172,44 +2217,13 @@ class AnimatedEvent {
__attach(viewRef, eventName) {
invariant(this.__isNative, 'Only native driven events need to be attached.');
// Find animated values in `argMapping` and create an array representing their
// key path inside the `nativeEvent` object. Ex.: ['contentOffset', 'x'].
const eventMappings = [];
const traverse = (value, path) => {
if (value instanceof AnimatedValue) {
value.__makeNative();
eventMappings.push({
nativeEventPath: path,
animatedValueTag: value.__getNativeTag(),
});
} else if (typeof value === 'object') {
for (const key in value) {
traverse(value[key], path.concat(key));
}
}
};
invariant(
this._argMapping[0] && this._argMapping[0].nativeEvent,
'Native driven events only support animated values contained inside `nativeEvent`.'
);
// Assume that the event containing `nativeEvent` is always the first argument.
traverse(this._argMapping[0].nativeEvent, []);
const viewTag = ReactNative.findNodeHandle(viewRef);
eventMappings.forEach((mapping) => {
NativeAnimatedAPI.addAnimatedEventToView(viewTag, eventName, mapping);
});
this._attachedEvent = attachNativeEvent(viewRef, eventName, this._argMapping);
}
__detach(viewTag, eventName) {
invariant(this.__isNative, 'Only native driven events need to be detached.');
NativeAnimatedAPI.removeAnimatedEventFromView(viewTag, eventName);
this._attachedEvent && this._attachedEvent.detach();
}
__getHandler() {
@ -2582,5 +2596,11 @@ module.exports = {
*/
createAnimatedComponent,
/**
* Imperative API to attach an animated value to an event on a view. Prefer using
* `Animated.event` with `useNativeDrive: true` if possible.
*/
attachNativeEvent,
__PropsOnlyForTests: AnimatedProps,
};

View File

@ -11,6 +11,7 @@
*/
'use strict';
const Animated = require('Animated');
const ColorPropType = require('ColorPropType');
const EdgeInsetsPropType = require('EdgeInsetsPropType');
const Platform = require('Platform');
@ -18,6 +19,7 @@ const PointPropType = require('PointPropType');
const React = require('React');
const ReactNative = require('ReactNative');
const ScrollResponder = require('ScrollResponder');
const ScrollViewStickyHeader = require('ScrollViewStickyHeader');
const StyleSheet = require('StyleSheet');
const StyleSheetPropType = require('StyleSheetPropType');
const View = require('View');
@ -50,7 +52,7 @@ const requireNativeComponent = require('requireNativeComponent');
* ScrollView simply renders all its react child components at once. That
* makes it very easy to understand and use.
* On the other hand, this has a performance downside. Imagine you have a very
* long list of items you want to display, worth of couple of your ScrollViews
* long list of items you want to display, worth of couple of your ScrollView's
* heights. Creating JS components and native views upfront for all its items,
* which may not even be shown, will contribute to slow rendering of your
* screen and increased memory usage.
@ -157,8 +159,8 @@ const ScrollView = React.createClass({
/**
* The style of the scroll indicators.
* - `default` (the default), same as `black`.
* - `black`, scroll indicator is black. This style is good against a white content background.
* - `white`, scroll indicator is white. This style is good against a black content background.
* - `black`, scroll indicator is black. This style is good against a light background.
* - `white`, scroll indicator is white. This style is good against a dark background.
* @platform ios
*/
indicatorStyle: PropTypes.oneOf([
@ -227,7 +229,8 @@ const ScrollView = React.createClass({
/**
* Called when scrollable content view of the ScrollView changes.
*
* Handler function is passed the content width and content height as parameters: `(contentWidth, contentHeight)`
* Handler function is passed the content width and content height as parameters:
* `(contentWidth, contentHeight)`
*
* It's implemented using onLayout handler attached to the content container
* which this ScrollView renders.
@ -372,10 +375,33 @@ const ScrollView = React.createClass({
mixins: [ScrollResponder.Mixin],
_scrollAnimatedValue: (new Animated.Value(0): Animated.Value),
_scrollAnimatedValueAttachment: (null: ?{detach: () => void}),
_stickyHeaderRefs: (new Map(): Map<number, ScrollViewStickyHeader>),
getInitialState: function() {
return this.scrollResponderMixinGetInitialState();
},
componentWillMount: function() {
this._scrollAnimatedValue = new Animated.Value(0);
this._stickyHeaderRefs = new Map();
},
componentDidMount: function() {
this._updateAnimatedNodeAttachment();
},
componentDidUpdate: function() {
this._updateAnimatedNodeAttachment();
},
componentWillUnmount: function() {
if (this._scrollAnimatedValueAttachment) {
this._scrollAnimatedValueAttachment.detach();
}
},
setNativeProps: function(props: Object) {
this._scrollViewRef && this._scrollViewRef.setNativeProps(props);
},
@ -415,11 +441,14 @@ const ScrollView = React.createClass({
animated?: boolean
) {
if (typeof y === 'number') {
console.warn('`scrollTo(y, x, animated)` is deprecated. Use `scrollTo({x: 5, y: 5, animated: true})` instead.');
console.warn('`scrollTo(y, x, animated)` is deprecated. Use `scrollTo({x: 5, y: 5, ' +
'animated: true})` instead.');
} else {
({x, y, animated} = y || {});
}
this.getScrollResponder().scrollResponderScrollTo({x: x || 0, y: y || 0, animated: animated !== false});
this.getScrollResponder().scrollResponderScrollTo(
{x: x || 0, y: y || 0, animated: animated !== false}
);
},
/**
@ -448,6 +477,42 @@ const ScrollView = React.createClass({
this.scrollTo({x, y, animated: false});
},
_updateAnimatedNodeAttachment: function() {
if (this.props.stickyHeaderIndices && this.props.stickyHeaderIndices.length > 0) {
if (!this._scrollAnimatedValueAttachment) {
this._scrollAnimatedValueAttachment = Animated.attachNativeEvent(
this._scrollViewRef,
'onScroll',
[{nativeEvent: {contentOffset: {y: this._scrollAnimatedValue}}}]
);
}
} else {
if (this._scrollAnimatedValueAttachment) {
this._scrollAnimatedValueAttachment.detach();
}
}
},
_setStickyHeaderRef: function(index, ref) {
this._stickyHeaderRefs.set(index, ref);
},
_onStickyHeaderLayout: function(index, event) {
if (!this.props.stickyHeaderIndices) {
return;
}
const previousHeaderIndex = this.props.stickyHeaderIndices[
this.props.stickyHeaderIndices.indexOf(index) - 1
];
if (previousHeaderIndex != null) {
const previousHeader = this._stickyHeaderRefs.get(previousHeaderIndex);
previousHeader && previousHeader.setNextHeaderY(
event.nativeEvent.layout.y - event.nativeEvent.layout.height,
);
}
},
_handleScroll: function(e: Object) {
if (__DEV__) {
if (this.props.onScroll && this.props.scrollEventThrottle == null && Platform.OS === 'ios') {
@ -531,14 +596,36 @@ const ScrollView = React.createClass({
};
}
const contentContainer =
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 =
<ScrollContentContainerViewClass
{...contentSizeChangeProps}
ref={this._setInnerViewRef}
style={contentContainerStyle}
removeClippedSubviews={this.props.removeClippedSubviews}
removeClippedSubviews={
hasStickyHeaders && Platform.OS === 'android' ? false : this.props.removeClippedSubviews
}
collapsable={false}>
{this.props.children}
{children}
</ScrollContentContainerViewClass>;
const alwaysBounceHorizontal =
@ -560,23 +647,26 @@ const ScrollView = React.createClass({
// Override the onContentSizeChange from props, since this event can
// bubble up from TextInputs
onContentSizeChange: null,
onTouchStart: this.scrollResponderHandleTouchStart,
onTouchMove: this.scrollResponderHandleTouchMove,
onTouchEnd: this.scrollResponderHandleTouchEnd,
onScrollBeginDrag: this.scrollResponderHandleScrollBeginDrag,
onScrollEndDrag: this.scrollResponderHandleScrollEndDrag,
onMomentumScrollBegin: this.scrollResponderHandleMomentumScrollBegin,
onMomentumScrollEnd: this.scrollResponderHandleMomentumScrollEnd,
onResponderGrant: this.scrollResponderHandleResponderGrant,
onResponderReject: this.scrollResponderHandleResponderReject,
onResponderRelease: this.scrollResponderHandleResponderRelease,
onResponderTerminate: this.scrollResponderHandleTerminate,
onResponderTerminationRequest: this.scrollResponderHandleTerminationRequest,
onScroll: this._handleScroll,
onScrollBeginDrag: this.scrollResponderHandleScrollBeginDrag,
onScrollEndDrag: this.scrollResponderHandleScrollEndDrag,
onScrollShouldSetResponder: this.scrollResponderHandleScrollShouldSetResponder,
onStartShouldSetResponder: this.scrollResponderHandleStartShouldSetResponder,
onStartShouldSetResponderCapture: this.scrollResponderHandleStartShouldSetResponderCapture,
onScrollShouldSetResponder: this.scrollResponderHandleScrollShouldSetResponder,
onScroll: this._handleScroll,
onResponderGrant: this.scrollResponderHandleResponderGrant,
onResponderTerminationRequest: this.scrollResponderHandleTerminationRequest,
onResponderTerminate: this.scrollResponderHandleTerminate,
onResponderRelease: this.scrollResponderHandleResponderRelease,
onResponderReject: this.scrollResponderHandleResponderReject,
sendMomentumEvents: (this.props.onMomentumScrollBegin || this.props.onMomentumScrollEnd) ? true : false,
onTouchEnd: this.scrollResponderHandleTouchEnd,
onTouchMove: this.scrollResponderHandleTouchMove,
onTouchStart: this.scrollResponderHandleTouchStart,
scrollEventThrottle: hasStickyHeaders ? 1 : this.props.scrollEventThrottle,
sendMomentumEvents: (this.props.onMomentumScrollBegin || this.props.onMomentumScrollEnd) ?
true : false,
stickyHeaderIndices: null,
};
const { decelerationRate } = this.props;
@ -636,17 +726,25 @@ const styles = StyleSheet.create({
},
});
let nativeOnlyProps, AndroidScrollView, AndroidHorizontalScrollView, RCTScrollView, RCTScrollContentView;
let nativeOnlyProps,
AndroidScrollView,
AndroidHorizontalScrollView,
RCTScrollView,
RCTScrollContentView;
if (Platform.OS === 'android') {
nativeOnlyProps = {
nativeOnly: {
sendMomentumEvents: true,
}
};
AndroidScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps);
AndroidScrollView = requireNativeComponent(
'RCTScrollView',
(ScrollView: ReactClass<*>),
nativeOnlyProps
);
AndroidHorizontalScrollView = requireNativeComponent(
'AndroidHorizontalScrollView',
ScrollView,
(ScrollView: ReactClass<*>),
nativeOnlyProps
);
} else if (Platform.OS === 'ios') {
@ -658,7 +756,11 @@ if (Platform.OS === 'android') {
onScrollEndDrag: true,
}
};
RCTScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps);
RCTScrollView = requireNativeComponent(
'RCTScrollView',
(ScrollView: ReactClass<*>),
nativeOnlyProps,
);
RCTScrollContentView = requireNativeComponent('RCTScrollContentView', View);
}

View File

@ -0,0 +1,101 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
* All rights reserved.
*
* This source code is licensed under the BSD-style license found in the
* LICENSE file in the root directory of this source tree. An additional grant
* of patent rights can be found in the PATENTS file in the same directory.
*
* @providesModule ScrollViewStickyHeader
* @flow
*/
'use strict';
const Animated = require('Animated');
const React = require('React');
const StyleSheet = require('StyleSheet');
type Props = {
children?: React.Element<*>,
scrollAnimatedValue: Animated.Value,
onLayout: (event: Object) => void,
};
class ScrollViewStickyHeader extends React.Component {
props: Props;
state = {
measured: false,
layoutY: 0,
nextHeaderLayoutY: (null: ?number),
};
setNextHeaderY(y: number) {
this.setState({ nextHeaderLayoutY: y });
}
_onLayout = (event) => {
this.setState({
measured: true,
layoutY: event.nativeEvent.layout.y,
});
this.props.onLayout(event);
};
render() {
const {measured, layoutY, nextHeaderLayoutY} = this.state;
let translateY;
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 the header y to the next header y: translate equally to scroll.
// This will cause the header to stay at the top of the scroll view.
// - Past the 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 indefinetly.
const inputRange = [-1, 0, layoutY];
const outputRange: Array<number> = [0, 0, 0];
if (nextHeaderLayoutY != null) {
inputRange.push(nextHeaderLayoutY, nextHeaderLayoutY + 1);
outputRange.push(nextHeaderLayoutY - layoutY, nextHeaderLayoutY - layoutY);
} else {
inputRange.push(layoutY + 1);
outputRange.push(1);
}
translateY = this.props.scrollAnimatedValue.interpolate({
inputRange,
outputRange,
});
} else {
translateY = 0;
}
const child = React.Children.only(this.props.children);
return (
<Animated.View
collapsable={false}
onLayout={this._onLayout}
style={[child.props.style, styles.header, {transform: [{translateY}]}]}>
{React.cloneElement(child, {
style: styles.fill,
})}
</Animated.View>
);
}
}
const styles = StyleSheet.create({
header: {
zIndex: 10,
},
fill: {
flex: 1,
},
});
module.exports = ScrollViewStickyHeader;