The Widlarz Group Blog

Reanimated 2 - the new approach to creating animations in React Native

August 27, 2020

react native

reanimated 2

animations

hooks

Introduction

Recently I’ve got the possibility to test the new library - Reanimated 2. The new version is completely different than the previous one and, as a consequence, we use a new, mostly hook based syntax. This guide uses 2.0.0-alpha.5 version and will be updated once a new version is released.

I must admit, that working with the new API was quite a pleasure, firstly the new hooks set is really intuitive for React developers, secondly animations are smooth and performant. I encourage you to start using Reanimated 2 in your React Native projects and slowly forgetting about the old syntax. Slowly, because it’s still in alpha, but by using it you can help improve the library.

In this article, we are not going to focus so much on theory. If you are looking for that kind of knowledge, I refer you to the great documentation. Our aim is to take a look at Reanimated 2 hooks and learn how to use them in code. We are going to build practical examples of animated sliders using new Reanimated 2 API and our custom useSlider hook.

You don’t need to be familiar with the previous version to make animations with Reanimated 2!

Below you can see a preview of what we will build together, step by step.

Article demo animation

At the end of each step, there’s a link to the current code (in case you’ve got stuck).
Let’s start then!

Project Setup

There is a starter repository on Github for this tutorial. We start by cloning the repo

git clone git@github.com:TheWidlarzGroup/reanimated2-slider-starter.git

then install packages

yarn

and if you develop on iOS simulator:

cd ios && pod install

The starter project consists of 3 sliders. Each slider is built with 3 fundamental View components.

<View style={styles.slider}>
  <View style={styles.progress} />
  <View style={styles.knob} />
</View>
  1. slider - wrapper
  2. progress - absolute positioned View, for displaying slider progress
  3. knob - draggable element

Our first task is to animate Slider1 using Reanimated 2 hooks. Then we will build custom hook useSlider with reusable logic.

Let’s start our project

  yarn ios
  // or
  yarn android

And move to Slider1.js file

useSharedValue

Probably you are familiar with Animated.Value concept. When it comes to Reanimated 2 we use the useSharedValue hook to keep animation ‘state’. Shared means that the value is accessible across 2 threads: UI and JS Thread.

In our example we keep two values in ‘state’:

  • translateX - a distance from beginning to the end of the slider.
  • isSliding - a boolean which tells us whether we are currently sliding or not.
import { useSharedValue } from "react-native-reanimated"

//...

const translateX = useSharedValue(0)
const isSliding = useSharedValue(false)

Our animations are based on changing shared values. What’s important to mention, to change shared values we use .value syntax:

For instance:

isSliding.value = true
// or
translateX.value = 100

useAnimatedGestureHandler

Reanimated 2 provides us with a hook useAnimatedGestureHandler to handle Gesture Handlers easily. The hook accepts an object where we can configure events like:

  • onStart, onActive, onEnd, onCancel, onFail, onFinish

Each event has access to 2 parameters:

  • gesture event - an object that consists of information like translate, velocity, current position, and more, dependent on Gesture Handler we use.
  • context - a plain JS object to store data between events. We can keep in context any information we want.

In return from hook, we get an object, which needs to be passed to Gesture Handler (onGestureEvent).

In our example, we use PanGestureHandler as a wrapper for Knob to make it draggable across the slider.

// import {useSharedValue} from 'react-native-reanimated'
import Animated, {useSharedValue, useAnimatedGestureHandler} from 'react-native-reanimated'
import {PanGestureHandler} from 'react-native-gesture-handler'

//... configuring PanGestureHandler here

const onGestureEvent = useAnimatedGestureHandler({
  onStart: (_, ctx) => {
    ctx.offsetX = translateX.value
  },
  onActive: (event, ctx) => {
    isSliding.value = true
    translateX.value = event.translationX + ctx.offsetX
  },
  onEnd: () => {
    isSliding.value = false
  },
})

//... Wrapping our Knob with PanGestureHandler

<PanGestureHandler onGestureEvent={onGestureEvent}>
 <Animated.View style={styles.knob}/>
</PanGestureHandler>

useAnimatedValue

The next and one of the most important hook is useAnimatedValue one. Inside this hook, we define style properties we want to be animated. Then we need to pass whatever hook has returned to an Animated component as a style property.

In our case, we created two animated styles.

  1. scrollTranslationStyle - to show the Knob translation
  2. progressStyle - to animate progress bar when the knob is being dragged

We also tell RN, that our views are Animated.

// import Animated, {useSharedValue, useAnimatedGestureHandler} from 'react-native-reanimated'
import Animated, {
  useSharedValue,
  useAnimatedGestureHandler,
  useAnimatedStyle,
} from "react-native-reanimated"

const scrollTranslationStyle = useAnimatedStyle(() => {
  return { transform: [{ translateX: translateX.value }] }
})

const progressStyle = useAnimatedStyle(() => {
  return {
    width: translateX.value + KNOB_WIDTH,
  }
})

return (
  <View style={styles.slider}>
    {/* <View style={styles.progress} /> */}
    <Animated.View style={[styles.progress, progressStyle]} />
    <PanGestureHandler onGestureEvent={onGestureEvent}>
      {/*  <Animated.View style={styles.knob} /> */}
      <Animated.View style={[styles.knob, scrollTranslationStyle]} />
    </PanGestureHandler>
  </View>
)

If you’ve followed to this point, you should be able to move the Knob.

Moving Knob

current code here

Quick fix and “worklet” directive

There is a problem with our slider. We can move the knob beyond our slider. Let’s fix that by adding the clamp function, that would not allow the knob to leave defined bounds.

//...
onActive: (event, ctx) => {
  const clamp = (value, lowerBound, upperBound) => {
    return Math.min(Math.max(lowerBound, value), upperBound)
  }

  isSliding.value = true
  //translateX.value = event.translationX + ctx.offsetX
  translateX.value = clamp(
    event.translationX + ctx.offsetX,
    0,
    SLIDER_WIDTH - KNOB_WIDTH
  )
}

Indeed, there is no need to define a clamp function inside the useAnimatedGestureHandler. For cleanliness, we move function definition to a separate file utils.js. Also, don’t forget to import it in our Slider1.js file.

// utils.js

export const clamp = (value, lowerBound, upperBound) => {
  "worklet"
  return Math.min(Math.max(lowerBound, value), upperBound)
}

Perhaps you have noticed that the new string appeared - "worklet". There is no need to write "worklet" directive inside reanimated 2 hooks. Each of reanimated 2 hooks, uses worklet directive under the hood.

If we want to run Javascript functions on the UI thread, we need to place "worklet" directive on the top of a function definition. (similar to our clamp method)

Link to current code

useDerivedValue and useAnimatedProp

useDerivedValue - we use this hook, to calculate a new value, based on changing shared value. In our example we will calculate step and then return its value as a string, to animate it in AnimatedText component.

//import Animated, {useSharedValue, useAnimatedGestureHandler, useAnimatedStyle } from 'react-native-reanimated'
import Animated, {
  useSharedValue,
  useAnimatedGestureHandler,
  useAnimatedStyle,
  useDerivedValue,
} from "react-native-reanimated"

const MAX_RANGE = 20

//...

const stepText = useDerivedValue(() => {
  const sliderRange = SLIDER_WIDTH - KNOB_WIDTH
  const oneStepValue = sliderRange / MAX_RANGE
  const step = Math.ceil(translateX.value / oneStepValue)

  return String(step)
})

then create a new file AnimatedText.js

useAnimatedProp - is similar to useAnimatedStyle. The difference is that we use useAnimatedProp when we want to animate properties, which are not style properties (background, opacity, etc.)

//AnimatedText.js

import * as React from "react"
import Animated, { useAnimatedProps } from "react-native-reanimated"
import { TextInput } from "react-native-gesture-handler"

const AnimatedTextInput = Animated.createAnimatedComponent(TextInput)

const AnimatedText = ({ text }) => {
  const animatedProps = useAnimatedProps(() => {
    return {
      text: text.value,
    }
  })

  return (
    <AnimatedTextInput
      underlineColorAndroid="transparent"
      editable={false}
      value={text.value}
      animatedProps={animatedProps}
    />
  )
}

export default AnimatedText

and finally in our Slider1.js

import AnimatedText from "./AnimatedText"

//...

// <Animated.View style={[styles.knob, scrollTranslationStyle]} />
<Animated.View style={[styles.knob, scrollTranslationStyle]}>
  <AnimatedText text={stepText} />
</Animated.View>
Animated Text animation

You can find the code for slider with animated text here

Adding onDragComplete handler

Often, we want to add some feedback when the knob is being dragged to the end of the slider. With Reanimated 2 it is very simple to implement. Let’s assume that dragging is completed just after we have dropped knob, and the distance from our knob to the max bound is less than 3 pt.

// import {StyleSheet, View} from 'react-native'
import { StyleSheet, View, Alert } from "react-native"

const onDraggedSuccess = () => {
  Alert.alert("dragged")
}

//...

onEnd: () => {
  isSliding.value = false

  if (translateX.value > SLIDER_WIDTH - KNOB_WIDTH - 3) {
    onDraggedSuccess()
  }
}
onDragComplete

Code with added onDraggedSuccess here

Custom hook - useSlider

So far, we have built a slider animation using Reanimated 2 hooks. As the React world loves hooks, we will move a step forward and create custom hook - useSlider. We will use this useSlider hook to animate our second slider. That would be a good test for how our shared values behave when we passed them across components.

Let’s create a new file for our hook useSlider.js. We will take our animation logic from Slider1. To increase the reusability, we will be passing following arguments to our hook:

  1. sliderWidth (number)
  2. knobWidth (number),
  3. onDraggedSuccess (callback).
  4. maxRange (number) - to track a current step, optional
  5. initialValue (number) - if we want to set the initial position of knob inside a slider, optional

The useSlider hook returns:

  1. onGestureEvent
  2. values (translateX, isSliding, stepText)
  3. styles (scrollTranslationStyle , progressStyle)
// useSlider.js

import {
  useSharedValue,
  useAnimatedGestureHandler,
  useAnimatedStyle,
  useDerivedValue,
} from "react-native-reanimated"
import { clamp } from "./utils"

export const useSlider = (
  sliderWidth,
  knobWidth,
  onDraggedSuccess,
  maxRange = 10,
  initialValue = 0
) => {
  const SLIDER_RANGE = sliderWidth - knobWidth
  const STEP = SLIDER_RANGE / maxRange ?? 1

  const translateX = useSharedValue(STEP * initialValue)
  const isSliding = useSharedValue(false)

  const onGestureEvent = useAnimatedGestureHandler({
    onStart: (_, ctx) => {
      ctx.offsetX = translateX.value
    },
    onActive: (event, ctx) => {
      isSliding.value = true

      translateX.value = clamp(
        event.translationX + ctx.offsetX,
        0,
        SLIDER_RANGE
      )
    },
    onEnd: () => {
      isSliding.value = false

      if (translateX.value > SLIDER_RANGE - 3) {
        onDraggedSuccess()
      }
    },
  })

  const scrollTranslationStyle = useAnimatedStyle(() => {
    return { transform: [{ translateX: translateX.value }] }
  })

  const progressStyle = useAnimatedStyle(() => {
    return {
      width: translateX.value + knobWidth,
    }
  })

  const stepText = useDerivedValue(() => {
    const step = Math.ceil(translateX.value / STEP)
    return String(step)
  })

  return {
    onGestureEvent,
    values: {
      isSliding,
      translateX,
      stepText,
    },
    styles: {
      scrollTranslationStyle,
      progressStyle,
    },
  }
}

Animate Slider2 with useSlider hook

To show a more interesting example, we will animate Slider2 with our useSlider hook. The idea of this example comes from a slider, which already has been animated with Reanimated 1, by William Candilon in this video

Firstly, uncomment in App.js these lines:

import Slider2 from './Slider2';

<View style={{margin: 50}} />
<Slider2 />

then let’s use our custom hook in Slider2.js

// import {StyleSheet, View, Text} from 'react-native'
import { StyleSheet, View, Alert } from "react-native"
import Animated from "react-native-reanimated"
import { PanGestureHandler } from "react-native-gesture-handler"
import { useSlider } from "./useSlider"
import AnimatedText from "./AnimatedText"

// ...

const Slider2 = () => {
  const onDragCompleteHandler = () => {
    Alert.alert(stepText.value, String(translateX.value))
  }

  const {
    onGestureEvent,
    values: { translateX, isSliding, stepText },
    styles: { scrollTranslationStyle, progressStyle },
  } = useSlider(SLIDER_WIDTH, KNOB_WIDTH, onDragCompleteHandler, STEP)

  return (
    <>
      <View style={styles.slider}>
        <Animated.View style={[styles.progress, progressStyle]} />
        <PanGestureHandler onGestureEvent={onGestureEvent}>
          <Animated.View style={[styles.knobContainer, scrollTranslationStyle]}>
            <Knob isSliding={isSliding} />
          </Animated.View>
        </PanGestureHandler>
      </View>

      <View style={{ marginTop: 40 }}>
        <AnimatedText text={stepText} />
      </View>
    </>
  )
}

Also in Knob.js we animate the opacity of two images:

  1. penguin with open eyes (assets/up.png)
  2. penguin with closed eyes (assets/down.png)

based on isSliding.value which can be true or false.

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

const Knob = ({ isSliding }) => {
  const knobUpStyle = useAnimatedStyle(() => {
    return {
      opacity: isSliding.value ? 1 : 0,
    }
  })

  const knobDownStyle = useAnimatedStyle(() => {
    return {
      opacity: isSliding.value ? 0 : 1,
    }
  })

  return (
    <View style={styles.container}>
      {/* <Image source={require("./assets/up.png")} style={styles.image} /> */}
      <Animated.Image
        source={require("./assets/up.png")}
        style={[styles.image, knobUpStyle]}
      />
      {/*  <Image source={require("./assets/down.png")} style={styles.image} /> */}
      <Animated.Image
        source={require("./assets/down.png")}
        style={[styles.image, knobDownStyle]}
      />
    </View>
  )
}

If you’ve done everything correctly, you could be able to see that animation

Slider 2 animation

Code

Interpolation

In this part, we will add some rotation styling to our penguin and interpolate color of the progress bar. To achieve this we use the interpolate method from the Reanimated 2 library.

Interpolation helps you to create good animations by mapping input ranges to output ranges. We use it like this:

// Example

const value = interpolate(
  sharedValue, // e.g translateX.value
  inputRange, // e.g : [0, 100]
  outputRange, // e.g : [360, 1000]
  extrapolation // Clamp, Extend, Identity
)

In Slider2.js let’s add

// import Animated from 'react-native-reanimated'
import Animated, {useAnimatedStyle, interpolate, Extrapolate} from 'react-native-reanimated'

// ..
// below useSlider hook

  const rotateStyle = useAnimatedStyle(() => {
    const rotate = interpolate(
      translateX.value,
      [0, SLIDER_RANGE], // between the beginning and end of the slider
      [0, 4 * 360], // penguin will make 4 full spins
      Extrapolate.CLAMP,
    )

    return {
      transform: [{rotate: `${rotate}deg`}],
    }
  })

  //..

  // <Knob isSliding={isSliding} />
  <Knob isSliding={isSliding} rotateStyle={rotateStyle} />

and in Knob.js

// const Knob = ({isSliding}) => {
const Knob = ({isSliding, rotateStyle}) => {

// <View style={styles.container}>
<Animated.View style={[styles.container, rotateStyle]}>
{/* ... */}
</Animated.View>

Color Interpolation

Unfortunately, there is no easy way to interpolate colors in Reanimated 2 yet. If we want to interpolate colors we need to use a little hack. The idea is to animate each of color rgb values, in this way:

// Slider2.js

const backgroundStyle = useAnimatedStyle(() => {
  const R = Math.round(
    interpolate(
      translateX.value,
      [0, SLIDER_RANGE],
      [129, 3],
      Extrapolate.CLAMP,
    ),
  )
  const G = Math.round(
    interpolate(
      translateX.value,
      [0, SLIDER_RANGE],
      [212, 169],
      Extrapolate.CLAMP,
    ),
  )
  const B = Math.round(
    interpolate(
      translateX.value,
      [0, SLIDER_RANGE],
      [250, 244],
      Extrapolate.CLAMP,
    ),
  )

  const backgroundColor = `rgb(${R},${G},${B})`
  return {
    backgroundColor,
  }
})

//...

// <Animated.View style={[styles.progress, progressStyle]}/>
<Animated.View style={[styles.progress, progressStyle, backgroundStyle]}/>

At the end of this section, we are able to see the penguin rotation and the progress background color animation.

Rotation and progress color animations

Code can be found here

withTiming and Easing functions

This is the last part of the article. We will make a small experiment by moving our knob programmatically by changing trasnlateX.value, in the button onPress method. Additionally, we will use timing functions from Reanimated 2.

// Slider2.js

// import {StyleSheet, View, Alert} from 'react-native'
import { StyleSheet, View, Alert, Button } from "react-native"
// import Animated, {useAnimatedStyle, interpolate, Extrapolate} from 'react-native-reanimated'
import Animated, {
  useAnimatedStyle,
  interpolate,
  Extrapolate,
  withTiming,
  Easing,
} from "react-native-reanimated"

//... in return statement, below Animated Text let's add 2 Buttons
<View>
  <Button
    title="Slide to beginning"
    onPress={() => {
      isSliding.value = true
      translateX.value = withTiming(0, {
        duration: 3000,
        easing: Easing.bounce,
      })

      setTimeout(() => {
        isSliding.value = false
      }, 3000)
    }}
  />
  <Button
    title="Slide to end"
    onPress={() => {
      isSliding.value = true

      translateX.value = withTiming(SLIDER_RANGE, {
        duration: 1000,
        easing: Easing.linear,
      })

      setTimeout(() => {
        isSliding.value = false
      }, 1000)
    }}
  />
</View>

Now, you can see two extra buttons, and move the slider by clicking them, like on the video below.

Buttons to animate programatically
You can find the whole code here

Reanimated 2 offers us more methods to create interesting animations like withSpring, loop, delay, repeat, and more. I encourage you to give it a try. We have left a Slider3.js file, with a static slider. Feel free to make your experiment using Reanimated 2 API.

Conclusions

To start writing code with the new Reanimated 2 API, you don’t need to have prior experience with react-native-reanimated library. Even though Reanimated 2 is still in alpha version (on the article release date), it offers us the powerful API to create eye-catching and effective animations.

By reading this article, you’ve gained some practical knowledge about how you can use the new Reanimated 2 API. I hope you’ve enjoyed reading it. For more examples, I refer you to stimulating YT channel


Written by Bartek Bajda.