Overview

This lecture would be a series of we trying to reproduce some Android system applications animations using React Native, the source code for upcoming lectures can be found here ahead of time.

I would also be updating this section in the future as other parts of the series are made available.

For reference, this is the Incoming Call animation on Android that we would be rebuilding in this part of the series, using React Native:

Setting Up The Project

We would be using expo to boostrap our project

Bootstrapping With Expo

You can read the docs on how to install and set up expo.

Now, let's create the project using expo, run the following command in the terminal:

expo init react-native-animations

Then select managed typescript configuration, wait for it to set up the project.

Installing The Dependencies

We would be needing React Native Reanimated, React Native Gesture Handler and React Native SVG to build the animations, handle gestures and draw paths respectively. We can install them using the following command:

With npm:

npm install --save react-native-reanimated react-native-gesture-handler react-native-svg

With yarn:

yarn add react-native-reanimated react-native-gesture-handler react-native-svg

Finally for the setup, we would have to add Reanimated plugin to the babelconfig.js file like so:

module.exports = {
      ...
      plugins: [
        "react-native-reanimated/plugin",
      ],
      ...
    };

Building The Project

Let us create a file called IncomingCall.tsx and add the following code to build the view:

import React from "react";
import { StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

function IncomingCall() {
  const edges = useSafeAreaInsets();
  return (
    <View style={[styles.container, { paddingTop: edges.top }]}>
      <View>
        <Text style={styles.callingText}>Call from</Text>
        <Text style={styles.titleText}>Tech Support NG</Text>
        <Text style={styles.callingText}>Mobile +234 00 000 000</Text>
      </View>
      <View style={styles.actionsContainer}>
        <Text style={styles.acceptText}>Swipe up to answer...</Text>
        <Text style={styles.iconContainer}>📞</Text>
        <Text style={styles.declineText}>Swipe down to decline...</Text>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  acceptText: {
    color: "#7B7C81",
    fontStyle: "italic",
    marginBottom: 12,
  },
  actionsContainer: {
    alignItems: "center",
    paddingBottom: 40,
  },
  callingText: {
    color: "#fff",
    fontSize: 20,
    marginBottom: 8,
    textAlign: "center",
  },
  container: {
    alignItems: "center",
    backgroundColor: "#222328",
    flex: 1,
    justifyContent: "space-between",
    padding: 8,
  },
  declineText: {
    color: "#7B7C81",
    fontStyle: "italic",
    marginTop: 12,
  },
  iconContainer: { 
    color: "#000" 
  },
  titleText: {
    color: "#fff",
    fontSize: 36,
    fontWeight: "800",
    marginBottom: 8,
    textAlign: "center",
  },
});

export default IncomingCall;

We should get the following on our screen:

Dont worry about the phone icon emoji, it is just a placeholder, we'd be building that later on.

Mount Animation

Now that we have a skeleton of the view, let's start animating!

From our reference, when the screen mounts the Swipe up to answer... text, Swipe down to decline... text and the Icon translate from the bottom of the screen on the y axis. Their opacity also fades in as they appear on the screen. We can use the parent view holding these elements to do this translation. We'd update the view to an animated one and define two shared values, one for translation and the other for opacity.

...

import Animated, { useSharedValue } from "react-native-reanimated";

...
function IncomingCall() {
  ...
  const gestureOpacity = useSharedValue(0);
  const gestureTranslate = useSharedValue(100);

  ...
  return (
    ...

     <Animated.View style={styles.actionsContainer}>
        <Text style={styles.acceptText}>Swipe up to answer...</Text>
        <Text style={styles.iconContainer}>📞</Text>
        <Text style={styles.declineText}>Swipe down to decline...</Text>
      </Animated.View>

    ...
  )
}
...

We are initializing our translation shared value to 100, because we want to start the animation from 100 pixels close the bottom of the screen to the original position according to it's layout which would be 0. Also for the opacity we are moving from 0 to 1.

...

import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";

...
function IncomingCall() {
  ...
  React.useEffect(() => {
    gestureTranslate.value = withTiming(0, { duration: 1000, easing: Easing.elastic(1) });
    gestureOpacity.value = withTiming(1, { duration: 1000 });
  }, []);
  ...
  const gestureContainerStyle = useAnimatedStyle(() => ({
    opacity: gestureOpacity.value,
    transform: [{ translateY: gestureTranslate.value }],
  }));
  ...
  return (
    ...

     <Animated.View style={[styles.actionsContainer, gestureContainerStyle]}>
        <Text style={styles.acceptText}>Swipe up to answer...</Text>
        <Text style={styles.iconContainer}>📞</Text>
        <Text style={styles.declineText}>Swipe down to decline...</Text>
      </Animated.View>

    ...
  )
}
...

We also added easing to the animation to give us an elastic effect.

This should result in the following:

Putting Together The Loop Animation

Next, we notice from our reference that after the mount animation, we get a loop animation that translates the Swipe up to answer... and the Icon from their layout positions to some pixels up and then back to their original position. We also get the Swipe down to decline... text to fade in and out as this is going on.

We can wrap our Swipe up to answer... text and Icon in an Animated.View which we would apply the styles to translate back and forth on and then we can interpolate from this to get the opacity of the text Swipe down to decline... to fade in and out.

...
function IncomingCall() {
  ...
  const repeatTranslate = useSharedValue(0);
  ...

  const doRepeat = () => {
    "worklet";

    repeatTranslate.value = withRepeat(
      withTiming(-50, {
        duration: 1500,
      }),
      -1,
      true,
    );
  };
  ...
  const declineOpacity = useAnimatedStyle(() => {
    const opacity = interpolate(
      repeatTranslate.value,
      [-50, 0],
      [0, 1],
      Extrapolate.CLAMP,
    );
    return { opacity };
  });
  ...

    React.useEffect(() => {
    gestureTranslate.value = withTiming(
      0,
      { duration: 1000, easing: Easing.elastic(1) },
      (isFinished) => {
        if (isFinished) {
          doRepeat();
        }
      },
    );
    gestureOpacity.value = withTiming(1, { duration: 1000 });
  }, []);
  ...
  return (
      ...
      <Animated.View style={[styles.actionsContainer, gestureContainerStyle]}>
        <Animated.View style={[styles.repeatContainer, repeatTranslateStyle]}>
          <Text style={styles.acceptText}>Swipe up to answer...</Text>
          <Text style=>📞</Text>
        </Animated.View>
        <Animated.Text style={[styles.declineText, declineOpacity]}>
          Swipe down to decline...
        </Animated.Text>
      </Animated.View>
      ...
  )
}
...
const styles = StyleSheet.create({
  ...
    repeatContainer: {
    alignItems: "center",
  },
  ...
})

From the snippet above, we create a doRepeat function which is our own custom worklet that would be called when the mount animation is finished. Within this function we have a withRepeat function which takes other animation functions like withTiming and repeats it a number of times, we are passing -1 as our number of repitition which means it should create an infinite loop, also we are passing true as our third argument which translates our shared value (repeatTranslate) from 0 to -50, then back to 0. Finally we interpolate on these values to get the opacity of the decline text to fade in and out. We add Extrapolate.CLAMP to the interpolation to make sure that the opacity never goes below 0 or above 1.

This should result to this on our screen:

Handling Swipes

The next thing to do would be to handle the user swipe gestures to either accept the call or decline it. We would be using the PanGestureHandler component to handle this. We have to wrap the components we want to be swipeable with the PanGestureHandler component.

...
import { PanGestureHandler } from "react-native-gesture-handler";
...
function IncomingCall() {
  return (
      ...
        <PanGestureHandler activeOffsetY={[0, 0]}>
          <Animated.View style={[styles.repeatContainer, repeatTranslateStyle]}>
            <Text style={styles.acceptText}>Swipe up to answer...</Text>
            <Text style=>📞</Text>
          </Animated.View>
        </PanGestureHandler>
        <Animated.Text style={[styles.declineText, declineOpacity]}>
          Swipe down to decline...
        </Animated.Text>
      ...
  )

We wouldn't be able to swipe just yet because we haven't handled the swipe gesture events yet. That's what we will be doing next.

Important: The first child of a PanGestureHandler component must be an Animated.View or else it would not work!

Handling Swipe Events

To handle the swipe events we would be using the PanGestureHandler component's onGestureEvent prop, and also define the events using the useAnimatedGestureHandler hook from the react-native-reanimated package. We'd create a shared value where we would store the gesture translation, which we can then use in our style for the component we want to be swipeable.

...
import { PanGestureHandlerGestureEvent } from "react-native-gesture-handler";
import { useAnimatedGestureHandler } from "react-native-reanimated";
...
type Context = {
  translateY: number;
};
...
function IncomingCall() {
  ...
  const swipe = useSharedValue(0);
  ...
  const gestureHandler = useAnimatedGestureHandler<
    PanGestureHandlerGestureEvent,
    Context
> ({
    onActive:({ translationY }, context) => {      
      ...
      swipe.value = translationY + context.translateY;
    },
    onFinish: () => {
      swipe.value = withTiming(0, { easing: Easing.inOut(Easing.linear) });
    },
    onStart: (_, context) => {
      context.translateY = swipe.value;
    },
  });
  ...
  const swipeStyle = useAnimatedStyle(() => ({
    transform: [{ translateY: swipe.value }],
  }));
  ...

  return (
    ...
        <PanGestureHandler
          activeOffsetY={[0, 0]}
          onGestureEvent={gestureHandler}>
          <Animated.View style={[styles.repeatContainer, repeatTranslateStyle]}>
            <Text style={styles.acceptText}>Swipe up to answer...</Text>
            <Animated.View style={swipeStyle}>
              <Text style=>📞</Text>
            </Animated.View>
          </Animated.View>
        </PanGestureHandler>
    ...
  )
}

...

We wrap our icon placeholder with an Animated.View which we would apply the swipe styles to. This makes the icon move with the swipe gesture based on the translationY value we get from the PanGestureHandler.

The onActive callback is used to get the current value of our swipe translationY on the screen and this is stored in the swipe shared value. The onFinish callback is used to reset the swipe shared value to 0 after the gesture is finished. The onStart callback is used to store the swipe value in a context object, so that we do not start from 0 again when we start a new gesture.

We should get this on our screen:

However we quickly notice that we can swipe our icon to the very top and very bottom of our screens, this is not what we intended, so we write a clamp worklet function to handle this by limiting the value we assign to the swipe shared value to be between the minimum and maximum we specify.

...
function clamp(number: number, min: number, max: number) {
  "worklet";
  return Math.min(Math.max(number, min), max);
}
...
const maxClamp = 60;
const minClamp = -80;
...
function IncomingCall() {
  ...
  const gestureHandler = useAnimatedGestureHandler<
    PanGestureHandlerGestureEvent,
    Context
> ({
    onActive:({ translationY }, context) => {
      ...
      const current = translationY + context.translateY;
      swipe.value = clamp(current, minClamp, maxClamp);
    },
  ...
  });
  ...
}
...

Now our swipe shared value will be clamped between the -80 and 60 as specified above. This results in:

Handling Loop Animation On Swipe

From what we have so far above, you'd notice that when we start swiping, our loop animation is still repeating, this is because we are using the withRepeat function to repeat the animation. The preffered behaviour would be to stop the animation altogether when the user starts swiping and then start it again when the user stops swiping. We can do this by setting the repeatTranslate shared value to 0 in the onActive callback and then calling the doRepeat worklet function in the onFinish callback.

...
  const gestureHandler = useAnimatedGestureHandler<
    PanGestureHandlerGestureEvent,
    Context
> ({
    onActive:({ translationY }, context) => {
      ...
      repeatTranslate.value = 0;
      const current = translationY + context.translateY;
      swipe.value = clamp(current, minClamp, maxClamp);
    },
    onFinish: () => {
      swipe.value = withTiming(0, { easing: Easing.inOut(Easing.linear) });
      doRepeat();
    },
    ...
})

We should get this on our screens:

Handling Opacity Of Texts When Swiping

According to the reference, when we start swiping, both the accept text and decline text would fade out. And when the swipe gesture is finished, both the accept text and decline text would fade in. We can achieve this in two ways, either interpolating on the swipe shared value or creating a shared value textOpacity which we can the animate to 0 in the onStart callback, and animate to 1 in the onFinish callback. Both would give similar results, but I prefer the latter.

...
function IncomingCall() {
    ...
    const textOpacity = useSharedValue(1);
    ...
     const acceptOpacity = useAnimatedStyle(() => ({
        opacity: textOpacity.value,
    }));
    const declineOpacity = useAnimatedStyle(() => {
        const opacity = interpolate(
        repeatTranslate.value,
        [-50, 0],
        [0, 1],
        Extrapolate.CLAMP,
        );
        return {
        opacity: repeatTranslate.value === 0 ? textOpacity.value : opacity,
        };
    });
    ...
    const gestureHandler = useAnimatedGestureHandler<
    PanGestureHandlerGestureEvent,
    Context
> ({
    onActive:({ translationY }, context) => {        
        ...
        textOpacity.value = withTiming(0);
    },
    onFinish: () => {
        ...
        textOpacity.value = withTiming(1);
        ...
    },
    ...
    });
    ...
    return (
        ...
        <Animated.Text style={[styles.acceptText, acceptOpacity]}>
          Swipe up to answer...
        </Animated.Text>
        ....
        <Animated.Text style={[styles.declineText, declineOpacity]}>
          Swipe down to decline...
        </Animated.Text>
        ...
    )
}
...

We modified declineOpacity style and set the opacity conditionally based on the repeatTranslate shared value. We also added acceptOpacity style for the accept text.

This should result in:

Building Our Icon Animation

Next thing to do is to remove our icon placeholder and replace it with our animated icon. We are going to be using SVG's to build our icon, I grabbed one of the icons from SVG Repo and converted it to a React Native component using SVGR. Here is the converted icon component in a separate file "PhoneIcon.tsx":

import * as React from "react";
import Svg, { Path, SvgProps } from "react-native-svg";

function PhoneIcon(props: SvgProps) {
  return (
    <Svg fill="none" height={24} width={24} {...props}>
      <Path
        d="M3.51 2l3.64.132A1.961 1.961 0 018.89 3.37l1.077 2.662c.25.62.183 1.326-.18 1.884l-1.379 2.121c.817 1.173 3.037 3.919 5.388 5.526l1.752-1.079a1.917 1.917 0 011.483-.226l3.485.894c.927.237 1.551 1.126 1.478 2.103l-.224 2.983c-.078 1.047-.935 1.869-1.952 1.75C6.392 20.429-1.481 2 3.511 2z"
        stroke="#0f0"
        strokeLinecap="round"
        strokeLinejoin="round"
        strokeWidth={2}
      />
    </Svg>
  );
}

export default PhoneIcon;

Now we can import it and use it in our screen to replace the placeholder icon:

return (
    ...
      <Animated.View style={[styles.iconContainer, swipeStyle]}>
        <PhoneIcon />
      </Animated.View>
    ...
  )
  ...
  const styles = StyleSheet.create({
    ...
    iconContainer: {
      alignItems: "center",
      backgroundColor: "#3A3B40",
      borderRadius: 40,
      height: 80,
      justifyContent: "center",
      width: 80,
   },
   ...
  });
...

We should get this on our screen:

Animating Our SVG Icon

We would like to animate the stroke color and also rotate the icon based on the swipe gesture, we'd have to turn our Path to an Animated one and also wrap the icon in an Animated.View. to apply the rotation style.

...
import { StyleProp, ViewStyle } from "react-native";
import Animated from "react-native-reanimated";
import Svg, { Path, PathProps, SvgProps } from "react-native-svg";

const AnimatedPath = Animated.createAnimatedComponent(Path);

interface IconProps extends SvgProps {
  animatedProps?: Partial<Animated.AnimateProps<PathProps>> | undefined;
  rotationStyle?: StyleProp<Animated.AnimateStyle<StyleProp<ViewStyle>>>;
}

function PhoneIcon(props: IconProps) {
  return (
    <Animated.View style={props.rotationStyle}>
        <Svg fill="none" height={24} width={24} {...props}>
            <AnimatedPath
                animatedProps={props.animatedProps}
                d="M3.51 2l3.64.132A1.961 1.961 0 018.89 3.37l1.077 2.662c.25.62.183 1.326-.18 1.884l-1.379 2.121c.817 1.173 3.037 3.919 5.388 5.526l1.752-1.079a1.917 1.917 0 011.483-.226l3.485.894c.927.237 1.551 1.126 1.478 2.103l-.224 2.983c-.078 1.047-.935 1.869-1.952 1.75C6.392 20.429-1.481 2 3.511 2z"
                strokeLinecap="round"
                strokeLinejoin="round"
                strokeWidth={2}
            />
        </Svg>
    </Animated.View>
  );
}

Note: You might get an error message relating to animatedProps, do not panic yet we would fix it soon

Let us define our animatedProps and rotationStyle:

...
import {
    ...
    Extrapolate,
    interpolate,
    interpolateColor,
    ...
} from "react-native-reanimated";
import { PathProps } from "react-native-svg";
...
function IncomingCall() {
    ...
    const iconProps = useAnimatedProps<PathProps>(() => {
    const color = interpolateColor(
      swipe.value,
      [-50, 0, 50],
      ["#ffffff", "#00ff00", "#ffffff"],
    );
    return {
      stroke: color,
      };
    });

  const rotationStyle = useAnimatedStyle(() => {
    const rotation = interpolate(
      swipe.value,
      [10, 40],
      [0, 135],
      Extrapolate.CLAMP,
    );
    return {
      transform: [{ rotate: `${rotation}deg` }],
    };
  });

    return (
        ...
        <Animated.View style={[styles.iconContainer, swipeStyle]}>
            <PhoneIcon animatedProps={iconProps} rotationStyle={rotationStyle} />
        </Animated.View>
        ...
    );
}
...

From the snippet above we are interpolating on the swipe value to get the color and rotation. To interpolate colors we need to use the interpolateColor function rather than the regular interpolate function.

We also need to animate the icon container's background color based on the swipe gesture. We just need to add a backgroundColor property to our swipeStyle and then use the interpolateColor function to achieve the desired result based on the swipe gesture.

...
function IncomingCall() {
    ....
    const swipeStyle = useAnimatedStyle(() => {
        const backgroundColor = interpolateColor(
        swipe.value,
        [-100, 0, 50],
        ["#00ff00", "#3A3B40", "#ff0000"],
        );
        return {
        backgroundColor,
        transform: [{ translateY: swipe.value }],
        };
  });
  ...
  return (
    ...
    <Animated.View style={[styles.iconContainer, swipeStyle]}>
        <PhoneIcon
            animatedProps={iconProps}
            rotationStyle={rotationStyle}
        />
    </Animated.View>
    ...
  )
}
...

This should give us the following result:

Heading Animation

Next, we would also like to animate the heading container, which houses the Call from text, the Mobile +... text and their name (if saved).

We can just convert the container to an animated one and then interpolate on the swipe shared value to get some transform properties, we are interested in the scale, translateY and opacity properties.

...
function IncomingCall() {
    ....
    const headingStyle = useAnimatedStyle(() => {
      const opacity = interpolate(
        swipe.value,
        [minClamp, 0, maxClamp],
        [0, 1, 0],
        Extrapolate.CLAMP,
      );
      const scale = interpolate(
        swipe.value,
        [minClamp, 0, maxClamp],
        [0.7, 1, 0.7],
        Extrapolate.CLAMP,
      );
      const translateY = interpolate(
        swipe.value,
        [minClamp, 0, maxClamp],
        [200, 0, 200],
        Extrapolate.CLAMP,
      );
      return {
        opacity,
        transform: [{ scale }, { translateY }],
      };
    });
    ...
    return (
        ....
        <Animated.View style={headingStyle}>
            <Text style={styles.callingText}>Call from</Text>
            <Text style={styles.titleText}>Tech Support NG</Text>
            <Text style={styles.callingText}>Mobile +234 00 000 000</Text>
        </Animated.View>
        ...
    );
}
...

Lastly, we might want to define actions to do when we reach the threshold for accept or rejecting / declining the call. For now we can just log to the console, this can be defined in the onFinish callback.

...
  const gestureHandler = useAnimatedGestureHandler<
    PanGestureHandlerGestureEvent,
    Context
> ({
    onFinish:() => {    ...
      if (swipe.value <= minClamp) {
        console.log("Accepting call");
      } else if (swipe.value >= maxClamp) {
        console.log("Rejecting call");
      }
    ...
    },
  });
...

Bonus

The phone icon vibrates as it is ringing. Let us declare a shared value vibration, and react to the repeatTranslate shared value, so we can play the animation at a certain point in the repeatTranslate's translation cycle.

...
import {
    ...
    useAnimatedReaction,
    withSequence,
    ...
} from "react-native-reanimated"
...
function IncomingCall() {
    ...
    const vibration = useSharedValue(0);
    ...  
    useAnimatedReaction(
        () => repeatTranslate.value,
        (prep) => {
        if (prep === -50) {
            vibration.value = withSequence(
            withTiming(-8, { duration: 25 }),
            withRepeat(withTiming(8, { duration: 50 }), 12, true),
            withTiming(0, { duration: 25 }),
            );
        }
        },
    );
    ...
    const rotationStyle = useAnimatedStyle(() => {
        const rotation = interpolate(
        swipe.value,
        [10, 40],
        [0, 135],
        Extrapolate.CLAMP,
        );
        return {
        transform: [
            {
            rotate: `${
                repeatTranslate.value === 0 && swipe.value >= 10
                ? rotation
                : vibration.value
            }deg`,
            },
        ],
        };
    });
    ...

}
...

So we effectively modified our rotation style and set the rotate transform property dynamically based on repeatTranslate and swipe values.

Our final result should look like this:

Refactor, Refactor, Refactor...

Just as a bonus tip, we can refactor our code to make it more readable and maintainable using custom hooks and also moving some of our jsx into custom components. I wouldn't be touching on the latter, rather I would just provide a snippet on the custom hook part, you can find the refactored code in the GitHub repository.

I have moved all the logic for animations into a custom hook called useIncomingCallAnimation. This hook takes two arguments, a callback for the accept action, and a callback for the reject action, then it returns all our animated styles and props, and gesture handler just as we have used them above.

Using this hook in IncomingCall.tsx component, we can now write the following:

...
function IncomingCall() {
    ...
    const {
        acceptOpacity,
        declineOpacity,
        gestureContainerStyle,
        gestureHandler,
        headingStyle,
        iconProps,
        repeatTranslateStyle,
        rotationStyle,
        swipeStyle,
    } = useIncomingCallAnimation(
        () => console.log("Accepting call"),
        () => console.log("Rejecting call"),
    );
...

Click here to checkout the refactored code for the custom hook.