loading...

Introduction to React Native Animations

renatobentorocha profile image Renato Rocha ・7 min read

According to statista, today, the number of smartphone users surpasses the number of three billions. So in the last years, we have seen an increasing number of applications for mobile devices being developed.

This application can be seen in many areas, like: music (Spotify), travel (Tripadvisor, Uber) and social network (Facebook, Instagram). When we are using some of this apps or another one, our experience improves as the application provides smoother transitions and interactions.

Thus in this post we will see an introduction of how to uses the React Native Animation library for improves the user experience in application and we will uses the Nubank interface as example.

Animation library basics

The React Native Animated library provide us two types of values tha we can use to animate our components:

  • Animated.Value
  • Animated.ValueXY

Since we have one of this values or both, we can trigger them with our component and use some function, provided by React Native, to drive the animation:

  • Animated.spring() -> provides a basic spring physics model.
  • Animated.timing() -> animates a value over time.

Example:

const value = new Animated.Value(0);

Animated.spring(value, {
  toValue: 0,
  speed: 5,
}).start();

Animated.timing(value, {
  toValue: 300,
  duration: 500,
}).start();

In the rest of this post we will use this Animated values and functions to animate our app example.

Horizontal animation

As we can see, when the app initialise, the first animation that occur is a horizontal moving of card and bottom buttons. Bellow we have the code and image app without any animation:

So our first step will be create a horizontal animation for the card and bottom buttons after the app has ben initialized.

React Native provide us some animatable components and only this can be animated. So to animate bottom buttons we need change the View that wrapper the BottomButtons Component by an Animated.View:

<View>
  <BottomButtons />
</View>;

to;

import {Animated} from 'react-native';
<Animated.View>
  <BottomButtons />
</Animated.View>;

and we need create a Animated Value that will be used toghether the Animated Component previously created:

import React, {useRef, useEffect} from 'react';
import {Dimensions, Animated} from 'react-native';

const {width} = Dimensions.get('screen');
const bottomButtonsAnimated = useRef(new Animated.Value(-width)).current;

<Animated.View
  style={{
    right: bottomButtonsAnimated,
  }}>
  <BottomButtons />
</Animated.View>;

In the above code we get the screen width to initialy put our bottom buttons component outside the screen and after animate it go back to screen. We also saw the useRef, whitout it every rerender will reset the bottomButtonsAnimated to the initial value.

So at this time we have an Animated Value (bottomButtonsAnimated) to be used with the <Animated.View /> that wrapper the <BottomButtons /> component. However we still need animate this value and it can be do using Animated.spring():

useEffect(() => {
  Animated.spring(bottomButtonsAnimated, {
    toValue: 0,
    speed: 5,
  }).start();
}, []);

When the Animated.spring initialise it will increment the bottomButtonsAnimated from -width to zero. As the <Card /> component has the same animation, we can use the bottomButtonsAnimated to animate it also:

<Card horizontalShift={bottomButtonsAnimated} />

and we will get the first animations:

Vertical animation

Now, as the first gif, our aim is create the vertical animations and for it we will use the PanResponder, once the PanResponder works with Animated API to build gestures in the UI.

The PanResponder provide us a create methodthat accept as parameter an config object with all responder callbacks. Each one callback receive as parameter a native event:

  changedTouches: Array of all touch events that have changed since the last event
  identifier: The ID of the touch
  locationX: The X position of the touch, relative to the element
  locationY: The Y position of the touch, relative to the element
  pageX: The X position of the touch, relative to the root element
  pageY: The Y position of the touch, relative to the root element
  target: The node id of the element receiving the touch event
  timestamp: A time identifier for the touch, useful for velocity calculation
  touches: Array of all current touches on the screen

and gestureState object:

  stateID: ID of the gestureState, persisted as long as there at least one touch on screen
  moveX: the latest screen coordinates of the recently-moved touch
  moveY: the latest screen coordinates of the recently-moved touch
  x0: the screen coordinates of the responder grant
  y0: the screen coordinates of the responder grant
  dx: accumulated distance of the gesture since the touch started
  dy: accumulated distance of the gesture since the touch started
  vx: current velocity of the gesture
  vy: current velocity of the gesture
  numberActiveTouches - Number of touches currently on screen

With the above information, as the first step, we must create a Animated Value to vertical moving:

const cardAnimatedValueY = useRef(new Animated.Value(0)).current;

and pass this value as parameter to Card component:

<Card
  horizontalShift={bottomButtonsAnimated}
  verticalShift={cardAnimatedValueY}
/>

Once we have the Animated Value to vertical moving, we can use the PanResponder to recognize the gestures:

import {StatusBar, Dimensions, Animated, PanResponder} from 'react-native';

const panResponder = React.useRef(
  PanResponder.create({
    onStartShouldSetPanResponder: (evt, gestureState) => true,
    onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
    onMoveShouldSetPanResponder: (evt, gestureState) => true,
    onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
    onPanResponderGrant: (evt, gestureState) => {},

    onPanResponderMove: (evt, gestureState) => {
      cardAnimatedValueY.setValue(gestureState.dy);
    },

    onPanResponderTerminationRequest: (evt, gestureState) => true,
    onPanResponderRelease: (evt, gestureState) => {},
    onPanResponderTerminate: (evt, gestureState) => {},
    onShouldBlockNativeResponder: (evt, gestureState) => true,
  }),
).current;

In the above code we use the onPanResponderMove to set the cardAnimatedValueY and we need to pass the PanResponder callbacks to Card component:

<Card
  horizontalShift={bottomButtonsAnimated}
  verticalShift={cardAnimatedValueY}
  {...panResponder.panHandlers}
/>

and internally the card component handles the parameters:

<WrapperSwiper
  style={{
    right: horizontalShift,
    transform: [{translateY: verticalShift}],
  }}
  {...rest}>
  {renderCard()}
</WrapperSwiper>

where the WrapperSwiper is a Animated.View.

The above gif demonstrates the results for vertical the moving, however we still have two issues with it gif:

1 - The opacity related to another components;
2 - When we release the card and touch it again the card position became to the initial.

Components opacity

When we slide the card, we need change the opacity from another components. So that the menu options under the card need initialise with zero opacity and growth to one as the card slide down. We need also that the <BottomButtons /> opacity initialise with one opacity and go to zero as the card slide down.

At this point we have creating our animations applying changes to animated variables cardAnimatedValueY straightly. However we can obtain another animated values as our animated variables cardAnimatedValueY changes, example:

cardAnimatedValueY Result
0 0
1 0.1
2 0.2
... ...
415 1

So to do that and use the result with the opacity of our components we will use the AnimatedVariable.interpolate function. It will receive as parameter a configuration object:

{
    inputRange: number[];
    outputRange: string[] | number[];
    extrapolate?: ExtrapolateType;
}

Thus this function will map the inputRange to outputRange and we can use extrapolate property to inform what action to take if the inputRange extrapolate the range. In our case we alway want a opacity value between 0 and 1, so we will "clamp" the outputRange in case the extrapolete inputRange occurring.

Now we can make our opacity animations:

import {Dimensions} from 'react-native';

const {width, height} = Dimensions.get('screen');
const MAX_SLIDE = Math.floor(height * 0.623);
const MIDDLE_SLIDE = Math.floor(height * 0.33);

const opacity = cardAnimatedValueY.interpolate({
  inputRange: [0, MIDDLE_SLIDE, MAX_SLIDE],
  outputRange: [0, 0.3, 1],
  extrapolate: 'clamp',
});

const opacityBottomButtons = cardAnimatedValueY.interpolate({
  inputRange: [0, MIDDLE_SLIDE, MAX_SLIDE],
  outputRange: [1, 0.6, 0],
  extrapolate: 'clamp',
});

the MAX_SLIDE value is the distance from the inital card component position to under the button SAIR DA CONTA.
Now we will be apply the opacity variable to:

<Menu opacity={opacity} />
<Logout opacity={opacity} />

and the opacityBottomButtons to:

<BottomButtons opacity={opacityBottomButtons} />

Fix card position after release and touch again

If we put a console.log statement inside the onPanResponderMove method and print the gestureState.dy value, we can see that the variable that contain the vertical displacement became zero always that we release the card and touch again.

So to solve this we need save the last gestureState.dy value to maintain the card position. To do it, we create a variable that will accumulate vertical displacement on each release inside onPanResponderRelease method that will be executed after the touch release.

export default function App() {
  let offSet = 0;

  const panResponder = React.useRef(
    PanResponder.create({
      onStartShouldSetPanResponder: (evt, gestureState) => true,
      onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
      onMoveShouldSetPanResponder: (evt, gestureState) => true,
      onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,

      onPanResponderGrant: (evt, gestureState) => {
      },

      onPanResponderMove: (evt, gestureState) => {
        cardAnimatedValueY.setValue(gestureState.dy);
      },

      onPanResponderTerminationRequest: (evt, gestureState) => true,

      onPanResponderRelease: (evt, gestureState) => {
        offSet += gestureState.dy;
        cardAnimatedValueY.setOffset(offSet);
      },

      onPanResponderTerminate: (evt, gestureState) => {},
      onShouldBlockNativeResponder: (evt, gestureState) => true,
    }),
  ).current;

Auto vertical displacement

At the post begin we saw a gif in which when we touch the card, move it move until certain point and release the card, it auto displacement to bottom or top. So our last step will be create this behavior.

As we saw, the method that catch the event related to release the touch is: onPanResponderRelease. Thus we will change this method to make our feature.

export default function App() {
  let offSet = 0;
  let down = true;

  const panResponder = React.useRef(
    PanResponder.create({
      .
      .
      .

      onPanResponderRelease: (evt, gestureState) => {
        offSet += gestureState.dy;
        cardAnimatedValueY.setOffset(offSet);

        const restOffSet = 415 - offSet;

        if (down && offSet >= 100) {
          Animated.timing(cardAnimatedValueY, {
            toValue: restOffSet,
            duration: 500,
          }).start(() => {
            cardAnimatedValueY.setOffset(415);
            cardAnimatedValueY.setValue(0);
            offSet = 415;
            down = !down;
          });
        }
      },

      .
      .
      .
    }),
  ).current;

In the above code snippet we add the down variable that will contain the direction that the card will be take. The restOffSet represents the units that the card can slide to the bottom. So if the direction is down and the card offset is bigger than or equal to 100 units, the card will slide restOffSet.

Now what we need is make the reverse path, to the top:

export default function App() {
  let offSet = 0;
  let down = true;

  const panResponder = React.useRef(
    PanResponder.create({
      .
      .
      .

      onPanResponderRelease: (evt, gestureState) => {
        offSet += gestureState.dy;
        cardAnimatedValueY.setOffset(offSet);

        const restOffSet = 415 - offSet;

        if (down && offSet >= 100) {
          Animated.timing(cardAnimatedValueY, {
            toValue: restOffSet,
            duration: 500,
          }).start(() => {
            cardAnimatedValueY.setOffset(415);
            cardAnimatedValueY.setValue(0);
            offSet = 415;
            down = !down;
          });
        } else if (!down && offSet <= 355) {
          Animated.timing(cardAnimatedValueY, {
            toValue: restOffSet - MAX_SLIDE,
            duration: 500,
          }).start(() => {
            cardAnimatedValueY.setOffset(0);
            cardAnimatedValueY.setValue(0);
            offSet = 0;
            down = !down;
          });
        }
      },

      .
      .
      .
    }),
  ).current;

The else clausule define the reverse path to the top.

This post was a little introduction about React Native Animated and we saw how tp apply some animations in an app. However we can explorer another libraries that improve the animations performance running on UI thread without having to go through the bridge on every frame: React Native Reanimated and React Native Gesture Handler.

Source code

Discussion

pic
Editor guide