Demystifying React Native’s Animated API: Part 2

Simon Steer
Drop Engineering
Published in
8 min readFeb 8, 2021

--

Please note that this is a follow up to part one of this series, and we will be building on top of previously discussed concepts such as the interpolation model, when to use certain React hooks with the Animated API, etc. If you did read it, already have a decent understanding of the Animated API, or don’t care either way and just want to see some stuff animate, let’s get to it!

Throughout this blog post, we are going to build this confetti animation step-by-step from start to finish using the Animated API 🎊

Hopefully by the time you are done reading, you not only will have a more advanced understanding of the Animated API, but will also be better equipped to break down animations into small, understandable steps so you can avoid spaghetti code 🍝

To simplify our mental model of what confetti is, we can think of confetti as a bunch of little rectangles that float down from the top of the screen to the bottom. We can start by rendering these rectangles at the top of the screen, without worrying about how to animate them for the time being:

import React from "react"
import { Animated, StyleSheet, View } from "react-native"
const CONFETTI_COLORS = ["#623cea", "#ffe66d", "#06d6a0", "#cc3363", "#00bbf9"]export default function Confetti({
amount = 60
}: {
amount?: number
}) {
return (
<View pointerEvents="none" style={styles.confettiContainer}>
{[...Array(amount)].map((_, index) => {
const backgroundColor =
CONFETTI_COLORS[index % CONFETTI_COLORS.length]
return (
<Animated.View
key={index}
style={[
styles.confettiBit,
{ backgroundColor }
]}
/>
)
})}
</View>
)
}
const styles = StyleSheet.create({
confettiContainer {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center"
},
confettiBit: {
position: "absolute",
top: 0,
left: 0,
width: 25,
height: 10,
borderRadius: 7
}
})

Rendering our component should result in the confetti all packed into the top-left corner, barely visible on the simulator:

We should reposition each confetti bit so they are distributed across the width of the screen instead of in the same area. Let’s translate our ConfettiBit components across the x-axis.

export default function Confetti({
amount = 60
}: {
amount?: number
}) {
const dimensions = Dimensions.get("screen")
return (
<View pointerEvents="none" style={styles.confettiContainer}>
{[...Array(amount)].map((_, index) => {
const backgroundColor =
CONFETTI_COLORS[index % CONFETTI_COLORS.length]
const startX = Math.random() * dimensions.width

return (
<Animated.View
key={index}
style={[
styles.confettiBit,
{
backgroundColor,
transform: [{ translateX: startX }]
}
]}
/>
)
})}
</View>
)
}

Our confetti will now render across the top of the device’s screen:

Now that our confetti is evenly distributed, we should animate it floating to the bottom of the screen. Let’s create an Animated.Value to represent the timeline of our animation, then interpolate that value to represent the y-translation we would like each confetti to undergo. We can do the same with the x-translation so the confetti doesn’t move in a perfectly vertical line. Let’s also add a function to animate our value from 1 → 2, and a button so we can test the animation:

export default function Confetti({
amount = 60
}: {
amount?: number
}) {
const dimensions = Dimensions.get("screen")
const animate = () => {
animatedValue.setValue(0)
Animated.timing(animatedValue, {
toValue: 1,
useNativeDriver: true,
easing: Easing.linear,
duration: 3000
}).start()
}
return (
<View pointerEvents="none" style={styles.confettiContainer}>
{[...Array(amount)].map((_, index) => {
const backgroundColor =
CONFETTI_COLORS[index % CONFETTI_COLORS.length]
const startX = Math.random() * dimensions.width
const endX = Math.random() * dimensions.width
const translateX = animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [startX, endX]
})
const startY = 0
const endY = dimensions.height
const translateY = animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [startY, endY]
})

return (
<Animated.View
key={index}
style={[
styles.confettiBit,
{
backgroundColor,
transform: [{ translateX }, { translateY }]
}
]}
/>
)
})}
<Button title="test animation" onPress={animate} />
</View>
)
}

So far so good. Obviously this doesn’t look like confetti just yet, but we have the basic timeline and movement of our animation down. Now comes the fun part where we add more details to our animation to breathe some life into it. We can start by having each confetti bit undergo a random number of rotations throughout the animation. We can also do some rotations along the x-axis to give the illusion that each confetti bit is flipping mid-air.

// inside our .map() callback...// start at a random angle between 0°-360°
// end after 2-10 rotations
const rotateStart = Math.random() * 360
const rotateEnd =
rotateStart + 720 + Math.random() * 8 * 360
const rotate = animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [rotateStart + 'deg', rotateEnd + 'deg']
})
// start at a random angle between 0°-360°
// end after 1-20 rotations along the x-axis
const rotateXStart = Math.random() * 360
const rotateXEnd =
rotateStart + 360 + Math.random() * 19 * 360
const rotateX = animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [rotateXStart + 'deg', rotateXEnd + 'deg']
})
return (
<Animated.View
key={index}
style={[
styles.confettiBit,
{
backgroundColor,
transform: [
{ translateX },
{ translateY },
{ rotate },
{ rotateX }
]
}
]}
/>
)

Now we have each of our confetti bits distributed across the x-axis at the start of our animation, and moving down the y-axis, and flipping a random number of times throughout the animation. We do have one problem, though — all the confetti floats down the screen at the same rate! Real confetti doesn’t move like this; each piece should float down at differing speeds. We can achieve this by adjusting each confetti’s startTranslateY and endTranslateY values by a random amount between the value of the longest edge of each confetti bit (to ensure it starts and ends offscreen) and the height of the device. This will result in each confetti bit traveling across a different distance as it travels down the screen, but over the same amount of time. This should result in each confetti bit moving at variable speeds.

// start at 0
// subtract confetti's longest edge (width)
// offset again by a random amount between 0 - device height
let startY = 0
startY -= styles.confettiBit.width
startY -= Math.random() * dimensions.height
// start with screen height
// add confetti's longest edge (width)
// add random amount between 0 - screen height
let endY = dimensions.height
endY += styles.confettiBit.width
endY += Math.random() * dimensions.height

Now that we’ve we increased the average distance our confetti travels, 3000ms doesn’t feel quite long enough for our animation. Let’s increase the duration to 8000ms, and change up the easing a bit.

// update our animate function
// increase timing
// change easing function
const animate = () => {
animatedValue.setValue(0)
Animated.timing(animatedValue, {
toValue: 1,
useNativeDriver: true,
easing: Easing.out(Easing.quad),
duration: 8000
}).start()
}

Now that we have everything looking the way we want, let’s clean up our code a bit. To start, we can abstract each confetti bit we render into a single component:

const CONFETTI_COLORS = ["#623cea", "#ffe66d", "#06d6a0", "#cc3363", "#00bbf9"]function ConfettiBit({
index,
animatedValue
}: {
index: number;
animatedValue: Animated.Value
}) {
// store style in state so subsequent renders
// don't result in new random values from Math.random()
const [style] = useState(() => {
const dimensions = Dimensions.get("screen")
const backgroundColor =
CONFETTI_COLORS[index % CONFETTI_COLORS.length]
const startX = Math.random() * dimensions.width
const endX = Math.random() * dimensions.width
const translateX = animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [startX, endX]
})
let startY = 0
startY -= styles.confettiBit.width
startY -= Math.random() * dimensions.height

let endY = dimensions.height
endY += styles.confettiBit.width
endY += Math.random() * dimensions.height

const translateY = animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [startX, endX]
})
const rotateStart = Math.random() * 360
const rotateEnd =
rotateStart + 720 + Math.random() * 8 * 360
const rotate = animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [rotateStart + 'deg', rotateEnd + 'deg']
})
const rotateXStart = Math.random() * 360
const rotateXEnd =
rotateStart + 360 + Math.random() * 19 * 360
const rotateX = animatedValue.interpolate({
inputRange: [0, 1],
outputRange: [rotateXStart + 'deg', rotateXEnd + 'deg']
})
return [
styles.confettiBit,
{
backgroundColor,
transform: [
{ translateX },
{ translateY },
{ rotate },
{ rotateX }
]
}
]
})
return <Animated.View style={style}/>
}

This lets us update our Confetti component to be a bit more compact. We can also remove the button we were using to test the animation.

function Confetti({
numConfetti = 60
}: {
numConfetti?: number
}) {
const animatedValue = useRef(new Animated.Value(0)).current
const
animate = () =>
Animated.timing(animatedValue, {
toValue: 1,
easing: Easing.out(Easing.quad),
duration: 8000,
useNativeDriver: false
}).start(() => {
animatedValue.setValue(0)
})
return (
<View pointerEvents="none" style={styles.confettiContainer}>
{[...Array(numConfetti)].map((_, index) => (
<ConfettiBit
key={index}
index={index}
animatedValue={animatedValue}
/>
))}
</View>
)
}

As neat and tidy as this code is, we still have one thing left to consider; how would a developer use this animated confetti component? Most likely, we would associate the animation with an achievement — maybe a user just completed onboarding, or opened the app on their birthday, for example. Currently, we only have access to the animate function inside of our Confetti component, which makes it difficult for us to interact with and trigger animations arbitrarily from outside of it. We can solve this elegantly by turning our confetti animation into a hook:

function useConfetti(numConfetti = 60) {
const animatedValue = useRef(new Animated.Value(0)).current
const
animate = () =>
Animated.timing(animatedValue, {
toValue: 1,
easing: Easing.out(Easing.quad),
duration: 8000,
useNativeDriver: false
}).start(() => {
animatedValue.setValue(0)
})
const confetti = (
<View pointerEvents="none" style={styles.confettiContainer}>
{[...Array(numConfetti)].map((_, index) => (
<ConfettiBit
key={index}
index={index}
animatedValue={animatedValue}
/>
))}
</View>
)

return [confetti, animate] as const
}

Now, instead of being locked into fiddling around with the internals of our Confetti component, we can just use our hook 🤩

function App() {
const [confetti, animateConfetti] = useConfetti()
return (
<View style={{ flex: 1 }}>
<ScrollView>
<Text style={{
fontSize: 48,
marginVertical: 64,
textAlign: "center"
}}>
Happy Birthday
</Text>
<Button title="let's party" onPress={animateConfetti} />
</ScrollView>
{confetti}
</View>
)
}

And there you have it! We could go even further and have some of the “magic numbers” (number of flips, etc.) in our style code be configurable via arguments to our hook function, but I’ll leave it there for the purposes of this blog post before this gets even longer.

Thanks for reading! will there be a part 3 to this series? Possibly 😉 stay tuned!

--

--