Skip to content

Implement sticky headers in JS using Native Animated #11315

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Examples/UIExplorer/js/UIExplorerExampleList.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ const styles = StyleSheet.create({
backgroundColor: '#eeeeee',
},
sectionHeader: {
backgroundColor: '#eeeeee',
padding: 5,
fontWeight: '500',
fontSize: 11,
Expand Down
18 changes: 14 additions & 4 deletions Libraries/Animated/src/Animated.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do you even need to create and export this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Native animated events do need a wrapper for ScrollView / ListView this is the reason it is exported here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When is this getter accessed though? I don't see any code in this diff that would call it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't used in this diff but we export it as part of the public api like Animated.View. Had to make this change just because this diff creates a circular reference since ScrollView now imports animated.

// Make this lazy to avoid circular reference.
if (!AnimatedScrollView) {
AnimatedScrollView = AnimatedImplementation.createAnimatedComponent(require('ScrollView'));
}
return AnimatedScrollView;
},
};

Object.assign((Animated: Object), AnimatedImplementation);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to express this properly in flow? I want to add props to an object using Object.assign. Can't create a new object here because it breaks the getter.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just include AnimatedImplementation in the initial Object creation?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sahrens Using { ...AnimatedImplementation, ... } will compile to Object.assign({}, AnimatedImplementation, ...) but there seems to be an issue with getters on iOS, the getter doesn't get assigned but it's value is instead, losing the getter altogether.

This is why I used Object.assign(Animated, AnimatedImplementation) instead to avoid having to copy Animated props and getters get preserved properly. However doing that causes a bunch of flow issues because we are assigning props that don't exist on the initial object.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant as a field, like Animated.AnimatedImplementation.createAnimatedComponent. Not a big deal.


module.exports = ((Animated: any): (typeof AnimatedImplementation) & typeof Animated);
86 changes: 53 additions & 33 deletions Libraries/Animated/src/AnimatedImplementation.js
Original file line number Diff line number Diff line change
Expand Up @@ -2148,9 +2148,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));
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

array type This type is incompatible with the expected param type of object type

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this bot drunk? Do you know who owns it? @vjeux or @mkonicek?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No there were some flow errors at some point, doesn't show as outdated diff because I changed some other line to fix it.

};

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 = 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(
Expand All @@ -2159,6 +2203,7 @@ class AnimatedEvent {
) {
this._argMapping = argMapping;
this._listener = config.listener;
this._attachedEvent = null;
this.__isNative = shouldUseNativeDriver(config);

if (this.__isNative) {
Expand All @@ -2173,44 +2218,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 = 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() {
Expand Down Expand Up @@ -2470,5 +2484,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,
};
119 changes: 102 additions & 17 deletions Libraries/Components/ScrollView/ScrollView.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
*/
'use strict';

const Animated = require('Animated');
const ColorPropType = require('ColorPropType');
const EdgeInsetsPropType = require('EdgeInsetsPropType');
const Platform = require('Platform');

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

access of computed property/element Computed property/element cannot be accessed on possibly undefined value undefined

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

call of method indexOf Method cannot be called on possibly undefined value undefined

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');
Expand Down Expand Up @@ -353,10 +355,33 @@ const ScrollView = React.createClass({

mixins: [ScrollResponder.Mixin],

_scrollAnimatedValue: (new Animated.Value(0): Animated.Value),
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there another way to declare instance variables with createClass that are not nullable? I can't just initialize these values here because otherwise the same object gets used for every instance so I have to do it in componentWillMount. As a workaround I'm just creating these values to be able to make the type not nullable.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that is a limitation of createClass

_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);
},
Expand Down Expand Up @@ -411,6 +436,40 @@ 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') {
Expand Down Expand Up @@ -470,14 +529,38 @@ const ScrollView = React.createClass({
};
}

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 =
<View
{...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}
</View>;

const alwaysBounceHorizontal =
Expand All @@ -499,23 +582,25 @@ 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,
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,
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,
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;
Expand Down Expand Up @@ -597,10 +682,10 @@ if (Platform.OS === 'android') {
sendMomentumEvents: true,
}
};
AndroidScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps);
AndroidScrollView = requireNativeComponent('RCTScrollView', (ScrollView: ReactClass<any>), nativeOnlyProps);
AndroidHorizontalScrollView = requireNativeComponent(
'AndroidHorizontalScrollView',
ScrollView,
(ScrollView: ReactClass<any>),
nativeOnlyProps
);
} else if (Platform.OS === 'ios') {
Expand All @@ -612,7 +697,7 @@ if (Platform.OS === 'android') {
onScrollEndDrag: true,
}
};
RCTScrollView = requireNativeComponent('RCTScrollView', ScrollView, nativeOnlyProps);
RCTScrollView = requireNativeComponent('RCTScrollView', (ScrollView: ReactClass<any>), nativeOnlyProps);
}

module.exports = ScrollView;
Loading