Back to index

Custom Layout Animations with Reanimated

February 2nd, 2023

7 min read

Banner image

In a previous article, I walked you through the different ways in which you can define Layout Animations in React Native. In there, you examined the LayoutAnimation core module and some of the limitations it suffers from.

It was a matter of time until a natural successor was born, and reanimated v2 came to fruition, reestablishing the glory of declarative animations by embracing the React component model.

The challenge

Just to pick up where I left off, I teased you with a little challenge where a preset animation wouldn't be sufficient.

But, before moving on, please revisit the previous article if you haven't already, so you can easily follow along!

Alrighty, strap on your seatbelts, because this is going to be a fun ride!

I am going to start with the UI first, which is pretty straightforward. There is a counter variable stored in local state and a button to increment it.

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,
  },
});

Ok, that was easy. Now let's start tackling the fancy part. As you could see in the video, the goal is to animate the counter when it changes. By breaking down the animation, two simultaneous behaviours can be observed:

  • The previous number slides out down and disappears
  • The new number slides in some distance from the top

Now, among the 3 types of animation covered in the previous article - entering, exiting and layout - which one do you think it should be used for each one of the behaviours?

You may be thinking layout, since it's an update on the text content. A layout animation is triggered by either a change in the dimensions of the container view, or its position. In this scenario, each number is always centered on the screen and the text style properties are fixed, so the dimensions and position of the Text node are always the same.

If you take a closer look at the teaser video that showcases the desired result and try to describe what's happening in plain English, you could say that the digits are sort of entering and exiting the screen.

Fantastic, the animation types needed are clear, but still one issue remains. Remember the main premise of entering and exiting animations? They are triggered when the view is added (mounted) or removed (unmounted) from the view hierarchy. However, the only change happening is an update on the text content (children), and the Text element remains the same for the lifecycle of the component.

So how could you force the digits to be added and removed from the view hierarchy as they change? 🤔

React Keys are not only for lists

Welcome back to React keys! That's right, they are not only used in lists to get rid of that pesky console warning 😆.

If a particular React Element has a key, React will use it to identify the element and keep track of it. If that key changes, you are indeed instructing React to unmount the previous element and mount a new one.

Therefore, you can use the counter state variable as the key for the Text node, forcing it to be unmounted and mounted again as the counter changes, enabling a trigger for both entering and exiting animations.

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

Let's wire everything up by turning the Text into an Animated.Text and using some predefined reanimated enter/exit animations.

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,
  },
});

I have initially chosen SlideInUp for entering and SlideOutDown for exiting, because they resemble the behaviour of the digits as they appear and disappear.

Let's see the result:

It seems to be working, but it's not quite there yet. Notice how the new number pops in from the top of the screen and the old number disappears from the bottom of the screen. But there is no apparent way to control the distance of both animations with this implementation.

This is the point where a preset animation is not enough, and I need to turn your attention to something way more powerful: the custom animation builder

Building your own layout animations

In cases where you need more granular control over the animation, reanimated offers you animation builders, which are basically worklet functions that return a configuration object with the initial values and the animations that should take place.

Let's see how you could define an entering animation to tailor it to your needs.

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 initial state of the animation is defined in the initialValues object.

For displaying a new number, I am aiming for the Text node to be initially 150dp above the target position.

Then, I would like to animate that value to a target of values.targetOriginY, with a duration of 300 ms and an easing curve animation.

values.targetOriginY is essentially the final Y position of the text centered on the screen, once the animation settles. This value is automatically calculated for you based on the styles applied to the Text node.

There are two things to note about the entering function:

  • It must be a worklet function, which means it must be defined inside a worklet block. That's necessary to tell Reanimated the function should be executed on the UI thread.
  • It offers a good degree of flexibility, allowing you to configure the animation however you want.

The exiting animation is defined in a similar way, with just different initial values and targets for the animations.

The final code with both entering/exiting animations set up looks as follows:

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.

Much better! There is one last thing to do though. Observe how the numbers animate beyond the parent view container. Let's prevent that from happening by adding overflow: "hidden" as a style property to the parent view.

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

And there you have it! We have a fully working counter with a nice smooth enter/exit animation.

There are still some rough edges to smooth out, like the initial 0 being animated in when the screen mounts. You probably want to get rid of that, but I would leave that as an exercise for the reader 😉

Conclusion

In this blog post, you learned about the powerful custom animation builder 🔥

This opens a door to a whole new world of possibilities, where you can create your own animations with a level of control that is not possible with the available presets. And, if you didn't know, all the presets from Reanimated are indeed implemented using the custom animation builder!

Last but not least, the Animated Stopwatch-Timer is a great example of a component library that leverages these building blocks, so don't hesitate to check it out. (Small teaser - its source code contains the solution to the proposed exercise 👀)

If you found this article useful, follow me on Twitter @rgommezz for React Native updates and more content like this.

Happy coding! ❤️

Back to index

Raul Gomez • © 2023