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:
parent
da04a6b1f3
commit
77b8c09727
|
@ -201,6 +201,7 @@ const styles = StyleSheet.create({
|
||||||
backgroundColor: '#eeeeee',
|
backgroundColor: '#eeeeee',
|
||||||
},
|
},
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
|
backgroundColor: '#eeeeee',
|
||||||
padding: 5,
|
padding: 5,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
|
|
|
@ -16,12 +16,22 @@ var AnimatedImplementation = require('AnimatedImplementation');
|
||||||
var Image = require('Image');
|
var Image = require('Image');
|
||||||
var Text = require('Text');
|
var Text = require('Text');
|
||||||
var View = require('View');
|
var View = require('View');
|
||||||
var ScrollView = require('ScrollView');
|
|
||||||
|
|
||||||
module.exports = {
|
let AnimatedScrollView;
|
||||||
...AnimatedImplementation,
|
|
||||||
|
const Animated = {
|
||||||
View: AnimatedImplementation.createAnimatedComponent(View),
|
View: AnimatedImplementation.createAnimatedComponent(View),
|
||||||
Text: AnimatedImplementation.createAnimatedComponent(Text),
|
Text: AnimatedImplementation.createAnimatedComponent(Text),
|
||||||
Image: AnimatedImplementation.createAnimatedComponent(Image),
|
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);
|
||||||
|
|
|
@ -2151,9 +2151,53 @@ type EventConfig = {
|
||||||
useNativeDriver?: bool,
|
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 {
|
class AnimatedEvent {
|
||||||
_argMapping: Array<?Mapping>;
|
_argMapping: Array<?Mapping>;
|
||||||
_listener: ?Function;
|
_listener: ?Function;
|
||||||
|
_attachedEvent: ?{
|
||||||
|
detach: () => void,
|
||||||
|
};
|
||||||
__isNative: bool;
|
__isNative: bool;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
@ -2162,6 +2206,7 @@ class AnimatedEvent {
|
||||||
) {
|
) {
|
||||||
this._argMapping = argMapping;
|
this._argMapping = argMapping;
|
||||||
this._listener = config.listener;
|
this._listener = config.listener;
|
||||||
|
this._attachedEvent = null;
|
||||||
this.__isNative = shouldUseNativeDriver(config);
|
this.__isNative = shouldUseNativeDriver(config);
|
||||||
|
|
||||||
if (__DEV__) {
|
if (__DEV__) {
|
||||||
|
@ -2172,44 +2217,13 @@ class AnimatedEvent {
|
||||||
__attach(viewRef, eventName) {
|
__attach(viewRef, eventName) {
|
||||||
invariant(this.__isNative, 'Only native driven events need to be attached.');
|
invariant(this.__isNative, 'Only native driven events need to be attached.');
|
||||||
|
|
||||||
// Find animated values in `argMapping` and create an array representing their
|
this._attachedEvent = attachNativeEvent(viewRef, eventName, this._argMapping);
|
||||||
// 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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
__detach(viewTag, eventName) {
|
__detach(viewTag, eventName) {
|
||||||
invariant(this.__isNative, 'Only native driven events need to be detached.');
|
invariant(this.__isNative, 'Only native driven events need to be detached.');
|
||||||
|
|
||||||
NativeAnimatedAPI.removeAnimatedEventFromView(viewTag, eventName);
|
this._attachedEvent && this._attachedEvent.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
__getHandler() {
|
__getHandler() {
|
||||||
|
@ -2582,5 +2596,11 @@ module.exports = {
|
||||||
*/
|
*/
|
||||||
createAnimatedComponent,
|
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,
|
__PropsOnlyForTests: AnimatedProps,
|
||||||
};
|
};
|
||||||
|
|
|
@ -11,6 +11,7 @@
|
||||||
*/
|
*/
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const Animated = require('Animated');
|
||||||
const ColorPropType = require('ColorPropType');
|
const ColorPropType = require('ColorPropType');
|
||||||
const EdgeInsetsPropType = require('EdgeInsetsPropType');
|
const EdgeInsetsPropType = require('EdgeInsetsPropType');
|
||||||
const Platform = require('Platform');
|
const Platform = require('Platform');
|
||||||
|
@ -18,6 +19,7 @@ const PointPropType = require('PointPropType');
|
||||||
const React = require('React');
|
const React = require('React');
|
||||||
const ReactNative = require('ReactNative');
|
const ReactNative = require('ReactNative');
|
||||||
const ScrollResponder = require('ScrollResponder');
|
const ScrollResponder = require('ScrollResponder');
|
||||||
|
const ScrollViewStickyHeader = require('ScrollViewStickyHeader');
|
||||||
const StyleSheet = require('StyleSheet');
|
const StyleSheet = require('StyleSheet');
|
||||||
const StyleSheetPropType = require('StyleSheetPropType');
|
const StyleSheetPropType = require('StyleSheetPropType');
|
||||||
const View = require('View');
|
const View = require('View');
|
||||||
|
@ -50,7 +52,7 @@ const requireNativeComponent = require('requireNativeComponent');
|
||||||
* ScrollView simply renders all its react child components at once. That
|
* ScrollView simply renders all its react child components at once. That
|
||||||
* makes it very easy to understand and use.
|
* makes it very easy to understand and use.
|
||||||
* On the other hand, this has a performance downside. Imagine you have a very
|
* 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 ScrollView’s
|
* 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,
|
* 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
|
* which may not even be shown, will contribute to slow rendering of your
|
||||||
* screen and increased memory usage.
|
* screen and increased memory usage.
|
||||||
|
@ -157,8 +159,8 @@ const ScrollView = React.createClass({
|
||||||
/**
|
/**
|
||||||
* The style of the scroll indicators.
|
* The style of the scroll indicators.
|
||||||
* - `default` (the default), same as `black`.
|
* - `default` (the default), same as `black`.
|
||||||
* - `black`, scroll indicator is black. This style is good against a white 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 black content background.
|
* - `white`, scroll indicator is white. This style is good against a dark background.
|
||||||
* @platform ios
|
* @platform ios
|
||||||
*/
|
*/
|
||||||
indicatorStyle: PropTypes.oneOf([
|
indicatorStyle: PropTypes.oneOf([
|
||||||
|
@ -227,7 +229,8 @@ const ScrollView = React.createClass({
|
||||||
/**
|
/**
|
||||||
* Called when scrollable content view of the ScrollView changes.
|
* 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
|
* It's implemented using onLayout handler attached to the content container
|
||||||
* which this ScrollView renders.
|
* which this ScrollView renders.
|
||||||
|
@ -372,10 +375,33 @@ const ScrollView = React.createClass({
|
||||||
|
|
||||||
mixins: [ScrollResponder.Mixin],
|
mixins: [ScrollResponder.Mixin],
|
||||||
|
|
||||||
|
_scrollAnimatedValue: (new Animated.Value(0): Animated.Value),
|
||||||
|
_scrollAnimatedValueAttachment: (null: ?{detach: () => void}),
|
||||||
|
_stickyHeaderRefs: (new Map(): Map<number, ScrollViewStickyHeader>),
|
||||||
|
|
||||||
getInitialState: function() {
|
getInitialState: function() {
|
||||||
return this.scrollResponderMixinGetInitialState();
|
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) {
|
setNativeProps: function(props: Object) {
|
||||||
this._scrollViewRef && this._scrollViewRef.setNativeProps(props);
|
this._scrollViewRef && this._scrollViewRef.setNativeProps(props);
|
||||||
},
|
},
|
||||||
|
@ -415,11 +441,14 @@ const ScrollView = React.createClass({
|
||||||
animated?: boolean
|
animated?: boolean
|
||||||
) {
|
) {
|
||||||
if (typeof y === 'number') {
|
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 {
|
} else {
|
||||||
({x, y, animated} = y || {});
|
({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});
|
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) {
|
_handleScroll: function(e: Object) {
|
||||||
if (__DEV__) {
|
if (__DEV__) {
|
||||||
if (this.props.onScroll && this.props.scrollEventThrottle == null && Platform.OS === 'ios') {
|
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
|
<ScrollContentContainerViewClass
|
||||||
{...contentSizeChangeProps}
|
{...contentSizeChangeProps}
|
||||||
ref={this._setInnerViewRef}
|
ref={this._setInnerViewRef}
|
||||||
style={contentContainerStyle}
|
style={contentContainerStyle}
|
||||||
removeClippedSubviews={this.props.removeClippedSubviews}
|
removeClippedSubviews={
|
||||||
|
hasStickyHeaders && Platform.OS === 'android' ? false : this.props.removeClippedSubviews
|
||||||
|
}
|
||||||
collapsable={false}>
|
collapsable={false}>
|
||||||
{this.props.children}
|
{children}
|
||||||
</ScrollContentContainerViewClass>;
|
</ScrollContentContainerViewClass>;
|
||||||
|
|
||||||
const alwaysBounceHorizontal =
|
const alwaysBounceHorizontal =
|
||||||
|
@ -560,23 +647,26 @@ const ScrollView = React.createClass({
|
||||||
// Override the onContentSizeChange from props, since this event can
|
// Override the onContentSizeChange from props, since this event can
|
||||||
// bubble up from TextInputs
|
// bubble up from TextInputs
|
||||||
onContentSizeChange: null,
|
onContentSizeChange: null,
|
||||||
onTouchStart: this.scrollResponderHandleTouchStart,
|
|
||||||
onTouchMove: this.scrollResponderHandleTouchMove,
|
|
||||||
onTouchEnd: this.scrollResponderHandleTouchEnd,
|
|
||||||
onScrollBeginDrag: this.scrollResponderHandleScrollBeginDrag,
|
|
||||||
onScrollEndDrag: this.scrollResponderHandleScrollEndDrag,
|
|
||||||
onMomentumScrollBegin: this.scrollResponderHandleMomentumScrollBegin,
|
onMomentumScrollBegin: this.scrollResponderHandleMomentumScrollBegin,
|
||||||
onMomentumScrollEnd: this.scrollResponderHandleMomentumScrollEnd,
|
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,
|
onStartShouldSetResponder: this.scrollResponderHandleStartShouldSetResponder,
|
||||||
onStartShouldSetResponderCapture: this.scrollResponderHandleStartShouldSetResponderCapture,
|
onStartShouldSetResponderCapture: this.scrollResponderHandleStartShouldSetResponderCapture,
|
||||||
onScrollShouldSetResponder: this.scrollResponderHandleScrollShouldSetResponder,
|
onTouchEnd: this.scrollResponderHandleTouchEnd,
|
||||||
onScroll: this._handleScroll,
|
onTouchMove: this.scrollResponderHandleTouchMove,
|
||||||
onResponderGrant: this.scrollResponderHandleResponderGrant,
|
onTouchStart: this.scrollResponderHandleTouchStart,
|
||||||
onResponderTerminationRequest: this.scrollResponderHandleTerminationRequest,
|
scrollEventThrottle: hasStickyHeaders ? 1 : this.props.scrollEventThrottle,
|
||||||
onResponderTerminate: this.scrollResponderHandleTerminate,
|
sendMomentumEvents: (this.props.onMomentumScrollBegin || this.props.onMomentumScrollEnd) ?
|
||||||
onResponderRelease: this.scrollResponderHandleResponderRelease,
|
true : false,
|
||||||
onResponderReject: this.scrollResponderHandleResponderReject,
|
stickyHeaderIndices: null,
|
||||||
sendMomentumEvents: (this.props.onMomentumScrollBegin || this.props.onMomentumScrollEnd) ? true : false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const { decelerationRate } = this.props;
|
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') {
|
if (Platform.OS === 'android') {
|
||||||
nativeOnlyProps = {
|
nativeOnlyProps = {
|
||||||
nativeOnly: {
|
nativeOnly: {
|
||||||
sendMomentumEvents: true,
|
sendMomentumEvents: true,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
AndroidScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps);
|
AndroidScrollView = requireNativeComponent(
|
||||||
|
'RCTScrollView',
|
||||||
|
(ScrollView: ReactClass<*>),
|
||||||
|
nativeOnlyProps
|
||||||
|
);
|
||||||
AndroidHorizontalScrollView = requireNativeComponent(
|
AndroidHorizontalScrollView = requireNativeComponent(
|
||||||
'AndroidHorizontalScrollView',
|
'AndroidHorizontalScrollView',
|
||||||
ScrollView,
|
(ScrollView: ReactClass<*>),
|
||||||
nativeOnlyProps
|
nativeOnlyProps
|
||||||
);
|
);
|
||||||
} else if (Platform.OS === 'ios') {
|
} else if (Platform.OS === 'ios') {
|
||||||
|
@ -658,7 +756,11 @@ if (Platform.OS === 'android') {
|
||||||
onScrollEndDrag: true,
|
onScrollEndDrag: true,
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
RCTScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps);
|
RCTScrollView = requireNativeComponent(
|
||||||
|
'RCTScrollView',
|
||||||
|
(ScrollView: ReactClass<*>),
|
||||||
|
nativeOnlyProps,
|
||||||
|
);
|
||||||
RCTScrollContentView = requireNativeComponent('RCTScrollContentView', View);
|
RCTScrollContentView = requireNativeComponent('RCTScrollContentView', View);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
Loading…
Reference in New Issue