Linking Animations to Scroll Position in React Native

When you want to link a custom animation to the scroll position in a ScrollView, like in the card example below, you are in for some bad performance on low end devices. Let’s figure out why and learn how to make it buttery smooth.

Our animated result. Can we make it buttery smooth?

Let’s start with a simple example of a horizontal scrollview layout:

function CardView(props: { children?: ReactElement<*> }) {
return (
<ScrollView
horizontal
pagingEnabled
style={style.scrollView}>
{props.children}
</ScrollView>
)
}
function Page(props: { children?: ReactElement<*> }) {
return (
<View style={style.scrollPage}>
{props.children}
</View>
)
}
function Card(props: { text: string }) {
return (
<View style={style.card}>
<Text>{props.text}</Text>
</View>
);
}
export default function App() {
return (
<CardView>
<Page>
<Card text="Card 1"/>
</Page>
<Page>
<Card text="Card 2"/>
</Page>
<Page>
<Card text="Card 3"/>
</Page>
</CardView>
);
}

Scrolling the with paging between cards is fully handled at the native side.

Now add some animated behaviour linked to the scroll position. Our naive first implementation uses an Animated.Value to track our scroll position:

const xOffset = new Animated.Value(0);
const onScroll = Animated.event([{ nativeEvent: { contentOffset: { x: xOffset } } }]);
function CardView(props: { children?: ReactElement<*> }) {
return (
<ScrollView
scrollEventThrottle={16}
onScroll={onScroll}
horizontal
pagingEnabled
style={style.scrollView}>
{props.children}
</ScrollView>
)
}

The onScroll function receives a native event with contentOffset.x coming from the bridge and immediately sets it on xOffset. We add some rotate transformation to the cards:

function Card(props: { text: string, index: number }) {
return (
<Animated.View style={[style.card, rotateTransform(props.index)]}>
<Text>{props.text}</Text>
</Animated.View>
);
}
function rotateTransform(index: number) {
return {
transform: [{
rotate: xOffset.interpolate({
inputRange: [
(index - 1) * SCREEN_WIDTH,
index * SCREEN_WIDTH,
(index + 1) * SCREEN_WIDTH
],
outputRange: ['30deg', '0deg', '-30deg'],
})
}]
};
}

This already gives good performance on high-end devices, however there’s still a roundtrip involved from the native side to JavaScript core and back:

[N] Touch event
[N→JS] Serialized scroll event
[JS] Animated.Value update
[JS] Interpolate calculation
[JS] Update Animated.View props
[JS→N] Serialized view update events
[N] Draw rotated views

If you want to see the serialized messages passing over the bridge (the N→JS and JS→N events), you can turn on spying by adding:

import MessageQueue from 'MessageQueue';
MessageQueue.spy(true);

Then, using react-native log-ios or react-native log-android, you can spot the following cycle of events during scrolling:

N->JS : RCTEventEmitter.receiveTouches(["topTouchMove",[{"target":7,"pageY":234.5,"locationX":172.64353231693445,"locationY":221.2383309593456,"identifier":1,"pageX":98,"timestamp":86608013.782401}],[0]])
N->JS : RCTEventEmitter.receiveEvent([4,"topScroll",{"contentOffset":{"x":108,"y":0},"layoutMeasurement":{"width":320,"height":568},"contentSize":{"width":960,"height":568},"zoomScale":1,"updatedChildFrames":[],"contentInset":{"right":0,"top":0,"left":0,"bottom":0}}])
JS->N : UIManager.updateView([7,"RCTView",{"transform":[0.9844265680898916,-0.1757962799343545,0,0,0.1757962799343545,0.9844265680898916,0,0,0,0,1,0,0,0,0,1]}])
JS->N : UIManager.updateView([12,"RCTView",{"transform":[0.9404365560933549,0.33996923973099424,0,0,-0.33996923973099424,0.9404365560933549,0,0,0,0,1,0,0,0,0,1]}])
JS->N : UIManager.updateView([16,"RCTView",{"transform":[0.6444573283588975,0.7646402761590003,0,0,-0.7646402761590003,0.6444573283588975,0,0,0,0,1,0,0,0,0,1]}])

The topScroll event passes a new contentOffset which triggers multiple updateView events back to native (one for each card) to update the rotation transformation. Independently, the JS side is notified of touches via the topTouchMove event (as other components might be interested in the touch).

On older devices (iPhone 4S, Galaxy S4), when lots of scroll events are produced, the JS side cannot keep up with the flood of messages and there is a noticeably lag in view updates. The scrolling itself is smooth (because that’s handled in a native thread), but the derived animation is lagging behind and makes the app feel sluggish.

To overcome this, we want to make sure that all calculations are done on the native side. Since React Native 0.35, you can natively link animations to scroll events. So let’s use the not-yet-documented useNativeDriver flag that’s passed as second argument on the Animated.event call.

const onScroll = Animated.event(
[{ nativeEvent: { contentOffset: { x: xOffset } } }],
{ useNativeDriver: true }
);
function CardView(props: { children?: ReactElement<*> }) {
return (
<Animated.ScrollView
scrollEventThrottle={16}
onScroll={onScroll}
horizontal
pagingEnabled
style={style.scrollView}>
{props.children}
</Animated.ScrollView>
)
}

So what’s up with that Animated.ScrollView component? Well, now that Animated.event is using a native driver for setting the contentOffset.x value to xOffset, it returns an object instead of a function. This object holds a reference to the native animation instance with the above setter behaviour configured. However, the onScroll normally wants to receive a function, so we need to have this property accept natively-defined event handlers as well.

And that’s what Animated.ScrollView does: it’s a wrapped component that can handle Animated events as well. And, because we moved our onScroll event handling to native, React Native ensures that all involved Animated events are handled on the native side (but that’s something for another post).

So, with these small changes, our behaviour has changed to:

[N] Touch event
[N] Animated.Value update
[N] Interpolate calculation
[N] Update Animated.View props
[N] Draw rotated views

Yes, all animations are handled on the native side! No more unnecessary passing of events. To validate that this is actually true, we can reload our app, fling some cards around and read the output of the message queue:

N->JS : RCTEventEmitter.receiveTouches(["topTouchMove",[{"target":7,"pageY":213,"locationX":190.22080790495568,"locationY":201.87360073653593,"identifier":1,"pageX":118,"timestamp":93758994.95038901}],[0]])
N->JS : RCTEventEmitter.receiveTouches(["topTouchMove",[{"target":7,"pageY":214.5,"locationX":192.76217168365747,"locationY":205.1398516431106,"identifier":1,"pageX":109,"timestamp":93759011.40860301}],[0]])
N->JS : RCTEventEmitter.receiveTouches(["topTouchMove",[{"target":7,"pageY":217,"locationX":186.54802355286196,"locationY":207.36969150450616,"identifier":1,"pageX":95,"timestamp":93759032.160082}],[0]])
[...]

No more topScroll or updateView, these are fully handled at the native side. Hook up an older device and you’ll definitely see a great performance boost.

There are a few limitations at the time of writing. You can animate any style property of a component using Animated without the native driver. However, the current version of React Native (0.39) has a whitelist of properties you can animate natively. For now, it only works for opacity and all transform style properties, and not all config flags for interpolate are supported.

You can check out my example repo to see the working code. Or you could check out the official example from the React Native repo.