Skip to content
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

Implement sticky headers in JS using Native Animated #11315

Closed

Conversation

janicduplessis
Copy link
Contributor

@janicduplessis janicduplessis commented Dec 6, 2016

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.

@facebook-github-bot
Copy link
Contributor

By analyzing the blame information on this pull request, we identified @sahrens and @emilsjolander to be potential reviewers.

@facebook-github-bot facebook-github-bot added GH Review: review-needed CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. labels Dec 6, 2016
};

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.

@nihgwu
Copy link
Contributor

nihgwu commented Dec 6, 2016

It's interesting you are into the JS way to implement stickyHeader, why do you want to remove the native iOS implementation since it works well now, and why do you give up the former native Android implementation, I've been waiting this feature for so long

@janicduplessis
Copy link
Contributor Author

There is no point in keeping the iOS implementation since that will replace it. I tested that it behaves the exact same way so you should not see any difference on iOS.

The Android implementation required a lot of changes because the view hierachy in react is different from the one in native because of the view hierarchy optimization that we do on Android so working with view indices doesn't work well at all. It would have been possible but after thinking about it more and now that native animations are a lot more stable this makes more sense.

@janicduplessis
Copy link
Contributor Author

Also it uses native animations so it won't really be slower than the native implementation if that was your concern.

@nihgwu
Copy link
Contributor

nihgwu commented Dec 6, 2016

I know that and great thanks for your works on this 👍 , and I have a new issue for your 😄

@brentvatne
Copy link
Collaborator

@nihgwu - if it is very important to you, you're welcome to contribute too ;) no need to wait for other people to do things for you

@ide
Copy link
Contributor

ide commented Dec 7, 2016

++ Also another way to contribute is to sponsor development of the feature.

@nihgwu
Copy link
Contributor

nihgwu commented Dec 8, 2016 via email

@nihgwu
Copy link
Contributor

nihgwu commented Jan 6, 2017

any progress on this?

@jeanregisser
Copy link
Contributor

Nice work @janicduplessis! 👍

I've implemented sticky headers with a similar technique as the one you used here (but not as generic as what you did) and noticed the issue with momentum scrolling on Android. No issue on iOS though.
If I leave my finger on the scrollview and move it around, everything is smooth.
But once I lift my finger and momentum kicks in, the headers positions are late / incorrect until the movement stops completely.
i.e. it shows an ugly jump of the headers.

Any hint on where to start tracking this issue? I'm very confortable with iOS development, but quite a beginner on Android.

@janicduplessis
Copy link
Contributor Author

Yea that is mainly what is blocking this PR at the moment, I will try to have a look at this this week.

@nihgwu
Copy link
Contributor

nihgwu commented Jan 11, 2017

some bugs on iOS:

  1. sticky header will bounce when there are layout animations in rows
  2. when you over pull over the scrollview, the sticky header will stay sticky instead of move with the scrollview

on Android:

  1. current implementation utilizes zIndex witch hasn't been supported by Nodes introduced in RN0.41

@jeanregisser
Copy link
Contributor

Interesting @nihgwu.
I've seen the jump of the sticky headers when using LayoutAnimation only on Android sofar.

@janicduplessis
Copy link
Contributor Author

Yea I think the bug with layout animation is on Android only and has always been there, more or less related to this PR.

@nihgwu
Copy link
Contributor

nihgwu commented Jan 11, 2017

sorry, my mistake, layout animation issue exists on Android only
I'm using Nodes now, so I'm willing to see a implementation to support it, that is not using the zIndex but nature hierarchy, would it be possible?

@nihgwu
Copy link
Contributor

nihgwu commented Jan 11, 2017

I've to say that I've tried to solve the momentum issue, but seems sendMomentumEvents can't be set to false and I have no idea.

@janicduplessis
Copy link
Contributor Author

Node is still experimental and will eventually support z-index so I'm not too worried at the moment.

@sahrens
Copy link
Contributor

sahrens commented Jan 17, 2017

@ide: are you or anyone else reviewing this?

@janicduplessis
Copy link
Contributor Author

@sahrens I don't think anyone is reviewing this at the moment, right now iOS works really well but android has a few bugs with events during momentum scrolling and view clipping. The JS code shouldn't change so feel free to review if you want :)

Copy link
Contributor

@sahrens sahrens left a comment

Choose a reason for hiding this comment

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

This is looking sweet :)

How are those known bugs going? Are you planning on fixing them in this diff or followups? How much have you tested this?

};

Object.assign((Animated: Object), AnimatedImplementation);
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?

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.

@@ -2152,6 +2196,7 @@ class AnimatedEvent {
) {
this._argMapping = argMapping;
this._listener = config.listener;
this._attachment = null;
Copy link
Contributor

Choose a reason for hiding this comment

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

_attachment is a non-intuitive name for me - something like _attachedEvent or _nativeEvent might be better?

ref={(ref) => this._setStickyHeaderRef(index, ref)}
onLayout={(event) => this._onStickyHeaderLayout(index, event)}
scrollAnimatedValue={this._scrollAnimatedValue}
>
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: should stay on prev line

},

_onStickyHeaderLayout: function(index, event) {
const previousHeaderIndice = this.props.stickyHeaderIndices[this.props.stickyHeaderIndices.indexOf(index) - 1];
Copy link
Contributor

Choose a reason for hiding this comment

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

s/Indice/Index

inputRange.push(layoutY + 1);
outputRange.push(1);
}
translateY = this.props.scrollAnimatedValue.interpolate({
Copy link
Contributor

Choose a reason for hiding this comment

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

All this interpolate range computation is pretty complicated and non-intuitive - can you add some comments, maybe a comment block, explaining the logic?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added a comment, let me know if that makes sense.

Copy link
Contributor

Choose a reason for hiding this comment

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

Very helpful.


const styles = StyleSheet.create({
header: {
zIndex: 10,
Copy link
Contributor

Choose a reason for hiding this comment

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

Anything less arbitrary/more robust you can do here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't really have other ideas except maybe exporting this as a constant somewhere.

const hasStickyHeaders = this.props.stickyHeaderIndices && this.props.stickyHeaderIndices.length > 0;

const children = hasStickyHeaders ?
React.Children.toArray(this.props.children).map((child, index) => {
Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I did try using React.Children.map but it doesn't work, looks like indices are different. My guess is that toArray handles null / nested children differently and that's the behavior we want here.

@janicduplessis
Copy link
Contributor Author

Ok, I figured out the lag during momentum scrolling on android, fix in #11994

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

@@ -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.

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

@janicduplessis
Copy link
Contributor Author

janicduplessis commented Feb 13, 2017

@sahrens I noticed you talked about FlatList not supporting sections and creating a separate component that support this. I was thinking we could just hold off on this PR and keep the native iOS only impl for ListView and move to something like this for the new FlatList that support sections.

@sahrens
Copy link
Contributor

sahrens commented Feb 13, 2017

I believe FlatList is orthogonal - it's still just going to set stickyHeaderIndices on the ScrollView, and it can work with the current impl or the Animated-based one here (I just wanted to make sure it works with this approach since this is probably the future). The main value of these diffs is to add Android support for sticky headers, which isn't that important to us internally.

@astreet/ @mkonicek: any progess on those test failures?

facebook-github-bot pushed a commit that referenced this pull request Feb 20, 2017
Summary:
Native animated events sometimes end up lagging a frame behind on android because we perform the update in the normal animation loop instead of doing it immediately when we receive the event. We had the same issue on iOS and was fixed in a similar way.

Moved some code around to have a method that updates a list of node that we can use to update the node in the animated event handler and also use it in the animation update loop.

**Test plan**
Tested that it did fix sticky headers lagging a frame behind during momentum scrolling in my PR #11315 and also tested the native animations examples still work properly.
Closes #11994

Reviewed By: mkonicek

Differential Revision: D4488977

Pulled By: sahrens

fbshipit-source-id: 831a1565bc7b8fa88cadd5a8c1be876fbdefbf66
GaborWnuk pushed a commit to GaborWnuk/react-native that referenced this pull request Feb 28, 2017
Summary:
Native animated events sometimes end up lagging a frame behind on android because we perform the update in the normal animation loop instead of doing it immediately when we receive the event. We had the same issue on iOS and was fixed in a similar way.

Moved some code around to have a method that updates a list of node that we can use to update the node in the animated event handler and also use it in the animation update loop.

**Test plan**
Tested that it did fix sticky headers lagging a frame behind during momentum scrolling in my PR facebook#11315 and also tested the native animations examples still work properly.
Closes facebook#11994

Reviewed By: mkonicek

Differential Revision: D4488977

Pulled By: sahrens

fbshipit-source-id: 831a1565bc7b8fa88cadd5a8c1be876fbdefbf66
dudeinthemirror pushed a commit to dudeinthemirror/react-native that referenced this pull request Mar 1, 2017
Summary:
Native animated events sometimes end up lagging a frame behind on android because we perform the update in the normal animation loop instead of doing it immediately when we receive the event. We had the same issue on iOS and was fixed in a similar way.

Moved some code around to have a method that updates a list of node that we can use to update the node in the animated event handler and also use it in the animation update loop.

**Test plan**
Tested that it did fix sticky headers lagging a frame behind during momentum scrolling in my PR facebook#11315 and also tested the native animations examples still work properly.
Closes facebook#11994

Reviewed By: mkonicek

Differential Revision: D4488977

Pulled By: sahrens

fbshipit-source-id: 831a1565bc7b8fa88cadd5a8c1be876fbdefbf66
dudeinthemirror pushed a commit to dudeinthemirror/react-native that referenced this pull request Mar 1, 2017
Summary:
Native animated events sometimes end up lagging a frame behind on android because we perform the update in the normal animation loop instead of doing it immediately when we receive the event. We had the same issue on iOS and was fixed in a similar way.

Moved some code around to have a method that updates a list of node that we can use to update the node in the animated event handler and also use it in the animation update loop.

**Test plan**
Tested that it did fix sticky headers lagging a frame behind during momentum scrolling in my PR facebook#11315 and also tested the native animations examples still work properly.
Closes facebook#11994

Reviewed By: mkonicek

Differential Revision: D4488977

Pulled By: sahrens

fbshipit-source-id: 831a1565bc7b8fa88cadd5a8c1be876fbdefbf66
@sahrens
Copy link
Contributor

sahrens commented Mar 2, 2017

Finally trying to land this again...wish it luck!

@nihgwu
Copy link
Contributor

nihgwu commented Mar 2, 2017

land to where?

@sahrens
Copy link
Contributor

sahrens commented Mar 2, 2017

Fixed the internal test this was breaking so this should be in master soon!

@janicduplessis
Copy link
Contributor Author

🎉 Thanks @sahrens for working on landing this!

@janicduplessis
Copy link
Contributor Author

@sahrens I just recalled that when landing the first version of this that was implemented natively (and that was reverted because of some bugs), having sticky headers enabled by default in ListView caused some issues for product teams at FB, since it's a breaking change in term of UI behavior. I think a good solution would be to make sticky section headers disabled by default on Android and enabled on iOS (https://github.com/facebook/react-native/blob/master/Libraries/CustomComponents/ListView/ListView.js#L333).

@sahrens
Copy link
Contributor

sahrens commented Mar 3, 2017

Ah yeah, totally forgot we were going to make that change.

@sahrens
Copy link
Contributor

sahrens commented Mar 3, 2017

Change to the default should go out soon.

@janicduplessis
Copy link
Contributor Author

@sahrens Cool, thanks!

@sahrens
Copy link
Contributor

sahrens commented Mar 3, 2017 via email

@janicduplessis
Copy link
Contributor Author

Hmm there might be cases where headers overlap? We'll have to safeguard against negative interpolations.

@sahrens
Copy link
Contributor

sahrens commented Mar 3, 2017

Seeing this in some tests:

inputRange must be monotonically increasing -1,0,0,-33,-32

@janicduplessis
Copy link
Contributor Author

janicduplessis commented Mar 3, 2017

Looks like some headers have negative y coords, the code currently doesn't handle these properly. https://github.com/facebook/react-native/blob/master/Libraries/Components/ScrollView/ScrollViewStickyHeader.js#L63

@janicduplessis
Copy link
Contributor Author

I'm not sure if just making sure the y coord is > 0 would work properly, let me know if you find what the failing test looks like.

facebook-github-bot pushed a commit that referenced this pull request Mar 3, 2017
Summary:
They aren't normal for android, so don't make that the default.

some more context: #11315 (comment)

Reviewed By: furdei

Differential Revision: D4648714

fbshipit-source-id: 3232a6914e3db82c2b300409663ce63412d892a6
rozele pushed a commit to microsoft/react-native-windows that referenced this pull request Mar 6, 2017
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 facebook/react-native#11315

Differential Revision: D4450278

Pulled By: sahrens

fbshipit-source-id: fec8da2cffce9807d74f8e518ebdefeb6a708667
@jasan-s
Copy link

jasan-s commented Mar 10, 2017

is the latest commit still failing test? Are sticky list section header working in android?

@sahrens
Copy link
Contributor

sahrens commented Mar 10, 2017

This should be all fixed and landed in master slated for v44 release around April. Note that behavior is slightly changed for headers because we wrap them in an Animated.View, so for example onLayout inside your header may give different results after this change.

dojiboy9 pushed a commit to dojiboy9/react-native that referenced this pull request Mar 18, 2017
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 facebook#11315

Differential Revision: D4450278

Pulled By: sahrens

fbshipit-source-id: fec8da2cffce9807d74f8e518ebdefeb6a708667
dojiboy9 pushed a commit to dojiboy9/react-native that referenced this pull request Mar 18, 2017
Summary:
They aren't normal for android, so don't make that the default.

some more context: facebook#11315 (comment)

Reviewed By: furdei

Differential Revision: D4648714

fbshipit-source-id: 3232a6914e3db82c2b300409663ce63412d892a6
@chrisknepper
Copy link
Contributor

This appears to have been merged and released in 0.44. Is there any reason this (rewrite/Android support) was not mentioned in the release notes?

@janicduplessis
Copy link
Contributor Author

Probably slipped through, let me see if I can edit release notes.

@dgruseck
Copy link

dgruseck commented May 3, 2017

Works perfectly on Android and iOS.
Thank you very much, waited a long time for this.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.
Projects
None yet
Development

Successfully merging this pull request may close these issues.