Android Animations With React Native (Part 1)!
Android Incoming Call Animation.
- Overview
- Setting Up The Project
- Building The Project
- Handling Swipes
- Building Our Icon Animation
- Refactor, Refactor, Refactor...
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.
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>
);
}
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.