The Widlarz Group Blog

Create Spotify heart animation with Reanimated 2 - tutorial

March 08, 2022

react native

reanimated 2

typescript

styled-components

Visualisation

Spotify Heart

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:

Spotify Heart Reanimated 2

Enjoy!

Table of Contents

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 state
  • useSharedValue - 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 HERE
  • HeartButton / BouncingCircles / FlyingHeartsRenderer - UI components, we will explore them later in this tutorial
  • Container - our styled component (SafeAreaView)
  • Coords - type for the startCoords

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 HERE
  • startCoords - 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 the heartAnimation props
  • useSharedValue - 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 HERE
  • ScaleViewContainer, ShakeViewContainer, StyledAnimatedPath, StyledHeartButton, StyledSvg - styled-components. We will explore them below
  • useMainHeartAnimation - custom hook containing animation styles and logic we need to pass to the proper components
  • useHeartPress - 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-component
  • Svg, Path - they’re just svg elements we want to style
  • PathProps - a type that we use to properly type our Path element
  • Animated - an element from the Reanimated library. We can use it to create different animated components. Here we need it to create AnimatedPath
  • AnimateProps - we use it to properly type our Path element. It’s a generic type which accepts the type of the chosen element. In this case Path / 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):

Spotify Heart

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):

Spotify Heart

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 the heartAnimation.
  • 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 TouchableOpacity
  • ShakeViewContainer - styled View (we pass an extra animated style here)
  • ScaleViewContainer - styled View (we pass an extra animated style here)
  • StyledSvg - styled Svg
  • StyledAnimatedPath - 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 params
  • withSequence - we can call it the animations ‘helper’. Its role is to combine a few animations. To read more, please click HERE
  • withSpring - 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 HERE
  • withTiming - 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 the heartTransform value.
  • Easing - helps us animate switching between values. To read more, please click HERE
  • interpolate - 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 point 0.5 will happen in point 0.7. In fact, we extended the first range and shortened the second one. To read more, please click HERE
  • interpolateColor - Interpolate for the colors. Transforms number values to color values. To read more, please click HERE
  • useAnimatedStyle - 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 HERE
  • useAnimatedProps - 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 HERE
  • useDerivedValue - this hook creates a shared value which is updated whenever one of the values used inside is changed. To read more, please click HERE
  • withTiming - 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 HERE
  • theme - 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 heart
  • Animated - an element from the Reanimated library. We can use it to create different animated components. We need it to type our props
  • Coords - type for the startCoords

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 HERE
  • minValueX and maxValueX - 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 the SingleFlyingHeart 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 file SingleFlyingHeart.styled.ts
  • useFlyingHeartAnimatedStyle - hook responsible for creating animated styles for the AnimatedViewContainer (Animated.View)
  • drawRandomNumberInRange - utility function responsible for drawing a random number within the given range
  • Animated - an element from the Reanimated library. We can use it to create different animated components. We need it here to type the props
  • Coords - type for the startCoords

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 the startCoords and the heartAnimation (shared values)
  • Extrapolation - when it’s provided, secures the value from going out of the range. To read more, please click HERE
  • interpolate - 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 point 0.5 will happen in point 0.7. In fact, we extended the first range and shortened the second one. To read more, please click HERE
  • useAnimatedStyle - 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 HERE
  • Coords - type for the startCoords

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 HERE
  • index - index in the array (if the SingleFlyingHeart is rendered from the array)
  • heartRendersNumber - length of the array (if the SingleFlyingHeart 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 circles
  • Animated - 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 HERE
  • isBgColored - 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 properties
  • interpolate - 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 point 0.5 will happen in point 0.7. In fact, we extended the first range and shortened the second one. To read more, please click HERE
  • useAnimatedStyle - 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 HERE
  • isBgColored - 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!

Spotify Heart


Written by Conrad Gauza.

Industries

  • Fintech
  • Health Care
  • E-commerce
  • Entertainment
  • Gambling
  • Telecommunication

Business models

  • Consultancy
  • Workshops
  • Outsourcing
  • Team Extension
  • Audit & Estimation

Technologies

  • iOS/Android
  • React/React Native
  • Node.js
  • TypeScript