One of the ways to make an app feel more polished is by introducing animations and transitions that lead to better visual feedback when users are interacting with your app.
For me personally, it has been fun looking for small things that I can do and will make up a great addition in the end.
Let's explore how to bring a better experience when users are toggling a "like" button by reproducing (or trying to) the Instagram version.
Why Reanimated?
React Native has its own animation APIs and they work great for a lot of cases. The issue is that for more complex scenarios it will not support running those animations in the native thread, which can cause performance issues and unresponsiveness since it then competes for resources with JavaScript.
React Native Reanimated is a library that allows developers to write smooth animations on React Native by making sure they are rendered in the native thread and not blocking JavaScript from handling other stuff at the same time.
They recently rewrote the entire library by re-thinking both the architecture and all of the APIs they provide.
One of the goals being to make it easier for developers to use and understand, so I've been using version 2 for some time and I think you should too!
As of today the v2 is in Release candidate so it will soon be ready for production.
A simple "like" button
To start a simple like button, we would need a component that can handle a press event, a state to toggle between liked and not-liked, and an icon.
If you want to follow along go to your terminal and hit
npx crna --template with-reanimated2
to start a brand new React Native project with Expo and Reanimated v2 installed.
The minimal code would look like this:
import React, { useState } from "react";
import { Pressable } from "react-native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
const LikeButton = () => {
const [liked, setLiked] = useState(false);
return (
<Pressable onPress={() => setLiked((isLiked) => !isLiked)}>
<MaterialCommunityIcons
name={liked ? "heart" : "heart-outline"}
size={32}
color={liked ? "red" : "black"}
/>
</Pressable>
);
};
So we used:
-
useState
to store and toggle the like value; -
Pressable
component to handle the press event, toggling the state based on the current previous value ofliked
; -
MaterialCommunityIcons
component that either shows an outline version of the heart or the filled one based on the state.
If you're following along and you render that component in a page you will have a result like this:
It works, but it's boring.
Introducing animations
Instagram's Like button brings a nice experience by fading in and out with scale its state when it's pressed.
The strategy is then to scale the non-liked version from 1 to 0 (making it disappear) and then the liked version from 0 to 1 (making it appear on top).
We start by having two icons stacked on top of each other instead of only one:
<Pressable onPress={() => setLiked((isLiked) => !isLiked)}>
<Animated.View
style={[
StyleSheet.absoluteFillObject,
{ transform: [{ scale: liked ? 0 : 1 }] },
]}
>
<MaterialCommunityIcons name={"heart-outline"} size={32} color={"black"} />
</Animated.View>
<Animated.View style={[{ transform: [{ scale: liked ? 1 : 0 }] }]}>
<MaterialCommunityIcons name={"heart"} size={32} color={"red"} />
</Animated.View>
</Pressable>
-
Animated.View
is used to wrap the icons, which we will animate. import theAnimated
fromreact-native-reanimated
; - We use absolute positioning in the first view, so the other one can stay on top;
- We apply the
scale
styles based on the state's value, nothing has changed so far. Still boring.
With this setup, we can now animate the scale. Exciting!
react-native-reanimated
provides some very useful hooks for us, we will use them.
We change React's useState
by Reanimated's useSharedValue
, which also gives us a state, but this one can be used in both JavaScript and Native threads.
const liked = useSharedValue(0);
We will now use numbers instead of booleans so we can interpolate and use them directly into the styles as well.
The onPress
event now has to be changed too since we don't have setState
anymore:
onPress={() => (liked.value = withSpring(liked.value ? 0 : 1))}
Note that we use
.value
here to reference the actual value of the state.
withSpring
is part of the magic, that is a method provided by Reanimated that tells it to change the animated value using a spring animation with default values.
Finally, we need to update the styles correctly based on the animated value, we will use useAnimatedStyle
:
const outlineStyle = useAnimatedStyle(() => {
return {
transform: [
{
scale: interpolate(liked.value, [0, 1], [1, 0], Extrapolate.CLAMP),
},
],
};
});
const fillStyle = useAnimatedStyle(() => {
return {
transform: [
{
scale: liked.value,
},
],
};
});
The useAnimatedStyle
hook receives a function that returns a style that gets updated based on animation values, we moved the inline styles from Animated.View
to this hook instead.
For the outline style we need to interpolate the animated value in the opposite way:
- If
liked.value
is 0 it means the scale for the outline style should be 1, ifliked.value
is 1 then the scale should be 0.
The fill style needs no interpolation since it follows liked.value
linearly.
Here's the component so far and the current result:
const LikeButton = () => {
const liked = useSharedValue(0);
const outlineStyle = useAnimatedStyle(() => {
return {
transform: [
{
scale: interpolate(liked.value, [0, 1], [1, 0], Extrapolate.CLAMP),
},
],
};
});
const fillStyle = useAnimatedStyle(() => {
return {
transform: [
{
scale: liked.value,
},
],
};
});
return (
<Pressable onPress={() => (liked.value = withSpring(liked.value ? 0 : 1))}>
<Animated.View style={[StyleSheet.absoluteFillObject, outlineStyle]}>
<MaterialCommunityIcons
name={"heart-outline"}
size={32}
color={"black"}
/>
</Animated.View>
<Animated.View style={fillStyle}>
<MaterialCommunityIcons name={"heart"} size={32} color={"red"} />
</Animated.View>
</Pressable>
);
};
That is much better 🎉
If you pay close attention you'll see that the filled icon bounces too much in the end and can still be seen.
We can fix that by also styling the opacity, so it's not visible.
Change the fillStyle
so it looks like this:
const fillStyle = useAnimatedStyle(() => {
return {
transform: [{ scale: liked.value }],
opacity: liked.value,
};
});
Nice one!
I posted the completed example in this gist with the entire component so you can more easily understand it and even copy/paste in our own apps.
We're done
It's always interesting to explore these smaller interactions that can make a real difference in an app, let me know if you have any others you usually apply by reaching out on Twitter.
I hope you learned something fun, and as always, I'm open to any kind of feedback at all you might have for me.
Thank's for your time 👋
This post was initially published in my own blog 😄
Top comments (2)
Great article , thanks for sharing
If you have more than one "Like Button" component, use TouchableOpacity from react-native-gesture-handler instead of Pressable to avoid some bugs on Android platform