Back to Blog

Custom Layout Animations with Reanimated - Part 2

Unleash the power of mind-blowing Custom Layout Animations and captivate your users' gaze

February 3rd, 2024

5 min read

by @rgommezz

Photo by Chris Liverani on Unsplash

Introduction

In part 1 of this series, I walked you through the various avenues for crafting Layout Animations in React Native. You peeked under the hood of the LayoutAnimation core module and poked around some of the limitations it hits.

But, as with all things tech, evolution was inevitable. Reanimated v2/v3 burst onto the scene, reviving the splendor of declarative animations by embracing the React component model like it was meant to be.

At the end, I dropped a challenge in front of you, where a run-of-the-mill preset animation just wouldn't cut it.

If you haven't already, do yourself a favour and give the previous article a once-over. It'll make tagging along a lot smoother!

Alright, buckle up, folks! This is where the fun begins.

The Challenge

Let's kick things off by creating the different components that compose the counter screen.

The App component contains a counter variable as local state, a Text to display it, and a Button to crank that number up. The remaining elements are just for layout and styling purposes.

import React, { useState } from "react"; import { View, StyleSheet, Button, Text } from "react-native"; export default function App() { const [counter, setCounter] = useState(0); return ( <View style={styles.container}> <View style={styles.counterContainer}> <Text style={styles.counter}>{counter}</Text> </View> <View style={styles.buttonContainer}> <Button title="Increment" onPress={() => setCounter((c) => c + 1)} /> </View> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: "center", alignItems: "center", }, counterContainer: { padding: 32, }, counter: { fontSize: 48, fontWeight: "bold", fontVariant: ["tabular-nums"], width: 100, textAlign: "center", }, buttonContainer: { position: "absolute", backgroundColor: "white", bottom: 16, left: 64, right: 64, }, });
import React, { useState } from "react"; import { View, StyleSheet, Button, Text } from "react-native"; export default function App() { const [counter, setCounter] = useState(0); return ( <View style={styles.container}> <View style={styles.counterContainer}> <Text style={styles.counter}>{counter}</Text> </View> <View style={styles.buttonContainer}> <Button title="Increment" onPress={() => setCounter((c) => c + 1)} /> </View> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: "center", alignItems: "center", }, counterContainer: { padding: 32, }, counter: { fontSize: 48, fontWeight: "bold", fontVariant: ["tabular-nums"], width: 100, textAlign: "center", }, buttonContainer: { position: "absolute", backgroundColor: "white", bottom: 16, left: 64, right: 64, }, });

The count is ticking up, but it's still as static as a statue. Remember that teaser video from the previous article?

Let's dissect the animation to understand its two key moves:

  1. The old number makes a exit by sliding down and fading away.
  2. Meanwhile, the new number makes an entrance, sliding in from above.

Given the trio of animation types you explored previously - entering, exiting, and layout - which do you reckon fits the bill for each of these movements?

Enter, Exit, or Layout?

You might be leaning towards layout animation, considering you're dealing with a text update here. Typically, layout animations are triggered by changes in a component's dimensions or its position on the screen. However, in this case, each digit remains perfectly centered, with the text properties staying constant - meaning the dimensions and position of our Text node don't really change.

But, if you take another glance at that teaser video and break down the action, it's as if the digits are making their own entrances and exits from the scene.

Bingo! You've identified that the types of animations we need are Enter and Exit. But, hang on. Recall their golden rule? They come into play when a view is either introduced to or is removed from the view hierarchy. Yet, here, the sole change is in the text content, while the Text element itself stays constant throughout the component's life.

So how could you make the digits appear to enter and exit the view hierarchy as they update? 🤔

React Keys: Beyond Lists

Welcome back to React keys! I bet you thought they were just for dodging those annoying console warnings in lists, huh? 😆

It turns out keys are more than just list helpers. When you assign a key to a React Element, you're essentially giving React a unique ID to tag and track that element with. Change that key, and you're telling React, "Hey, it's time to swap out the old for something new."

So, how does this magic help with the animation conundrum? Simple. By using the counter state variable as the key for our Text node, every time the counter ticks up or down, that key alteration signals React to unmount the old digit and mount a fresh one. This neat trick sets the stage for the entering and exiting animations to work properly.

<Text style={styles.counter} key={counter}> {counter} </Text>
<Text style={styles.counter} key={counter}> {counter} </Text>

Wiring It All Together

First of all, you have to upgrade the Text into a Reanimated Animated.Text component.

To set things in motion, let's start with some predefined animations, like SlideInUp and SlideOutDown, since they seem to resemble the behaviour of the digits as they appear and disappear.

import React, { useState } from "react"; import { View, StyleSheet, Button } from "react-native"; import Animated, { SlideInUp, SlideOutDown } from "react-native-reanimated"; export default function App() { const [counter, setCounter] = useState(0); return ( <View style={styles.container}> <View style={styles.counterContainer}> <Animated.Text key={counter} entering={SlideInUp} exiting={SlideOutDown} style={styles.counter} > {counter} </Animated.Text> </View> <View style={styles.buttonContainer}> <Button title="Increment" onPress={() => setCounter((c) => c + 1)} /> </View> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: "center", alignItems: "center", }, counterContainer: { padding: 32, }, counter: { fontSize: 48, fontWeight: "bold", fontVariant: ["tabular-nums"], width: 100, textAlign: "center", }, buttonContainer: { position: "absolute", backgroundColor: "white", bottom: 16, left: 64, right: 64, }, });
import React, { useState } from "react"; import { View, StyleSheet, Button } from "react-native"; import Animated, { SlideInUp, SlideOutDown } from "react-native-reanimated"; export default function App() { const [counter, setCounter] = useState(0); return ( <View style={styles.container}> <View style={styles.counterContainer}> <Animated.Text key={counter} entering={SlideInUp} exiting={SlideOutDown} style={styles.counter} > {counter} </Animated.Text> </View> <View style={styles.buttonContainer}> <Button title="Increment" onPress={() => setCounter((c) => c + 1)} /> </View> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: "center", alignItems: "center", }, counterContainer: { padding: 32, }, counter: { fontSize: 48, fontWeight: "bold", fontVariant: ["tabular-nums"], width: 100, textAlign: "center", }, buttonContainer: { position: "absolute", backgroundColor: "white", bottom: 16, left: 64, right: 64, }, });

Let's see the result:

So, the animation is up and running, but it's not hitting that sweet spot just yet. The new number makes an entrance from the top of the screen, and the old one takes its leave through the bottom. However, we don't have any control over the animation distance.

This is where you hit the limits of preset animations. They're great for getting things moving quickly but lack the finesse for precise adjustments.

Custom Layout Animations

When the off-the-shelf animations just don't cut it, and you're needing that precise control over every aspect of your animation, reanimated provides you with custom animation builders. They are a special breed of JavaScript functions designed to run on the UI thread, also known as worklets. Their return value is a configuration object that determines the starting conditions and the animations to be executed.

To make sure the worklet functions are executed on the UI thread, they must be marked with a worklet directive at the top of the function body.

Let's see them in action by creating the entering animation.

import Animated, { withTiming } from "react-native-reanimated"; const entering = (values) => { "worklet"; const animations = { originY: withTiming(values.targetOriginY, { duration: 300, }), }; const initialValues = { originY: values.targetOriginY - 150, }; return { initialValues, animations, }; };
import Animated, { withTiming } from "react-native-reanimated"; const entering = (values) => { "worklet"; const animations = { originY: withTiming(values.targetOriginY, { duration: 300, }), }; const initialValues = { originY: values.targetOriginY - 150, }; return { initialValues, animations, }; };

The starting state of the animation is specified by the initialValues object. For the entrance of a new number, the goal is to start the Text node 150dp above its final, center-stage position.

The animation should span over 300ms and follow an easing curve. The end state is then captured by values.targetOriginY. This values.targetOriginY variable is calculated for you and represents the final Y position where the text will rest, after the animation concludes.

Crafting the exiting animation follows a similar blueprint, although with its unique set of starting points and destinations.

With both the entering and exiting animations dialed in, let's refine the App component to use these custom animations.

import React, { useState } from "react"; import { View, StyleSheet, Button } from "react-native"; import Animated, { withTiming } from "react-native-reanimated"; const animationDistance = 150; const animationDuration = 300; export default function App() { const [counter, setCounter] = useState(0); const entering = (values) => { "worklet"; const animations = { originY: withTiming(values.targetOriginY, { duration: animationDuration, }), }; const initialValues = { originY: values.targetOriginY - animationDistance, }; return { initialValues, animations, }; }; const exiting = (values) => { "worklet"; const animations = { originY: withTiming(values.currentOriginY + animationDistance, { duration: animationDuration, }), }; const initialValues = { originY: values.currentOriginY, }; return { initialValues, animations, }; }; return ( <View style={styles.container}> <View style={styles.counterContainer}> <Animated.Text key={counter} entering={entering} exiting={exiting} style={styles.counter} > {counter} </Animated.Text> </View> <View style={styles.buttonContainer}> <Button title="Increment" onPress={() => setCounter((c) => c + 1)} /> </View> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: "center", alignItems: "center", }, counterContainer: { padding: 32, }, counter: { fontSize: 48, fontWeight: "bold", fontVariant: ["tabular-nums"], width: 100, textAlign: "center", }, buttonContainer: { position: "absolute", backgroundColor: "white", bottom: 16, left: 64, right: 64, }, });
import React, { useState } from "react"; import { View, StyleSheet, Button } from "react-native"; import Animated, { withTiming } from "react-native-reanimated"; const animationDistance = 150; const animationDuration = 300; export default function App() { const [counter, setCounter] = useState(0); const entering = (values) => { "worklet"; const animations = { originY: withTiming(values.targetOriginY, { duration: animationDuration, }), }; const initialValues = { originY: values.targetOriginY - animationDistance, }; return { initialValues, animations, }; }; const exiting = (values) => { "worklet"; const animations = { originY: withTiming(values.currentOriginY + animationDistance, { duration: animationDuration, }), }; const initialValues = { originY: values.currentOriginY, }; return { initialValues, animations, }; }; return ( <View style={styles.container}> <View style={styles.counterContainer}> <Animated.Text key={counter} entering={entering} exiting={exiting} style={styles.counter} > {counter} </Animated.Text> </View> <View style={styles.buttonContainer}> <Button title="Increment" onPress={() => setCounter((c) => c + 1)} /> </View> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: "center", alignItems: "center", }, counterContainer: { padding: 32, }, counter: { fontSize: 48, fontWeight: "bold", fontVariant: ["tabular-nums"], width: 100, textAlign: "center", }, buttonContainer: { position: "absolute", backgroundColor: "white", bottom: 16, left: 64, right: 64, }, });

Time to give it a spin and see the results.

Still not there! Notice how the numbers get stuck before they finish their entrance and exit animations? You can smooth that out by narrowing down the visible area of the counter. Just add overflow: "hidden" to the counterContainer style.

const styles = StyleSheet.create({ counterContainer: { padding: 32, overflow: "hidden", }, });
const styles = StyleSheet.create({ counterContainer: { padding: 32, overflow: "hidden", }, });

And with that, you've sealed the deal. The counter now boasts sleek enter/exit animations.

The avid observer would have noticed that the 0 makes an unnecessary animated entrance when the screen first comes to life. Consider it a little challenge to flex your newfound animation muscles. 😉

Conclusion

Throughout this journey, you've unlocked the secrets of the custom animation builder 🔥, catapulting your animation powers to new heights. This toolkit grants you a level of creative control that preset animations lacked.

And here's a fun fact: all those handy presets offered by Reanimated? They're all implemented using the very same custom animation builder!

If you would like a showcase of what's possible when these foundational blocks are put to work, go check out the Animated Stopwatch-Timer library.

Also, A dive into its source code might just reveal the answers you seek to the proposed exercise. 👀

Happy coding! ❤️

Raul Gomez

Written by Raúl Gómez Acuña

Raúl is a Product Engineer based in Madrid, Spain. He specialises in building mobile apps with React Native and TypeScript, while also sharing all his knowledge and experiences through the React Native University platform.

Back to Blog

* We aim to release new free content once a week. Be the first one to know!

React Native University • © 2024