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',
|
||||
},
|
||||
sectionHeader: {
|
||||
backgroundColor: '#eeeeee',
|
||||
padding: 5,
|
||||
fontWeight: '500',
|
||||
fontSize: 11,
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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 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,
|
||||
* 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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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