The Widlarz Group Blog
Create Spotify heart animation with Reanimated 2 - tutorial
June 08, 2022
Visualisation
Introduction
Before we begin, let me give you a short introduction.
If you are, by any chance (and chances are high I suppose), a Spotify user, you have seen this cool little animation fired up whenever you like a song :)
Those are the things that really add to the user experience, so why not try recreating it with React Native and reanimated library in this article!
It’s going to be fun, without a doubt!
The main technologies I use in this tutorial are:
- React Native
- Typescript
- Reanimated 2
- Styled-components
React Native and Reanimated 2 should be obvious.
Why typescript?
I chose TS over JS for this article as TS is simply fun and very useful, and I couldn’t recommend it more! :) It’s like a guardian angel for your code, and I really love having a type-safe project. For anyone wanting to code-along in plain JS, just loose the TS parts, it shouldn’t be a huge problem.
I would also like to encourage every React Native user to try TS.
Why Styled-components?
Because the idea, and the library itself, are great!
When it’s used in the right way, it can make your code incredibly legible.
It’s really super easy. Trust me and give it a try :)
OK! Let’s bite some meat(code) :)
Repository
This article has its own repository. You can clone it from the link below:
Enjoy!
Table of Contents
- Introduction
- Repository
- Environment setup
- Main component - SpotifyHeart
- Heart Button
- useHeartPress
- useMainHeartAnimation
- FlyingHeartsRenderer
- SingleFlyingHeart
- useFlyingHeartAnimatedStyle
- Bouncing Circles
- useBouncingCirclesAnimatedStyle
Environment setup
First, we need to add Reanimated 2 and Styled-components to our project using npm:
npm i react-native-reanimated
npm i styled-components
or yarn
yarn add react-native-reanimated
yarn add styled-components
We are using typescript, so we also need to add styled-components types by npm:
npm i @types/styled-components
npm i @types/styled-components-react-native
or yarn
yarn add @types/styled-components
yarn add @types/styled-components-react-native
Now we can start creating the animation!
Main component - SpotifyHeart
First let’s create a Container
for all main components.
This Container
is just a styled SafeAreaView
:
Filename: SpotifyHeart.styled.ts
import styled from 'styled-components/native'
export const Container = styled.SafeAreaView`
flex: 1;
justify-content: flex-end;
align-items: center;
background-color: black;
`
It’s equal to:
<SafeAreaView style={{flex: 1, justifyContent: 'flex-end', alignItems: 'center', backgroundColor: 'black'}}>
But we can give it our own name and hide all styles in the other file.
To create a styled component, we need to import the styled
element from styled-components/native
. The styled
allows us to upgrade any component into a styled component. We use it here to create the Container
. All style properties are inside the backticks
- in other words, it’s just a template string. We have to export it to use it in the tsx
file.
Now we can go to the next step and create the main component:
Filename: SpotifyHeart.tsx
import React, { useState } from 'react'
import { useSharedValue } from 'react-native-reanimated'
import { HeartButton } from './components/HeartButton/HeartButton'
import { Container } from './SpotifyHeart.styled'
import { BouncingCircles } from './components/BouncingCircles/BouncingCircles'
import { FlyingHeartsRenderer } from './components/FlyingHeartsRenderer/FlyingHeartsRenderer'
import { Coords } from '../../types/types'
export const SpotifyHeart = () => {
const [isBgColored, setIsBgColored] = useState<boolean>(false)
const heartAnimation = useSharedValue<number>(0)
const startCoords = useSharedValue<Coords>({ x: 0, y: 0 })
return (
<Container>
<HeartButton
isBgColored={isBgColored}
setIsBgColored={setIsBgColored}
heartAnimation={heartAnimation}
/>
<FlyingHeartsRenderer startCoords={startCoords} heartAnimation={heartAnimation} />
<BouncingCircles heartAnimation={heartAnimation} isBgColored={isBgColored} />
</Container>
)
}
Imports:
useState
- we will use it to create boolean stateuseSharedValue
- this hook creates an object with value, which can be used for animations. This value can be read or changed by other elements or functions. To make things simple, we use it when we need value to change smoothly from one point to another. For example:0 -> 0.01 -> 0.02 -> 0.03 -> 0.04 -> ... -> 0.97 -> 0.98 -> 0.99 -> 1
To read more, please click HEREHeartButton
/BouncingCircles
/FlyingHeartsRenderer
- UI components, we will explore them later in this tutorialContainer
- our styled component (SafeAreaView)Coords
- type for thestartCoords
At the beginning we need to create 3 values:
isBgColored
- we will need it to check the current state of our heart (heart button) at the moment (green / black with a white border)heartAnimation
- shared value. We will use it to animate elements. Its fundamental architecture allows it to rise and fall in a way that can be used for animation. In the process, this value is going through every single hundredth number. For example from 0 to 1: (0.01, 0.02, 0.03, 0.04, 0.05 ... 0.96, 0.97, 0.98, 0.99, 1
). To read more, please click HEREstartCoords
- start coordinates on the X and Y axes. All UI elements used by us will be placed at this point at the beginning
Now we can wrap all UI elements in our Container
wrapper and pass the required props.
Heart Button
Filename: HeartButton.tsx
import React, { Dispatch, SetStateAction } from 'react'
import Animated, { useSharedValue } from 'react-native-reanimated'
import {
ScaleViewContainer,
ShakeViewContainer,
StyledAnimatedPath,
StyledHeartButton,
StyledSvg,
} from './HeartButton.styled'
import { useMainHeartAnimation } from '../../../../hooks/useMainHeartAnimation'
import { useHeartPress } from '../../../../hooks/useHeartPress'
interface Props {
isBgColored: boolean
setIsBgColored: Dispatch<SetStateAction<boolean>>
heartAnimation: Animated.SharedValue<number>
}
export const HeartButton = ({ isBgColored, setIsBgColored, heartAnimation }: Props) => {
const heartTransform = useSharedValue<number>(1)
const { animatedProps, scaleAnimatedStyle, shakeAnimatedStyle } = useMainHeartAnimation(
isBgColored,
heartTransform
)
const { heartPress } = useHeartPress(isBgColored, setIsBgColored, heartAnimation, heartTransform)
return (
<StyledHeartButton onPress={heartPress}>
<ShakeViewContainer style={[shakeAnimatedStyle]}>
<ScaleViewContainer style={[scaleAnimatedStyle]}>
<StyledSvg>
<StyledAnimatedPath animatedProps={animatedProps} />
</StyledSvg>
</ScaleViewContainer>
</ShakeViewContainer>
</StyledHeartButton>
)
}
Imports:
Dispatch
,SetStateAction
- necessary to properly type our props -setIsBgColored
Animated
- an element from the Reanimated library. We can use it to create different animated components. Here we need it to type theheartAnimation
propsuseSharedValue
- this hook creates an object with value, which can be used for animations. This value can be read or changed by other elements or functions. To make things simple, we use it when we need value to change smoothly from one point to another. For example:0 -> 0.01 -> 0.02 -> 0.03 -> 0.04 -> ... -> 0.97 -> 0.98 -> 0.99 -> 1
To read more, please click HEREScaleViewContainer
,ShakeViewContainer
,StyledAnimatedPath
,StyledHeartButton
,StyledSvg
- styled-components. We will explore them belowuseMainHeartAnimation
- custom hook containing animation styles and logic we need to pass to the proper componentsuseHeartPress
- a custom hook that contains everything that must happen when we click on the heart
Let’s take a short break and take a closer look at those styled-components we mentioned a few lines above:
Filename: HeartButton.styled.ts
import styled from 'styled-components/native'
import Svg, { Path, PathProps } from 'react-native-svg'
import Animated, { AnimateProps } from 'react-native-reanimated'
import { TouchableOpacity } from 'react-native'
const AnimatedPath = Animated.createAnimatedComponent(Path)
export const StyledHeartButton = styled(TouchableOpacity).attrs(() => ({
activeOpacity: 1,
}))`
width: 70px;
height: 70px;
display: flex;
justify-content: flex-end;
align-items: center;
position: absolute;
z-index: 1;
bottom: 30px;
`
export const ScaleViewContainer = styled(Animated.View)`
width: 70px;
height: 70px;
display: flex;
justify-content: flex-end;
align-items: center;
`
export const ShakeViewContainer = styled(Animated.View)`
width: 70px;
display: flex;
align-items: center;
height: 140px;
justify-content: flex-start;
bottom: -70px;
`
export const StyledSvg = styled(Svg).attrs({
viewBox: '-20 -20 552.131 552.131',
})`
width: 70px;
height: 70px;
`
interface AnimatedPathProps {
animatedProps: Partial<AnimateProps<PathProps>>
}
export const StyledAnimatedPath = styled(AnimatedPath).attrs((props: AnimatedPathProps) => ({
animatedProps: props.animatedProps,
strokeWidth: 30,
d: 'M511.489,167.372c-7.573-84.992-68.16-146.667-144.107-146.667c-44.395,0-85.483,20.928-112.427,55.488 c-26.475-34.923-66.155-55.488-110.037-55.488c-75.691,0-136.171,61.312-144.043,145.856c-0.811,5.483-2.795,25.045,4.395,55.68 C15.98,267.532,40.62,308.663,76.759,341.41l164.608,144.704c4.011,3.541,9.067,5.312,14.08,5.312 c4.992,0,10.005-1.749,14.016-5.248L436.865,340.13c24.704-25.771,58.859-66.048,70.251-118.251 C514.391,188.514,511.66,168.268,511.489,167.372z',
}))`
width: 50px;
height: 50px;
`
Imports:
styled
- we use it to create any styled-componentSvg
,Path
- they’re just svg elements we want to stylePathProps
- a type that we use to properly type ourPath
elementAnimated
- an element from the Reanimated library. We can use it to create different animated components. Here we need it to createAnimatedPath
AnimateProps
- we use it to properly type ourPath
element. It’s a generic type which accepts the type of the chosen element. In this casePath
/PathProps
TouchableOpacity
- a well known React Native element. We use it to create touchable elements, for example buttons
At first, we will modify our Path
to the AnimatedPath
.
We need to use Animated
to do that. If we take a closer look at the Animated
, it can give us 3 animated components by default: Animated.View
, Animated.Text
, and Animated.Image
.
However, in some cases you may want to turn another component into an Animated component, so it can take advantage of Animated.Value in its styles or properties. Here we need to animate Path
, so we need to convert our Path
element to the animated version.
To do that, we need to use the function createAnimatedComponent
from Animated
.
AnimatedPath
is just a name I have chosen, you can name it however you want.
Now that the AnimtedPath
exist, we can start creating all the necessary styled-components:
StyledHeartButton
It’s just a styled TouchableOpacity
. It will be a wrapper for our svg heart. Its function is to make our heart clickable.
When we create a styled component, we can call the attrs
method, which takes a function as an argument. In this function, we can return an object with all props that we can normally pass to the element(component) as if we were adding a JSX <View />
. For example, we can pass props activeOpacity
to the TouchableOpacity
.
Because I want to cover other elements under my HeartButton
, I give it possition: absolute
with z-index: 1
. I would also like to center all the elements inside a button, so I use flex for that.
ScaleViewContainer
It’s a styled Animated.View.
What is an Animated.View?
It’s just a View
that can be animated by Reanimated. Simple.
I chose a name with ‘Scale’ inside, because this View
will help me scale my heart.
ShakeViewContainer
The same situation as with ScaleViewContainer
, but instead of scaling, this container helps me shake the heart.
We should notice one thing - this container is twice as high as the others.
Just like I said, we want to use it to shake our heart. If we set the height to 70px
, then the center of mass will be placed in the center of the heart (whole heart will be rotating):
But the effect I am after is that the bottom of the heart stays in the same position and only the top is shaking.
How to do that?
We just need to move the center of the mass to the bottom of our heart. We can do that by making container twice height and placing the heart at the top of the container (I’ve changed the background color to illustrate it better):
After that we can just set bottom: -70px
and TADAM!
StyledSvg
This element is a styled wrapper for the path.
StyledAnimatedPath
It’s a path element with an extra prop - animatedProps
. Above we can see type for it - AnimatedPathProps
.
Other props are typical props that we can pass to the Path
element.
Thanks to the styled-components, we were able to pass the props here (not in the tsx file).
Please analyze how easily we can pass external props here.
Let’s come back to the main HeartButton
component.
Filename: HeartButton.tsx
import React, { Dispatch, SetStateAction } from 'react'
import Animated, { useSharedValue } from 'react-native-reanimated'
import {
ScaleViewContainer,
ShakeViewContainer,
StyledAnimatedPath,
StyledHeartButton,
StyledSvg,
} from './HeartButton.styled'
import { useMainHeartAnimation } from '../../../../hooks/useMainHeartAnimation'
import { useHeartPress } from '../../../../hooks/useHeartPress'
interface Props {
isBgColored: boolean
setIsBgColored: Dispatch<SetStateAction<boolean>>
heartAnimation: Animated.SharedValue<number>
}
export const HeartButton = ({ isBgColored, setIsBgColored, heartAnimation }: Props) => {
const heartTransform = useSharedValue<number>(1)
const { animatedProps, scaleAnimatedStyle, shakeAnimatedStyle } = useMainHeartAnimation(
isBgColored,
heartTransform
)
const { heartPress } = useHeartPress(isBgColored, setIsBgColored, heartAnimation, heartTransform)
return (
<StyledHeartButton onPress={heartPress}>
<ShakeViewContainer style={[shakeAnimatedStyle]}>
<ScaleViewContainer style={[scaleAnimatedStyle]}>
<StyledSvg>
<StyledAnimatedPath animatedProps={animatedProps} />
</StyledSvg>
</ScaleViewContainer>
</ShakeViewContainer>
</StyledHeartButton>
)
}
The variables that we need to create:
heartTransform
- the second shared value that we created. This time - to transform the heart. We did this because now we have to go from 1 to 0 (not like before, from 0 to 1), and the animations will be a bit different from theheartAnimation
.useMainHeartAnimation
- this hook takes care of the heart button animation (we will come back to it soon)heartPress
- this hook takes care of the heart button onPress (we will come back to it soon)
Let’s summarize the components we render here:
StyledHeartButton
- styled TouchableOpacityShakeViewContainer
- styled View (we pass an extra animated style here)ScaleViewContainer
- styled View (we pass an extra animated style here)StyledSvg
- styled SvgStyledAnimatedPath
- styled Path (we pass an extra animated props here)
When we summarize it like that, I think it’s super easy to read.
Next in the line is useHeartPress
.
useHeartPress
This hook handles everything that has to be done when we click on the main heart.
(Please notice that the heartAnimation.value
and the heartTransform.value
are animated differently. That’s why, as I’ve said, I had to create another shared value)
Let’s take a closer look:
Filename: useHeartPress.ts
import Animated, { Easing, withSequence, withSpring, withTiming } from 'react-native-reanimated'
import { Dispatch, SetStateAction } from 'react'
export const useHeartPress = (
isBgColored: boolean,
setIsBgColored: Dispatch<SetStateAction<boolean>>,
heartAnimation: Animated.SharedValue<number>,
heartTransform: Animated.SharedValue<number>
) => {
const heartPress = () => {
if (isBgColored) {
setIsBgColored(false)
heartAnimation.value = 0
} else {
setIsBgColored(true)
heartAnimation.value = withTiming(1, {
duration: 800,
easing: Easing.bezier(0.12, 0, 0.39, 0).factory(),
})
}
heartTransform.value = withSequence(
withTiming(0.8, { duration: 200 }),
withSpring(1, { damping: 0.8, mass: 0.2 })
)
}
return { heartPress }
}
Imports:
Animated
,Dispatch
,setStateAction
- here we need those elements only for type incoming paramswithSequence
- we can call it the animations ‘helper’. Its role is to combine a few animations. To read more, please click HEREwithSpring
- an animation provided by the Reanimated library. We use it when we want to get a spring-based animation with a cool bouncing effect. To read more, please click HEREwithTiming
- an animation provided by the Reanimated library. We use it when we want to start a time-based animation. To read more, please click HERE
As we said, the main function of this hook is to prepare a function for the onPress
of the main heart.
This function is based on the state of our heart. In fact, we have two states:
- when the heart is black with a white border
- when the heart is green (colored)
If the heart is colored, and we click it, we want to change its color to black, so also the state for false
(heart is not colored). Also, we want to change our heartAnimation.value
to 0 (default).
If the heart is black (with a white border), we want to change its color to green (state for true). Also, we want to animate heartAnimation.value
to 1 with timing. Timing duration is 800ms, with the second argument easing
. This argument drives the easing curve for the animation. We can choose one of the pre-configured options. I decided to use bezier
. To read more, please click HERE
Regardless of the state, we still need to animate heartTransform.value
. This value will be necessary to rotate or scale our heart. Here, we will use the withSequence
animation.
First, our heartTransform.value
will be animated using withTiming
to value 0.8
, and when the value reaches this point, the animation will change to the withSpring
at the end (from the value 0.8
to 1
).
As the second argument withSpring
takes object with few keys. Two of them are:
damping
- as the name says, it’s responsible for damping the spring (bumping) effect. The higher the value, the more damped the effect.mass
- again, as the name says, it’s responsible for the animation mass. The higher the value, the heavier the animation.
I encourage you to try it yourself because it’s really hard to explain it by words :)
That’s all folks! Now we need to return our heartPress
function and unpack it in the HeartButton
component.
Let’s go to the useMainHeartAnimation
hook and take a look what we do with those values and how we animate our main heart.
useMainHeartAnimation
This hook prepares the animated styles and the animated props we would use in our components. First let’s take a look at the whole file:
Filename: useMainHeartAnimation.ts
import Animated, {
Easing,
interpolate,
interpolateColor,
useAnimatedProps,
useAnimatedStyle,
useDerivedValue,
withTiming,
} from 'react-native-reanimated'
import { theme } from '../theme'
export const useMainHeartAnimation = (
isBgColored: boolean,
heartTransform: Animated.SharedValue<number>
) => {
const progress = useDerivedValue(() => {
return withTiming(isBgColored ? 0 : 1, {
duration: 400,
easing: Easing.bezier(0.65, 0, 0.35, 1).factory(),
})
})
const animatedProps = useAnimatedProps(() => {
const fill = interpolateColor(
progress.value,
[0, 1],
[theme.colors.spotifyGreen, theme.colors.backgroundColor],
'RGB'
)
const stroke = interpolateColor(
progress.value,
[0, 1],
[theme.colors.spotifyGreen, theme.colors.whiteBorder],
'RGB'
)
return { fill: fill, stroke: stroke }
})
const scaleAnimatedStyle = useAnimatedStyle(() => {
if (isBgColored) {
return {
transform: [{ scale: heartTransform.value }],
}
} else {
return {}
}
})
const shakeAnimatedStyle = useAnimatedStyle(() => {
if (!isBgColored) {
return {
transform: [
{
rotate:
interpolate(
heartTransform.value,
[0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4],
[0, -25, 0, 25, 0, 25, 0, -25, 0]
) + 'deg',
},
],
}
} else {
return {}
}
})
return { animatedProps, scaleAnimatedStyle, shakeAnimatedStyle }
}
Imports:
Animated
- an element from the Reanimated library. We can use it to create different animated components. Here we just need it to type theheartTransform
value.Easing
- helps us animate switching between values. To read more, please click HEREinterpolate
- this function transforms one scope to another. For example, if we have a shared value in the scope [0-1], we can change it to [0-100] or [50-0], etc. For example, if we move the square from the point **0 to 1, and interpolate this value to 0-50, then the square will move 50 times on. We can also declare a few points inside, and modify what will happen in different time points. For example,[0, 0.5, 1]
(input) can be modified to the[0, 0.7, 1]
(output). For what? It’s a bit crazy, but now that what should happen in point0.5
will happen in point0.7
. In fact, we extended the first range and shortened the second one. To read more, please click HEREinterpolateColor
- Interpolate for the colors. Transforms number values to color values. To read more, please click HEREuseAnimatedStyle
- creates animated style values for the Animated Components (for example, View). Returns the same values as classic style values, for example: width, rotate; but customized for the animations. To read more, please click HEREuseAnimatedProps
- creates animated props values for other elements, e.g. svg elements. It’s also a style value, but for svg elements. To read more, please click HEREuseDerivedValue
- this hook creates a shared value which is updated whenever one of the values used inside is changed. To read more, please click HEREwithTiming
- one of the default animations provided by Reanimated library. Creates animation based on time. We can, for example, set a duration of 1000 ms for some animation. To read more, please click HEREtheme
- just a theme that I always create in my projects. It contains certain style elements, for example colors.
Let’s take a look at the hook body.
progress
value
Our first value is progress
. I decided to use useDerivedValue
(not just useSharedValue
) here, because I needed to animate this value and make a condition depending on the state isBgColored
. This was the easiest and most comfortable way. I will use this value to interpolateColor
in the animatedProps
.
animatedProps
Here we are creating animated style for the Path
element. We need to return fill
and stroke
values (because we want to animate those two).
The first argument that the interpolateColor
takes is value. Here, we pass our previously instantiated progress value. Please notice that if the isBgColored
is true, then progress.value
is equal to 0, and if not, it’s equal to 1.
It’s very important because the second argument that interpolateColor
takes is range [0-1].
In other words, if progress.value
is equal to 0, then the value will go to 1.
If the progress.value
is equal to 1, then the value will go to 0.
The third argument are colors. The first color we pass is equal to 0. The second is equal to 1.
Then the range [0-1], means “from green (theme.colors.spotifyGreen), to black (theme.colors.backgroundColor)”.
This means that if the progress.value
is equal to 0, then the value will go from 0 to 1, in other words, from green to black.
If the progress.value
is equal to 1, then the value will go from 1 to 0, in other words, from black to green.
The fourth value is just some information about the color code in which we pass the value. I’m doing it using RGB, so the fourth argument is just RGB
.
After that we just need to return fill
and stroke
.
scaleAnimatedStyle
and shakeAnimatedStyle
Depending on the isBgColored
boolean value, whether it is true or false, we want to scale or to shake the heart.
In the first case, it’s really simple. We just need to transform the scale just as we would do in css, but instead of putting a ‘hardcoded’ value like 0.5 or 0.7, we want to put a value that will change in time, so we use heartTransform.value
(as we have set in hook useHeartPress
, its value is going from 0.8 to 1).
When we want to shake our heart, the situation gets a bit more complicated, but still noting we couldn’t handle.
As above, we need to transform something, but this time it will be rotate
instead of scale
.
Because we want our heart to go to the left, then to the right, then to the left, etc. to get the shaking effect, we need to interpolate heartTransform.value
.
At first, we’re changing its range from [0-1] to [0-4], and making a few ‘bullet points’.
interpolate(
heartTransform.value,
[0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4],
[0, -25, 0, 25, 0, 25, 0, -25, 0]
) + 'deg',
In the first point, the heart will rotate -25 deg, then in the second point it has to go back to 0 deg, so that then rotate 25 deg, again to 0 deg, and again to -25 deg…
We have 9 points because we need to move it 8 times (the first point is a start point).
FlyingHeartsRenderer
FlyingHeartsRenderer
is a container where we render all those small hearts, that show up and vanish behind the main heart:
Filename: FlyingHeartsRenderer.tsx
import React from 'react'
import { SingleFlyingHeart } from '../SingleFlyingHeart/SingleFlyingHeart'
import Animated from 'react-native-reanimated'
import { Coords } from '../../../../types/types'
interface Props {
startCoords: Animated.SharedValue<Coords>
heartAnimation: Animated.SharedValue<number>
}
const heartRendersNumber = 2
export const FlyingHeartsRenderer = ({ startCoords, heartAnimation }: Props) => {
return (
<>
<SingleFlyingHeart
startCoords={startCoords}
heartAnimation={heartAnimation}
minValueX={110}
maxValueX={120}
heartRendersNumber={1}
/>
{[...Array(heartRendersNumber)].map((_, index) => (
<SingleFlyingHeart
startCoords={startCoords}
heartAnimation={heartAnimation}
minValueX={70}
maxValueX={100}
index={index}
heartRendersNumber={heartRendersNumber}
/>
))}
<SingleFlyingHeart
startCoords={startCoords}
heartAnimation={heartAnimation}
minValueX={60}
maxValueX={60}
heartRendersNumber={1}
/>
<SingleFlyingHeart
startCoords={startCoords}
heartAnimation={heartAnimation}
minValueX={-120}
maxValueX={-110}
heartRendersNumber={1}
/>
{[...Array(heartRendersNumber)].map((_, index) => (
<SingleFlyingHeart
startCoords={startCoords}
heartAnimation={heartAnimation}
minValueX={-100}
maxValueX={-70}
index={index}
heartRendersNumber={heartRendersNumber}
/>
))}
<SingleFlyingHeart
startCoords={startCoords}
heartAnimation={heartAnimation}
minValueX={-60}
maxValueX={-60}
heartRendersNumber={1}
/>
</>
)
}
Imports:
SingleFlyingHeart
- as the name says, it’s a component that renders a single small heartAnimated
- an element from the Reanimated library. We can use it to create different animated components. We need it to type our propsCoords
- type for thestartCoords
Every SingleFlyingHeart
needs to get a few props:
startCoords
- position X and position Y of the heart at the start of the animation.heartAnimation
- a shared value. We will use it to animate elements. Its fundamental architecture allows it to rise and fall in a way that makes it suitable for the animation. In the process, this value is going through every single hundredth number. For example from 0 to 1: (0.01, 0.02, 0.03, 0.04, 0.05 ... 0.96, 0.97, 0.98, 0.99, 1
). To read more, please click HEREminValueX
andmaxValueX
- I want to make hearts fly to different points randomly (end points). That’s why I’m making a range - min and max value on the X axis. In the end, two hearts have fixed positions X ( 60 and -60 ), but it’s useful for the other 6 hearts. We will discuss it further in theSingleFlyingHeart
component.heartRenderNumber
- the number of hearts that should be rendered at once. It’s doing really nothing when the number is 1, but when we have the array, the animation depend on it.index
- optional argument, when we have the array, the animation depend on it.
As we can see, we’re rendering 8 SingleFlyingHeart
.
Twice we use the array to render SingleFlyingHeart
. It’s important when it comes to the animation, because time for a single heart animation is then split halt and half. I will explain it further in the next sections.
SingleFlyingHeart
SingleFlyingHeart
renders a single small heart that shows up and vanishes behind the main heart.
First let’s take a look at how we styled our components:
Filename: SingleFlyingHeart.styled.ts
import styled from 'styled-components/native'
import Svg, { Path } from 'react-native-svg'
import Animated from 'react-native-reanimated'
import { theme } from '../../../../theme'
const AnimatedPath = Animated.createAnimatedComponent(Path)
interface HeartSizeProps {
heartSize: number
}
export const AnimatedViewContainer = styled(Animated.View)`
width: 50px;
height: 50px;
display: flex;
justify-content: flex-end;
align-items: center;
position: absolute;
z-index: 0;
bottom: 30px;
`
export const StyledSvg = styled(Svg).attrs({
viewBox: '-20 -20 552.131 552.131',
})<HeartSizeProps>`
width: ${(props) => props.heartSize}px;
height: ${(props) => props.heartSize}px;
`
export const StyledAnimatedPath = styled(AnimatedPath).attrs({
stroke: theme.colors.spotifyGreen,
strokeWidth: 30,
fill: theme.colors.spotifyGreen,
d: 'M511.489,167.372c-7.573-84.992-68.16-146.667-144.107-146.667c-44.395,0-85.483,20.928-112.427,55.488 c-26.475-34.923-66.155-55.488-110.037-55.488c-75.691,0-136.171,61.312-144.043,145.856c-0.811,5.483-2.795,25.045,4.395,55.68 C15.98,267.532,40.62,308.663,76.759,341.41l164.608,144.704c4.011,3.541,9.067,5.312,14.08,5.312 c4.992,0,10.005-1.749,14.016-5.248L436.865,340.13c24.704-25.771,58.859-66.048,70.251-118.251 C514.391,188.514,511.66,168.268,511.489,167.372z',
})<HeartSizeProps>`
width: ${(props) => props.heartSize}px;
height: ${(props) => props.heartSize}px;
display: flex;
justify-content: flex-end;
align-items: center;
`
Here we have an almost the same (even simpler) structure as in the HeartButton.styled.ts
.
First we need to create the AnimatedPath
which is, as we said before, just a Path
element that can be animated.
We also need to create type (interface) HeartSizeProps
to pass the heart size (hearts have different sizes).
As before, we need to create styled Animated.View
, styled Svg
, and styled Animated.Path
.
Only the width and the height of the Svg
and the Path
depends on the value we will pass from the SingleFlyingHeart.tsx
.
Let’s go to the SingleFlyingHeart.tsx
.
Filename: SingleFlyingHeart.tsx
import React from 'react'
import { AnimatedViewContainer, StyledAnimatedPath, StyledSvg } from './SingleFlyingHeart.styled'
import { useFlyingHeartAnimatedStyle } from '../../../../hooks/useFlyingHeartAnimatedStyle'
import { drawRandomNumberInRange } from '../../utils'
import Animated from 'react-native-reanimated'
import { Coords } from '../../../../types/types'
interface Props {
startCoords: Animated.SharedValue<Coords>
heartAnimation: Animated.SharedValue<number>
minValueX: number
maxValueX: number
index?: number
heartRendersNumber: number
}
export const SingleFlyingHeart = ({
startCoords,
heartAnimation,
minValueX,
maxValueX,
index,
heartRendersNumber,
}: Props) => {
const randomXCoord = drawRandomNumberInRange(minValueX, maxValueX)
const randomYCoord = drawRandomNumberInRange(-120, -200)
const finalCoords = { x: randomXCoord, y: randomYCoord }
const heartSize = drawRandomNumberInRange(40, 50)
const { heartStyle } = useFlyingHeartAnimatedStyle(
finalCoords,
startCoords,
heartAnimation,
index,
heartRendersNumber
)
return (
<AnimatedViewContainer style={[heartStyle]}>
<StyledSvg heartSize={heartSize}>
<StyledAnimatedPath heartSize={heartSize} />
</StyledSvg>
</AnimatedViewContainer>
)
}
Imports:
AnimatedViewContainer
,StyledAnimatedPath
,StyledSvg
- styled-components that we created in the fileSingleFlyingHeart.styled.ts
useFlyingHeartAnimatedStyle
- hook responsible for creating animated styles for theAnimatedViewContainer
(Animated.View)drawRandomNumberInRange
- utility function responsible for drawing a random number within the given rangeAnimated
- an element from the Reanimated library. We can use it to create different animated components. We need it here to type the propsCoords
- type for thestartCoords
As we said before when we were exploring the FlyingHeartsRenderer
component, the final X coordinate (in the destination point) must be different for each heart.
We need to take minValueX
and maxValueX
and draw the final X coordinate (randomXCoord
) from the drawRandomNumberInRange
.
The same situation is with the Y coordinate, but with one difference. We want the final Y coordinate to be in the range (-120, -200) for each heart, so we don’t need to pass different ranges for each heart, and can just pass it here (it’s simply fine for us as it is).
After that we can set the final coordinates for each heart (finalCoords
).
Also, I want my hearts to have different sizes, so I’m drawing number for height and width for each of them (heartSize
).
The last thing that we need to prepare is the heartStyle
, which is the animation style for the AnimatedViewContainer
(Animated.View). Like we said before, useFlyingHeartAnimatedStyle
is responsible for that. We will explore it in a minute.
When we have all those elements, we need to render AnimatedViewContainer
(Animated.View) and pass the heartStyle
(animated styles) there.
Inside AnimatedViewContainer
(Animated.View) we render StyledSvg
(Svg), and inside the StyledSvg
(Svg) the last component - StyledAnimatedPath
(Path).
Now let’s go to the useFlyingHeartAnimatedStyle
.
useFlyingHeartAnimatedStyle
Filename: useFlyingHeartAnimatedStyle.ts
import Animated, { Extrapolation, interpolate, useAnimatedStyle } from 'react-native-reanimated'
import { Coords } from '../types/types'
export const useFlyingHeartAnimatedStyle = (
finalCoords: Coords,
startCoords: Animated.SharedValue<Coords>,
heartAnimation: Animated.SharedValue<number>,
index = 0,
heartRendersNumber: number
) => {
const calcBezierPoint = (
interpolatedValue: number,
point0: number,
point1: number,
point2: number
) => {
'worklet'
return Math.round(
Math.pow(1 - interpolatedValue, 2) * point0 +
2 * (1 - interpolatedValue) * interpolatedValue * point1 +
Math.pow(interpolatedValue, 2) * 1.3 * point2
)
}
const rangeChunk = 1 / (heartRendersNumber + 1)
const heartStyle = useAnimatedStyle(() => {
const destination: Coords = finalCoords
const start: Coords = startCoords.value
const input: number[] = [rangeChunk * index, rangeChunk * (index + 1), rangeChunk * (index + 2)]
const animatedPosition = interpolate(heartAnimation.value, input, [0, 0.5, 0.8], {
extrapolateLeft: Extrapolation.CLAMP,
extrapolateRight: Extrapolation.CLAMP,
})
const translateX = calcBezierPoint(animatedPosition, start.x, destination.x, destination.x)
const translateY = calcBezierPoint(animatedPosition, start.y, start.y, destination.y)
const opacity = interpolate(heartAnimation.value, input, [0, 0.9, 0])
const scale = interpolate(heartAnimation.value, input, [0, 1.3, 0])
return {
transform: [{ translateX }, { translateY }, { scale }],
opacity: opacity,
}
})
return { heartStyle }
}
Imports:
Animated
- an element from the Reanimated library. We can use it to create different animated components. Here, we need it to type thestartCoords
and theheartAnimation
(shared values)Extrapolation
- when it’s provided, secures the value from going out of the range. To read more, please click HEREinterpolate
- this function transforms one scope to another. For example, if we have a shared value in scope [0-1], we can change it to [0-100] or [50-0], etc. For example, if we move square from point 0 to 1 and we interpolate this value to [0-50], then the square will move 50 times on. We can also declare a few points inside and modify what happens in the different time points. For example[0, 0.5, 1]
(input) can be modified to the[0, 0.7, 1]
(output). What’s the purpose of that? It’s a bit crazy, but now that what should happen in point0.5
will happen in point0.7
. In fact, we extended the first range and shortened the second one. To read more, please click HEREuseAnimatedStyle
- creates animated style values for the Animated Components (for example View). Returns the same values as classic style values, for example: width, rotate; but customized for the animations. To read more, please click HERECoords
- type for thestartCoords
Let’s also take a look at the arguments we pass to the useFlyingHeartAnimatedStyle
:
finalCoords
- coordinates (axis X and Y) where single small heart end its way (different for each small heart)startCoords
- coordinates (axis X and Y) where single small heart start its way (the same for each small heart). These coords are constant and equal{x: 0, y: 0}
heartAnimation
- shared value. We will use it to animate elements. Its fundamental architecture allows it to rise and fall in such a way that it can be used for animation. In the process, this value is going through every single hundredth number. For example from 0 to 1: (0.01, 0.02, 0.03, 0.04, 0.05 ... 0.96, 0.97, 0.98, 0.99, 1
). To read more, please click HEREindex
- index in the array (if theSingleFlyingHeart
is rendered from the array)heartRendersNumber
- length of the array (if theSingleFlyingHeart
are rendered from the array, in other way equals 1)
Now that we have all the ingredients, we can mix them.
calcBezierPoint
First we need to create the calcBezierPoint
function, which allows us to create a path for each small flying heart.
We use it because we want from our heart to fly on a curve rather than a straight line. translateX
and translateY
are calculated to start growing at the right pace and timing relative to each other. You can modify the curve, modifying the values by yourself.
rangeChunk
The rangeChunk
variable will help us render a few hearts at once with a time difference (when we render them from the array/when there are more than 1). This variable creates ranges, which will be used as the time segments. Every next heart will be rendered with a multiplied delay.
heartStyle
The time has come and we need to create an animated style for each of our hearts.
To simplify code reading at this point, I renamed finalCoords
to destination
and assigned startCoords.value
to the simple variable start
.
Another variable is input
, which is array of the points in time, when the specific heart should appear.
I will say it again - it’s important only when we render a heart from the array (because we want to render them in a sequence).
animatedPosition
Here we interpolate our input array (ranges) to the values we want to receive ([0, 0.5, 0.8]
- the final value is 0.8, because I want to cut the second range of heartAnimation.value
) a bit, and secure this scope by using Extrapolation. If you are still feeling dizzy about Interpolation
and Extrapolation
, don’t worry. It comes with the experience. You just need to experiment to get the hang of it better :) Stay strong!
Now we need to create translateX
and translateY
values using the calcBezierPoint
function, and opacity
and scale
using interpolation
.
As far as opacity
is concerned, we want it to start at 0% visibility and reach 90%, and then disappear completely, which is why the output array is [0, 0.9, 0]
.
When it comes to the scale
, we also want to start from the 0% width and height, go to the 130% (bump!) and vanish completely, that’s why the output array is [0, 1.3, 0]
.
Now we need to return our style values - transform
and opacity
.
Please remember that the values we want to transform are always placed, as objects in the array (that one thing is different in the css).
At the end, useFlyingHeartAnimatedStyle
returns the fully prepared heartStyle
for us. And it’s ready for use!
Bouncing Circles
When you look at our final version of the animation, you can see some green diverging circles / circle waves.
That’s what I call the ‘Bouncing Circles’.
This is the last, and I believe the easiest part of this animation.
Let’s first take a look at the styled-components we need to prepare for them:
Filename: BouncingCircles.styled.ts
import styled from 'styled-components/native'
import Animated from 'react-native-reanimated'
interface Props {
borderWidth: number
}
export const AnimatedCircle = styled(Animated.View)<Props>`
width: 50px;
height: 50px;
border-radius: 25px;
border-width: ${(props) => props.borderWidth}px;
border-color: ${({ theme }) => theme.colors.spotifyGreen};
position: absolute;
bottom: 43px;
`
As you can see, we just need to create one styled component (Animated.View). We will use it twice with a different border-width
, that’s why I pass this value in the props. I don’t think there is much more to it, so let’s go to the main component now!
Filename: BouncingCircles.tsx
import React from 'react'
import { AnimatedCircle } from './BouncingCircles.styled'
import { useBouncingCirclesAnimatedStyle } from '../../../../hooks/useBouncingCIrclesAnimatedStyle'
import Animated from 'react-native-reanimated'
interface Props {
heartAnimation: Animated.SharedValue<number>
isBgColored: boolean
}
export const BouncingCircles = ({ heartAnimation, isBgColored }: Props) => {
const { animateBigCircle, animateSmallCircle } = useBouncingCirclesAnimatedStyle(
heartAnimation,
isBgColored
)
return (
<>
<AnimatedCircle borderWidth={1.5} style={[animateBigCircle]} />
<AnimatedCircle borderWidth={5} style={[animateSmallCircle]} />
</>
)
}
Imports:
AnimatedCircle
- styled component we prepared a minute ago (Animated.View)useBouncingCirclesAnimatedStyle
- hook responsible for preparing animated styles for the animated circlesAnimated
- an element from the Reanimated library. We can use it to create different animated components. We need it here to type our props
Now incoming props:
heartAnimation
- shared value. We will use it to animate elements. Its fundamental architecture allows it to rise and fall in such a way that it can be used for animation. In the process, this value is going through every single hundredth number. For example from 0 to 1: (0.01, 0.02, 0.03, 0.04, 0.05 ... 0.96, 0.97, 0.98, 0.99, 1
). To read more, please click HEREisBgColored
- we will need it the check the current state of “our” heart (heart button) at the moment (green / black with a white border)
In the component’s body we just need to unpack styles (animateBigCircle
and animateBigCircle
) from the useBouncingCirclesAnimatedStyle
hook and pass them to the AnimatedCircle
components.
As you can see, these components have a different borderWidth
. The small ones have a wider border, and the big one is much slimmer. They also have different animation styles.
Let’s go to the last part and explore the useBouncingCirclesAnimatedStyle
hook.
useBouncingCirclesAnimatedStyle
Filename: useBouncingCirclesAnimatedStyle.ts
import Animated, { interpolate, useAnimatedStyle } from 'react-native-reanimated'
export const useBouncingCirclesAnimatedStyle = (
heartAnimation: Animated.SharedValue<number>,
isBgColored: boolean
) => {
const animateBigCircle = useAnimatedStyle(() => {
const opacity = interpolate(heartAnimation.value, [0, 1], [1, 0])
const scale = interpolate(heartAnimation.value, [0, 1], [0, 5])
if (isBgColored) {
return {
transform: [{ scale: scale }],
opacity: opacity,
}
} else {
return {}
}
})
const animateSmallCircle = useAnimatedStyle(() => {
const opacity = interpolate(heartAnimation.value, [0, 1], [1, 0])
const scale = interpolate(heartAnimation.value, [0, 1], [0, 4])
if (isBgColored) {
return {
transform: [{ scale: scale }],
opacity: opacity,
}
} else {
return {}
}
})
return { animateBigCircle, animateSmallCircle }
}
Imports:
Animated
- an element from the Reanimated library. We can use it to create different animated components. We need it here to type incoming propertiesinterpolate
- this function transforms one scope to another. For example, if we have a shared value in scope [0-1], we can change it to the [0-100] or the [50-0], etc. For example, if we move the square from point 0 to 1 and interpolate this value to 0-50, then the square will move 50 times on. We can also declare a few points inside and modify what happens in different time points. For example[0, 0.5, 1]
(input) can be modified to the[0, 0.7, 1]
(output). What’s the purpose of that? It’s a bit crazy, but now that what should happen in point0.5
will happen in point0.7
. In fact, we extended the first range and shortened the second one. To read more, please click HEREuseAnimatedStyle
- creates animated style values for the Animated Components (for example View). Returns the same values as classic style values, for example: width, rotate; but customized for animations. To read more click HERE
And we have two incoming properties:
heartAnimation
- shared value. We will use it to animate elements. Its fundamental architecture allows it to rise and fall in such a way that it can be used for the animation. In the process, this value is going through every single hundredth number. For example from 0 to 1: (0.01, 0.02, 0.03, 0.04, 0.05 ... 0.96, 0.97, 0.98, 0.99, 1
). To read more, please click HEREisBgColored
- we will need it to check the current state of “our” heart (heart button) at the moment (green / black with a white border)
The first variable is the animateBigCircle
animated style.
Because we want from our circle to appear in full opacity and vanish in time, we will animate it from 1 to 0.
To do that, we can once again use the well known heartAnimation
value. As its input is from 0 to 1, we just need to interpolate it and receive output from 1 to 0. This simple interpolate
takes value that is growing from 0 to 1, and turns it to the value which is going from 1 to 0. Our opacity will be decreasing.
And the opacity is now **prepared.
The scale is also very simple. We just need to take heartAnimation
again and multiply its value 5 times. Because of that we have input [0, 1]
and output [0, 4]
.
If the heart is clicked, and turns green (isBgColored === true
) we want to apply those styles.
Otherwise (isBgColored === false
), we will just return an empty style object and do nothing.
With the animated style for the small circle, only difference is that we want to multiply its size 4 times.
That’s all. Our animated styles are ready for use.
Our Spotify Heart Animation is READY!
Written by Conrad Gauza.