DEV Community

Cover image for React Native Animation Tutorial – Creating Animations with Reanimated
Upsilon
Upsilon

Posted on • Edited on • Originally published at upsilonit.com

React Native Animation Tutorial – Creating Animations with Reanimated

Animations are a big part of making user interface of applications easier to use and understand. In this article, we are going to have a look at the process of creating animations with React Native Reanimated.


Smooth animations are a big part of making applications more user-friendly and easy to understand. In this article, we are going to discuss the process of creating animations using React Native Reanimated.

React Native simplifies cross-platform application development in a significant way. It allows the developers to use a single codebase to build applications on Android and iOS, focusing primarily on the application code as a whole, without worrying about platform-specific APIs and quirks too much. But in spite of the simplified model, creating animations with React Native can still be quite challenging. So, here’s where the React Native Reanimated library comes in handy.

Overview - React Native Reanimated and How it Works

Reanimated is a React Native animation library that helps to build smooth animations and gestures. It has been designed to match the React Native’s API while being more accurate in defining interactions. Reanimated is a library that replaces the Animated API, providing APIs based on JavaScript, which are easier to use and run on the native thread, enhancing performance.

Most of the animation logic is dealt with by the JavaScript thread. Another important thing is that now it is possible to use worklets and hook-based animations. This allows you to write in JavaScript instead of using declarative codes, which are not as intuitive.

Benefits and Limitations

Reanimated version 2 has a number of improvements compared to version 1:

  • This model allows the animations and the entire application to perform better.

  • The animations are written in JS in the form of so-called “worklets,” which are pieces of JS code extracted from the main react-native code and run in a separate context on the main thread.

  • Due to the new API, you will need fewer “Animated Values” to create an animation in React Native.

  • The animations can be created in different ways: triggering animated change on a shared value or returning the animated value from the useAnimatedStyle hook.

  • The worklets can serve as event handlers. It is possible to define a separate worklet for handling each handler state.

Though Reanimated version 2 has improved a lot compared to Reanimated 1, it is still in its early days, so there can be some limitations:

  • It doesn’t support remote debugging. You can use Flipper for debugging your code, but connecting debuggers that run on the UI thread is not available.

  • Objects passed to worklets from React Native don’t pave the correct prototypes in JavaScript, which somewhat limits the ways you can use them.

  • Animating the virtual components of layout, like nested components, is not supported.

How To Create Animations Using React Native Reanimated

Now let’s have a look at some examples of creating animations using React Native Reanimated.

Example 1: Animated Contacts List

  1. Install Node.js‍

  2. Install yarn.
    npm install yarn — global

  3. Create a project
    npx react-native init animatedApp

  4. Navigate to the project.
    cd animatedApp

  5. Install React-Native-Reanimated and other dependencies.
    yarn add react-native-reanimated

  6. Starting a development server:
    yarn start

Let's start by creating an animated list of your contacts. We will use @faker-js/faker to create fake users, which generates massive amounts of fake (but realistic) data. As a result, we’ll get a list of contacts with names and avatars and, thanks to animations, a nice UI with an animated header.

First, notice this bit:

const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);

RN's FlatList component isn't animatable by default, so we need to make it animatable using the createAnimatedComponent method by Reanimated. This allows us to pass animated props or styles to this component.

import faker, {GenderType} from '@faker-js/faker';
import * as React from 'react';
import {
  Dimensions,
  FlatList,
  Image,
  StyleSheet,
  Text,
  View,
} from 'react-native';
import LinearGradient from 'react-native-linear-gradient';
import Animated, {
  Extrapolate,
  interpolate,
  useAnimatedScrollHandler,
  useAnimatedStyle,
  useSharedValue,
  withTiming,
} from 'react-native-reanimated';

import {Colors} from '../colors';
import getRandomColor from '../utils/generateRandomColor';

const {width, height} = Dimensions.get('screen');

const COLUMNS = 2;
const ITEM_SIZE = width / 2;
const SPACING = 10;
const HEADER_SPACING = height * 0.3;
const HEADER_FONTSIZE = 74;

const AnimatedContactsScreen: React.FunctionComponent = () => {
  const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);

  faker.seed(4);

  type PersonItem = {
    key: string;
    hasAvatar: boolean;
    avatar: string;
    firstName: string;
    lastName: string;
    initials: string;
    backgroundColor: string;
    text: string;
  };

  const data: PersonItem[] = [...Array(50).keys()].map(() => {
    const backgroundColor = getRandomColor();
    const text = Colors.WHITE;
    const hasAvatar = faker.datatype.boolean();
    const gender = faker.name.gender();
    const firstName = faker.name.firstName(gender as GenderType);
    const lastName = faker.name.lastName(gender as GenderType);
    return {
      key: faker.datatype.uuid(),
      hasAvatar,
      avatar: hasAvatar
        ? `https://i.pravatar.cc/200?u=${faker.datatype.uuid()}`
        : '',
      firstName,
      lastName,
      initials: `${firstName.substring(0, 1)}${lastName.substring(0, 1)}`,
      backgroundColor,
      text,
    };
  });

  const scrollY = useSharedValue(0);
  const headerHeight = useSharedValue(height);

  const onScroll = useAnimatedScrollHandler(event => {
    scrollY.value = event.contentOffset.y;
  });

  const textStyles = useAnimatedStyle(() => {
    return {
      fontSize: interpolate(
        scrollY.value,
        [0, HEADER_SPACING, headerHeight.value],

        [HEADER_FONTSIZE, 24, 24],
        Extrapolate.CLAMP,
      ),
    };
  });

  const headerContainerStyle = useAnimatedStyle(() => {
    return {
      marginBottom: interpolate(
        scrollY.value,
        [
          -1,
          0,
          HEADER_SPACING + HEADER_FONTSIZE,
          headerHeight.value + HEADER_FONTSIZE,
        ],
        [HEADER_SPACING + 1, HEADER_SPACING, 0, 0],
      ),
    };
  });

  const listHeaderStyle = useAnimatedStyle(() => {
    return {
      height: headerHeight.value,
    };
  });

  const renderItem = ({item}: {item: PersonItem}) => {
    return (
      <View
        style={[
          styles.itemStyle,
          {
            width: ITEM_SIZE,
            height: ITEM_SIZE,
            backgroundColor: item.backgroundColor,
          },
        ]}>
        {item.hasAvatar ? (
          <Image source={{uri: item.avatar}} style={styles.image} />
        ) : (
          <Text
            style={[
              styles.initials,
              {
                color: item.text,
              },
            ]}
            numberOfLines={1}
            adjustsFontSizeToFit>
            {item.initials}
          </Text>
        )}
        <LinearGradient
          colors={
            item.hasAvatar
              ? ['transparent', 'rgba(0,0,0,0.3)', 'rgba(0,0,0,.8)']
              : ['transparent', 'transparent']
          }
          style={[
            StyleSheet.absoluteFillObject,
            styles.nameContainer,
            {padding: SPACING},
          ]}>
          <Text
            numberOfLines={1}
            adjustsFontSizeToFit
            style={[
              styles.textName,
              {
                color: item.hasAvatar ? Colors.WHITE : item.text,
              },
            ]}>
            {item.firstName} {item.lastName}
          </Text>
        </LinearGradient>
      </View>
    );
  };

  return (
    <View style={styles.container}>
      <View
        style={[styles.main, styles.statusBar, {padding: SPACING}]}
        onLayout={ev => {
          if (
            headerHeight.value === ev.nativeEvent.layout.height ||
            headerHeight.value !== height
          ) {
            return;
          }
          headerHeight.value = withTiming(ev.nativeEvent.layout.height, {
            duration: 0,
          });
        }}>
        <Animated.View>
          <Animated.Text
            style={[
              styles.text,
              textStyles,
              {paddingRight: width / 4 - SPACING * 2},
            ]}
            numberOfLines={1}
            adjustsFontSizeToFit>
            Friends
          </Animated.Text>
          <Text style={styles.subtitle}>{data.length} contacts</Text>
        </Animated.View>
        <Animated.View style={headerContainerStyle} />
      </View>
      <AnimatedFlatList
        data={data}
        numColumns={COLUMNS}
        keyExtractor={item => item.key}
        scrollEventThrottle={16}
        onScroll={onScroll}
        ListHeaderComponent={<Animated.View style={listHeaderStyle} />}
        stickyHeaderIndices={[0]}
        renderItem={renderItem}
      />
    </View>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: Colors.WHITE,
  },
  main: {
    zIndex: 1,
    position: 'absolute',
    left: 0,
    right: 0,
    backgroundColor: Colors.WHITE,
  },
  statusBar: {
    paddingTop: 20,
  },
  paragraph: {
    margin: 24,
    fontSize: 18,
    fontWeight: 'bold',
    textAlign: 'center',
  },
  text: {
    color: Colors.TOMATO,
    fontSize: 54,
    fontWeight: '700',
    letterSpacing: -1,
  },
  subtitle: {
    color: Colors.TOMATO,
  },
  image: {
    width: '100%',
    height: '100%',
  },
  initials: {
    fontSize: 94,
    fontWeight: 'bold',
    opacity: 0.1,
  },
  itemStyle: {
    justifyContent: 'center',
    alignItems: 'center',
  },
  nameContainer: {
    justifyContent: 'flex-end',
  },
  textName: {
    fontWeight: '600',
  },
});

export default AnimatedContactsScreen;
Enter fullscreen mode Exit fullscreen mode

We want to animate the header based on the onScroll method in our FlatList. Thanks to a convenience hook useAnimatedScrollHandler, we can use the scrollY value to interpolate the header.

When we think of animation, we generally think of animating various styles. Reanimated gives us a useAnimatedStyle hook that allows us to animate styles using shared values.

In the example above, we use useAnimatedStyle to create a style that is dynamic based on our shared value, scrollY. We pass animated styles to Animated.View and Animated.Text, and as we scroll our contacts list, the header will increase or decrease (depending on which way we scroll).

This is how our animated contacts list looks:

Image description

Example 2: Comparing Pictures Using Gesture Handler

Let's continue working with animations with the following example. Imagine that we want to compare two photos. And to do this better, we need to enlarge one of the photos by swiping to the left or right.

Reanimated provides a useAnimatedGestureHandler hook that allows us to configure a gesture handler declaratively, and then we can provide that handler to a gesture handler component. If we are interested in handling the movement of a finger on the screen, we need to receive a continuous stream of touch events. For this purpose, PanGestureHandler from react-native-gesture-handler package can be used.

import 'react-native-gesture-handler';

import * as React from 'react';
import {
  Dimensions,
  ImageBackground,
  StatusBar,
  StyleSheet,
  Text,
  View,
} from 'react-native';
import {PanGestureHandler} from 'react-native-gesture-handler';
import Animated, {
  interpolate,
  useAnimatedGestureHandler,
  useAnimatedStyle,
  useSharedValue,
} from 'react-native-reanimated';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';

const {width} = Dimensions.get('window');
const image =
  'https://images.pexels.com/photos/302896/pexels-photo-302896.jpeg?auto=compress&cs=tinysrgb&dpr=2&w=500';
const secondImage =
  'https://images.pexels.com/photos/302888/pexels-photo-302888.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260';
const bg =
  'https://images.pexels.com/photos/585750/pexels-photo-585750.jpeg?auto=compress&cs=tinysrgb&dpr=2&h=750&w=1260';

const IMAGE_HEIGHT = 300;
const HANDLER_SIZE = 6;

const ComparePhotoScreen = () => {
  const posX = useSharedValue(0);
  const gestureHandler = useAnimatedGestureHandler({
    onStart: (event, ctx) => {
      ctx.startX = posX.value;
    },
    onActive: (event, ctx) => {
      posX.value = ctx.startX + event.translationX;
    },
  });

  const leftImage = useAnimatedStyle(() => {
    return {
      flex: interpolate(posX.value, [-width / 2, 0, width / 2], [0, 1, 2]),
    };
  });
  const rightImage = useAnimatedStyle(() => {
    return {
      flex: interpolate(posX.value, [-width / 2, 0, width / 2], [2, 1, 0]),
    };
  });

  return (
    <ImageBackground
      source={{uri: bg}}
      style={styles.container}
      blurRadius={80}>
      <StatusBar hidden />
      <View style={styles.main}>
        <Text style={styles.header}>Compare Photos</Text>
        <View style={styles.imgView}>
          <Animated.Image
            source={{uri: image}}
            style={[styles.imageStyle, {height: IMAGE_HEIGHT}, leftImage]}
          />
          <PanGestureHandler onGestureEvent={gestureHandler}>
            <Animated.View
              style={[
                styles.animatedContainer,
                {
                  width: HANDLER_SIZE,
                },
              ]}>
              <View style={styles.arrowContainer}>
                <MaterialIcons name={'arrow-left'} size={30} color="black" />
                <MaterialIcons name={'arrow-right'} size={30} color="black" />
              </View>
            </Animated.View>
          </PanGestureHandler>
          <Animated.Image
            source={{uri: secondImage}}
            style={[{height: IMAGE_HEIGHT}, styles.imageStyle, rightImage]}
          />
        </View>
      </View>
    </ImageBackground>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    backgroundColor: '#ecf0f1',
    padding: 20,
  },
  main: {alignItems: 'center'},
  header: {
    fontSize: 28,
    fontWeight: '700',
    marginBottom: 20,
    color: '#fff',
    opacity: 0.7,
  },
  imgView: {
    flexDirection: 'row',
    borderRadius: 16,
    overflow: 'hidden',
  },
  arrowContainer: {
    width: 60,
    height: 60,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
    flexDirection: 'row',
    borderRadius: 30,
  },
  animatedContainer: {
    backgroundColor: 'transparent',
    zIndex: 9999,
    alignItems: 'center',
    justifyContent: 'center',
  },
  imageStyle: {
    resizeMode: 'cover',
    flex: 1,
  },
});

export default ComparePhotoScreen;
Enter fullscreen mode Exit fullscreen mode

We need to dig into some of this code, though. The useAnimatedGestureHandler hooks allow us to configure our gesture handler based on various lifecycle hooks for the gesture, such as onStart, onActive, onEnd, and so on. The handler has a context object that we can use to pass information between the different lifecycle methods, which is quite useful for stateful gestures. We use the onStart method when the user starts to pan the handle, so we can attach the current progress value (or the progress value at the start of the pan) to the context object and use it later in the useAnimatedStyle hook for our photos.

Next, we use the onActive callback, which is called as the user is actively panning. This callback exposes an event object, as well as the previously mentioned context object. The event object will tell us how much the user has panned since the start via the event.translationX property. To determine the new progress value, we take the starting progress value and add to it the amount by which the handle's horizontal position has changed (e.g., (final value) = (starting value) + (change in value) ). Technically, this is all we need to enlarge or reduce the photo by swiping.

This is how our feature for comparing two photos looks:

Image description

If you want to study the code examples in more detail, you can do it in the repository on our Github. Here you will be able to find the whole code that we used and some extra components necessary to make everything work.

Conclusion

Hope that this article helped you learn more about the Reanimated library, and how it makes the creation of animations quicker and easier. Also, we created two animation examples to showcase some of the useful animation techniques and how they can be used together with Reanimated. Though Reanimated 2 is still in its early days, it has a bright and promising future.

Top comments (0)