The Gnar Company
The Gnar Company

React Native's Animated API

by Reese Williams

Wheel of Fortune spinner stopping on the million dollar prize.

React Native's Animated library is a powerful tool for creating beautiful and performant animations. It has a few basic tools that provide a robust foundation for building production-ready animations.

Project Overview

Throughout the course of this project, we're going to be building a copy of the Wheel of Fortune spinner. The code for this project can be found here.

While this little spinner isn't going to have a ton of complexity, it will serve as a foundation for discussing the key aspects of the Animated library. Here's what it will look like when we're all done:

Our new Wheel of Fortune Spinner

Project setup

For this project, we'll be using the React Native command line tool. If you don't have this installed, follow the instructions here under "React Native CLI Quickstart".

To get started, clone the starter branch from this project's Github repo, install all dependencies, and start the app:

git clone -b starter https://github.com/TheGnarCo/animation-tutorial.git
cd animation-tutorial
npm ci
react-native run-ios

The starter app should have the wheel and pointer on the screen, but it doesn't do anything quite yet.

Rotating the Wheel

First things first: we need to give the wheel the ability to rotate. We'll worry about the animation part later, but for now, let's add just the CSS transform. Eventually this will come from our App component, so let's go ahead and pass this in as a prop. We'll pass a string prop so we can use a degree value (such as "90deg").

// Wheel.tsx

interface Props {
  rotationAngle: string
}

export class Wheel extends Component<Props> {
  public render() {
    const { rotationAngle } = this.props
    return (
      <Animated.Image
        style={{
          ...styles.wheel,
          transform: [{ rotate: rotationAngle }],
        }}
        source={wheelOfFortune}
      />
    )
  }
}

Notice that here we are using the Animated.Image component instead of a standard Image component. The Animated library provides wrappers around many common components. These wrappers allow the components to use Animated.Value instances for different properties. We'll come back to this later on.

We also have to adjust our App component to pass in some rotation value. For now, let's only pass in a static value to be sure this works as intended.

// App.tsx

<Wheel rotationAngle="30deg" />

Great! Now our wheel can rotate, but how do we get it to spin when we drag it? We need to figure out how to translate gestures to degrees.

Handling Gestures

For this project, we'll be using react-native-gesture-handler, which is a great library for dealing with touch and gesture events. In particular, we'll be using the PanGestureHandler component. This component takes two props: an onGestureEvent function, which handles the drag as it's happening, and an onHandlerStateChange function, which handles state changes for the drag (such as when it starts, is active, and ends).

For now, we'll just focus on being able to drag the wheel; flicking to spin it will come later. Our scrollHandler will receive an event object, and from it we'll need to extract the x-axis movement. We can then put that value in state so we can manipulate it later.

// App.tsx
interface State {
  rotationAngle: Animated.Value
}

const ROTATION_THROTTLE = 20

export class App extends Component<State> {
  public state = {
    rotationAngle: new Animated.Value(0),
  }

  public render() {
    const { rotationAngle } = this.state
    const interpolatedAngle = rotationAngle.interpolate({
      inputRange: [-360, 360],
      outputRange: ['-360deg', '360deg'],
    })

    return (
      <PanGestureHandler onGestureEvent={this.scrollHandler}>
        <View style={styles.container}>
          <Text style={styles.pointer}>👇</Text>
          <Wheel rotationAngle={interpolatedAngle} />
        </View>
      </PanGestureHandler>
    )
  }

  private scrollHandler = (event: PanGestureHandlerGestureEvent) => {
    const { rotationAngle } = this.state
    const offset = event.nativeEvent.translationX / ROTATION_THROTTLE
    this.setState({
      rotationAngle: Animated.add(rotationAngle, new Animated.Value(offset)),
    })
  }
}

A few things are worth noting here. First, we implement a scrollHandler method, which reads the gesture event to get the length of the swipe and adds it to the current rotation angle (after dividing it by some constant to make it map closer to the drag gesture).

We store it as an Animated.Value, which was mentioned earlier. This gives us a few key tools, some of which will become apparent later. One of the biggest advantages is the interpolate method used at the start of our render method. This method takes a range of inputs that map to a range of outputs.

The power of this is two-fold. First, we don't have to deal with any of this calculation ourselves; the Animated library takes care of it all for us. Secondly, we don't have to worry about the data type. In this example, we map numbers to a string that's in degrees, but again, Animated does all of that for us. You don't even need a 1-to-1 mapping; the following works just as well:

rotationAngle.interpolate({
  inputRange: [-360, 360],
  outputRange: ['-90deg', '90deg'],
})

By changing the output range, we slow down the scroll speed of our wheel. The above example rotates at 1/4th the speed of our original output, since the output values are now degree values that are 1/4th of the inputs. This allows you to adjust your animations without worrying about how your gesture value maps to some other range of values.

When we add the flicking gesture, this will also make some of our calculations quite a bit easier. Calculations using Animated.Values can be somewhat verbose since we need to use the Animated add, subtract, etc. methods, but other tools like interpolate more than make up for this verbosity.

We also need to update our Wheel props to take this interpolated value.

// Wheel.tsx

interface Props {
  rotationAngle: Animated.AnimatedInterpolation
}

And with that, we can now drag our Wheel of Fortune!

Wheel of Fortune with only dragging implemented.

But what about actually spinning it?

Spinning the wheel

This is where all the parts of the Animated library come together.

First, let's take a look at the second prop for PanGestureHandler, the onHandlerStateChange function. Touch events go through multiple states (such as BEGAN, CANCELLED, and ACTIVE, among others), but for this project, we're only concerned with the END state. When a gesture ends, we want to take the velocity of that gesture (the "flick") and use that to animate the spin.

To do this, we'll need a new scrollEndHandler that takes in the velocity at the end of the gesture and gradually decreases it. We'll continually add the velocity to the rotation angle to create the spin, so as we decrease it, the spin will slow to a stop.

// App.tsx
import { State as GestureState } from 'react-native-gesture-handler'

const ROTATION_THROTTLE = 20
const VELOCITY_THROTTLE = 2000

private scrollEndHandler = (event: PanGestureHandlerStateChangeEvent) => {
  if (event.nativeEvent.state === GestureState.END) {
    this.decaySpinVelocity(event.nativeEvent.velocityX)
  }
}

private decaySpinVelocity = (velocity: number) => {
  const startingVelocity = velocity / VELOCITY_THROTTLE
  Animated.decay(this.state.velocity, {
    useNativeDriver: true,
    velocity: startingVelocity,
  }).start()
}

Walking through our scrollEndHandler, we check the state of the event to make sure it's finished. Once the swipe is done, we can take care of the spinning animation.

In the decaySpinVelocity method, we take the horizontal velocity and divide it by some number to slow down the animation to an appropriate speed. (Feel free to change the VELOCITY_THROTTLE value; 2000 is essentially arbitrary.) Then comes one of the most powerful tools in the Animated library: decay.

decay is one of several methods (along with timing and spring) that take care of some complex calculations required for animation. It does exactly what you think it does: it decays some value (here, the velocity) to 0. It does this by repeatedly multiplying that value times some number until it reaches 0. By default, it uses 0.997 as the multiplier, but you can pass in any number you'd like by passing a deceleration value:

Animated.decay(this.state.velocity, {
  deceleration: 0.91
  useNativeDriver: true,
  velocity: startingVelocity,
}).start()

For our purposes, the default deceleration value works well.

We'll also need to create a function that calculates the rotation angle with our new velocity and update our render function accordingly.

public render() {
  const angle = this.calculatedRotationAngle

  const interpolatedAngle = angle.interpolate({
    inputRange: [-360, 360],
    outputRange: ["-360deg", "360deg"]
  })

  return (
    <PanGestureHandler
      onGestureEvent={this.scrollHandler}
      onHandlerStateChange={this.scrollEndHandler}
    >
      <View style={styles.container}>
        <Text style={styles.pointer}>👇</Text>
        <Wheel rotationAngle={interpolatedAngle} />
      </View>
    </PanGestureHandler>
  )
}

private get calculatedRotationAngle() {
  const { rotationAngle, velocity } = this.state
  const newRotationAngle = Animated.add(rotationAngle, velocity)

  if (velocity._value !== 0) {
    this.setState({ rotationAngle: newRotationAngle })
  }

  return newRotationAngle
}

In calculatedRotationAngle, we update our rotation angle if velocity is any number other than 0 (meaning that the spinner is still spinning); otherwise we get the state rotation angle and leave our state as is, since the wheel is standing still. To recap, here are our completed App and Wheel components:

// App.tsx

import React, { Component } from 'react'
import { Animated, Text, View } from 'react-native'
import {
  PanGestureHandler,
  PanGestureHandlerGestureEvent,
  PanGestureHandlerStateChangeEvent,
  State as GestureState,
} from 'react-native-gesture-handler'

import { Wheel } from 'src/Wheel/Wheel'
import { styles } from './styles'

interface State {
  rotationAngle: Animated.Value
  velocity: Animated.Value
}

const VELOCITY_THROTTLE = 2000

export class App extends Component<State> {
  public state = {
    rotationAngle: new Animated.Value(0),
    velocity: new Animated.Value(0),
  }

  public render() {
    const angle = this.calculatedRotationAngle

    const interpolatedAngle = angle.interpolate({
      inputRange: [-360, 360],
      outputRange: ['-360deg', '360deg'],
    })

    return (
      <PanGestureHandler
        onGestureEvent={this.scrollHandler}
        onHandlerStateChange={this.scrollEndHandler}
      >
        <View style={styles.container}>
          <Text style={styles.pointer}>👇</Text>
          <Wheel rotationAngle={interpolatedAngle} />
        </View>
      </PanGestureHandler>
    )
  }

  private get calculatedRotationAngle() {
    const { rotationAngle, velocity } = this.state

    const newRotationAngle = Animated.add(rotationAngle, velocity)
    if (velocity._value !== 0) {
      this.setState({ rotationAngle: newRotationAngle })
    }
    return newRotationAngle
  }

  private scrollHandler = (event: PanGestureHandlerGestureEvent) => {
    const { rotationAngle } = this.state
    const offset = event.nativeEvent.translationX / 20
    this.setState({
      rotationAngle: Animated.add(rotationAngle, new Animated.Value(offset)),
    })
  }

  private scrollEndHandler = (event: PanGestureHandlerStateChangeEvent) => {
    if (event.nativeEvent.state === GestureState.END) {
      this.decaySpinVelocity(event.nativeEvent.velocityX)
    }
  }

  private decaySpinVelocity = (velocity: number) => {
    const startingVelocity = velocity / VELOCITY_THROTTLE
    Animated.decay(this.state.velocity, {
      useNativeDriver: true,
      velocity: startingVelocity,
    }).start()
  }
}
// Wheel.tsx

import React, { Component } from 'react'
import { Animated } from 'react-native'

import wheelOfFortune from 'assets/images/wheelOfFortune.png'
import { styles } from './styles'

interface Props {
  rotationAngle: Animated.AnimatedInterpolation
}

export class Wheel extends Component<Props> {
  public render() {
    const { rotationAngle } = this.props
    return (
      <Animated.Image
        style={{
          ...styles.wheel,
          transform: [{ rotate: rotationAngle }],
        }}
        source={wheelOfFortune}
      />
    )
  }
}

And that's it! We now have a working Wheel of Fortune spinner.

In this tutorial we went over several of the fundamentals of the Animated library. These include the following:

  • Animated.Value, which allows us to apply other Animated methods to manipulate these values.
  • Animated.Image and related wrapper components that allow you to use Animated.Values to manipulate the components.
  • Animated.decay, which handles decreasing a value until it reaches 0 to slow down the spinner.
  • value.interpolate to easily map values across data types and ranges.

These few building blocks go a long way towards building production-ready animations for your next React Native project.

Our finished product!