DEV Community

Cover image for Animated Progress Bar with Reanimated 2
Spencer Carli
Spencer Carli

Posted on • Originally published at reactnativeschool.com

Animated Progress Bar with Reanimated 2

Animations can add a lot of polish to your app but can be challenging to accomplish in a performant way. Reanimated v2 makes that much easier to do.

This article serves as an introduction to the Reanimated v2 API. We'll be building an animated progress bar, as seen in the following GIF.

Progress bar with clamped animation

We'll be building the StatsCard component, which will have the progress bar inside of it (that's the part with the "Pairs matched" and "Total moves" at the top of in the above GIF). Whenever the numerator or denominator changes the progress bar width changes.

// screens/MatchThePairs.tsx

<StatsCard
  title="Pairs matched"
  numerator={matchCount}
  denominator={totalPairs}
/>
Enter fullscreen mode Exit fullscreen mode

Starting Code

Let's take a look at our starting point. It has a progress bar that will adjust its width based on the percentage complete but when the width changes there is no animation, it simply jumps to the next width, as seen in the following GIF.

Progress bar with no animation

Take note that we're getting the width of the card via the onLayout prop on the View component. That way, regardles of the screen size and card width we're getting an accurate width to do our calculations off of.

// components/cards/Stats.tsx

import React from "react"
import { View, StyleSheet, Text, ViewStyle } from "react-native"

import { Colors, Spacing, Theme } from "src/constants"

type StatsCardProps = {
  title: string
  numerator: number
  denominator?: number
}

export const StatsCard = (props: StatsCardProps) => {
  const { numerator, denominator } = props
  const showProgressBar = denominator !== undefined

  const [cardWidth, setCardWidth] = React.useState(0)

  const progressBarContainerStyles: ViewStyle[] = [styles.progressBarContainer]
  const progressBarStyles: ViewStyle[] = [styles.progressBar]

  if (showProgressBar) {
    progressBarContainerStyles.push({ backgroundColor: Colors.greyMedium })
    progressBarStyles.push({ width: (numerator / denominator) * cardWidth })
  }

  if (numerator === denominator) {
    progressBarStyles.push({ borderBottomRightRadius: 0 })
  }

  return (
    <View
      style={styles.container}
      onLayout={e => setCardWidth(e.nativeEvent.layout.width)}
    >
      <View style={progressBarContainerStyles}>
        <View style={progressBarStyles} />
      </View>
      <View style={styles.content}>
        <Text style={styles.title}>{props.title}</Text>
        <Text style={styles.numerator}>
          {numerator}
          {denominator && (
            <Text style={styles.denominator}>{`/${denominator}`}</Text>
          )}
        </Text>
      </View>
    </View>
  )
}

const borderRadius = Theme.radius
const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: Colors.greyLight,
    margin: Spacing.sm,
    borderRadius,
  },
  content: {
    padding: Spacing.sm,
  },
  title: {
    fontWeight: "500",
    fontSize: 16,
    color: Colors.greyDarkest,
    marginBottom: Spacing.xs,
  },
  numerator: {
    color: Colors.greyDarkest,
    fontSize: 20,
    fontWeight: "600",
  },
  denominator: {
    color: Colors.greyDark,
    fontSize: 14,
    fontWeight: "500",
  },
  progressBarContainer: {
    backgroundColor: "transparent",
    height: 8,
    borderTopLeftRadius: borderRadius,
    borderTopRightRadius: borderRadius,
    marginBottom: Spacing.xs,
  },
  progressBar: {
    height: 8,
    width: 0,
    backgroundColor: Colors.blueMedium,
    borderTopLeftRadius: borderRadius,
    borderTopRightRadius: borderRadius,
    borderBottomRightRadius: borderRadius,
  },
})
Enter fullscreen mode Exit fullscreen mode

Migrate to Reanimated 2

If you don't already have Reanimated 2 installed you'll want to do so by following the official docs.

In order to use the animations Reanimated 2 enables we need to use components that understand the animated values that Reanimated provides. Most/all components you would animate are provided under the default export from the library. That means we have Animated.Text, Animated.View, etc.

In the code below we'll want to import Animated from "react-native-reanimated" as well as switch

<View style={progressBarStyles} />
Enter fullscreen mode Exit fullscreen mode

to

<Animated.View style={progressBarStyles} />
Enter fullscreen mode Exit fullscreen mode

resulting in

// components/cards/Stats.tsx

import React from "react"
import { View, StyleSheet, Text, ViewStyle } from "react-native"
import Animated, { useAnimatedStyle, withSpring } from "react-native-reanimated"

import { Colors, Spacing, Theme } from "src/constants"

type StatsCardProps = {
  /* ... */
}

export const StatsCard = (props: StatsCardProps) => {
  const { numerator, denominator } = props
  const showProgressBar = denominator !== undefined

  const [cardWidth, setCardWidth] = React.useState(0)

  const progressBarContainerStyles: ViewStyle[] = [styles.progressBarContainer]
  const progressBarStyles: ViewStyle[] = [styles.progressBar]

  if (showProgressBar) {
    progressBarContainerStyles.push({ backgroundColor: Colors.greyMedium })
    progressBarStyles.push({ width: (numerator / denominator) * cardWidth })
  }

  if (numerator === denominator) {
    progressBarStyles.push({ borderBottomRightRadius: 0 })
  }

  return (
    <View
      style={styles.container}
      onLayout={e => setCardWidth(e.nativeEvent.layout.width)}
    >
      <View style={progressBarContainerStyles}>
        <Animated.View style={progressBarStyles} />
      </View>
      <View style={styles.content}>
        <Text style={styles.title}>{props.title}</Text>
        <Text style={styles.numerator}>
          {numerator}
          {denominator && (
            <Text style={styles.denominator}>{`/${denominator}`}</Text>
          )}
        </Text>
      </View>
    </View>
  )
}

const borderRadius = Theme.radius
const styles = StyleSheet.create({
  /* ... */
})
Enter fullscreen mode Exit fullscreen mode

The result of this change looks and works exactly the same as before.

Progress bar with no animation

Creating Animated Styles

Next we need to set up our animated styles. An Animated.View supports the same style properties as a normal View component (as seen above) in addition to the animated styles unique to Reanimated.

To accomplish this we'll need to import useAnimatedStyle from react-native-reanimated. This hook is one of the main elements in Reanimated v2's API.

From the useAnimatedStyle hook we want to return a style object with the properties that should be animated. In our case it's the width of the progress bar.

Also take note that useAnimatedStyle takes a second argument that serves as an array of depedencies (much like useEffect). Since the width of the bar changes when the numerator, denominator, or cardWidth change we'll add them all as dependencies.

The width is calculated exactly the same as before. The only difference is that we'll add our check of showProgressBar inside the hook.

const progressBarWidthAnimated = useAnimatedStyle(() => {
  if (!showProgressBar) {
    return {
      width: 0,
    }
  }

  return { width: (numerator / denominator) * cardWidth }
}, [numerator, denominator, cardWidth])
Enter fullscreen mode Exit fullscreen mode

That hook in the context of the component:

// components/cards/Stats.tsx

import React from "react"
import { View, StyleSheet, Text, ViewStyle } from "react-native"
import Animated, { useAnimatedStyle, withSpring } from "react-native-reanimated"

import { Colors, Spacing, Theme } from "src/constants"

type StatsCardProps = {
  /* ... */
}

export const StatsCard = (props: StatsCardProps) => {
  const { numerator, denominator } = props
  const showProgressBar = denominator !== undefined

  const [cardWidth, setCardWidth] = React.useState(0)

  const progressBarContainerStyles: ViewStyle[] = [styles.progressBarContainer]

  if (showProgressBar) {
    progressBarContainerStyles.push({ backgroundColor: Colors.greyMedium })
  }

  const progressBarWidthAnimated = useAnimatedStyle(() => {
    if (!showProgressBar) {
      return {
        width: 0,
      }
    }

    return { width: (numerator / denominator) * cardWidth }
  }, [numerator, denominator, cardWidth])

  const progressBarStyles: ViewStyle[] = [
    styles.progressBar,
    progressBarWidthAnimated,
  ]

  if (numerator === denominator) {
    progressBarStyles.push({ borderBottomRightRadius: 0 })
  }

  return (
    <View
      style={styles.container}
      onLayout={e => setCardWidth(e.nativeEvent.layout.width)}
    >
      <View style={progressBarContainerStyles}>
        <Animated.View style={progressBarStyles} />
      </View>
      <View style={styles.content}>
        <Text style={styles.title}>{props.title}</Text>
        <Text style={styles.numerator}>
          {numerator}
          {denominator && (
            <Text style={styles.denominator}>{`/${denominator}`}</Text>
          )}
        </Text>
      </View>
    </View>
  )
}

const borderRadius = Theme.radius
const styles = StyleSheet.create({
  /* ... */
})
Enter fullscreen mode Exit fullscreen mode

By looking at the following GIF you'll see that, once again, the result is exactly the same as what we've had this whole time.

Progress bar with no animation

Driving the Animation

Finally we can actually make the animation happen! This happens via withSpring from Reanimated (there are a variety of other animation drivers available as well). This one will allow us to have a spring like animation where it "bounces" over/under the target value.

All we'll do is pass the target value as an argument to withSpring and return that as the width.

return {
  width: withSpring((numerator / denominator) * cardWidth),
}
Enter fullscreen mode Exit fullscreen mode
// components/cards/Stats.tsx

import React from "react"
import { View, StyleSheet, Text, ViewStyle } from "react-native"
import Animated, { useAnimatedStyle, withSpring } from "react-native-reanimated"

import { Colors, Spacing, Theme } from "src/constants"

type StatsCardProps = {
  /* ... */
}

export const StatsCard = (props: StatsCardProps) => {
  const { numerator, denominator } = props
  const showProgressBar = denominator !== undefined

  const [cardWidth, setCardWidth] = React.useState(0)

  const progressBarContainerStyles: ViewStyle[] = [styles.progressBarContainer]

  if (showProgressBar) {
    progressBarContainerStyles.push({ backgroundColor: Colors.greyMedium })
  }

  const progressBarWidthAnimated = useAnimatedStyle(() => {
    if (!showProgressBar) {
      return {
        width: 0,
      }
    }

    return {
      width: withSpring((numerator / denominator) * cardWidth),
    }
  }, [numerator, denominator, cardWidth])

  const progressBarStyles: ViewStyle[] = [
    styles.progressBar,
    progressBarWidthAnimated,
  ]

  if (numerator === denominator) {
    progressBarStyles.push({ borderBottomRightRadius: 0 })
  }

  return (
    <View
      style={styles.container}
      onLayout={e => setCardWidth(e.nativeEvent.layout.width)}
    >
      <View style={progressBarContainerStyles}>
        <Animated.View style={progressBarStyles} />
      </View>
      <View style={styles.content}>
        <Text style={styles.title}>{props.title}</Text>
        <Text style={styles.numerator}>
          {numerator}
          {denominator && (
            <Text style={styles.denominator}>{`/${denominator}`}</Text>
          )}
        </Text>
      </View>
    </View>
  )
}

const borderRadius = Theme.radius
const styles = StyleSheet.create({
  /* ... */
})
Enter fullscreen mode Exit fullscreen mode

Progress bar with animation that overflows bounds

Now we see some animations! But there are few issues:

  1. If the bar is 100% full, due to the spring animation, it can overflow beyond the bounds of the card.
  2. If the value is set to 0 after being at 8 (when you reset the game for example) it glitches by filling/unfilling the bar multiple times
  3. If the bar is close to full (7/8 for example) it can still overflow beyond the card

Fixing Overflow Issues

The problems we're seeing are a result of using spring animations, but we can fix those with a few arguments to withSpring.

To fix overshoot issue we can "clamp" the animation to not extend past the target value with overshootClamping. This takes some of the fun away from the spring animation though so we'll only do this if the bar is 0% or 100% full.

To fix the third point of a 90% full bar overflowing the card we can reduce how "bouncy" the spring is by adjusting the stiffness option.

// components/cards/Stats.tsx

import React from "react"
import { View, StyleSheet, Text, ViewStyle } from "react-native"
import Animated, { useAnimatedStyle, withSpring } from "react-native-reanimated"

import { Colors, Spacing, Theme } from "src/constants"

type StatsCardProps = {
  /* ... */
}

export const StatsCard = (props: StatsCardProps) => {
  const { numerator, denominator } = props
  const showProgressBar = denominator !== undefined

  const [cardWidth, setCardWidth] = React.useState(0)

  const progressBarContainerStyles: ViewStyle[] = [styles.progressBarContainer]

  if (showProgressBar) {
    progressBarContainerStyles.push({ backgroundColor: Colors.greyMedium })
  }

  const progressBarWidthAnimated = useAnimatedStyle(() => {
    if (!showProgressBar) {
      return {
        width: 0,
      }
    }

    // We clamp at 0 and the last number so that the bar doesn't extend outside of
    // the card. If we jump from 8 to 0 (reseting a game) the bar glitches and
    // empties, refills, and empties again. Clamping fixes that.
    const useClamping = numerator === 0 || numerator >= denominator
    return {
      width: withSpring((numerator / denominator) * cardWidth, {
        overshootClamping: useClamping,
        stiffness: 75,
      }),
    }
  }, [numerator, denominator, cardWidth])

  const progressBarStyles: ViewStyle[] = [
    styles.progressBar,
    progressBarWidthAnimated,
  ]

  if (numerator === denominator) {
    progressBarStyles.push({ borderBottomRightRadius: 0 })
  }

  return (
    <View
      style={styles.container}
      onLayout={e => setCardWidth(e.nativeEvent.layout.width)}
    >
      <View style={progressBarContainerStyles}>
        <Animated.View style={progressBarStyles} />
      </View>
      <View style={styles.content}>
        <Text style={styles.title}>{props.title}</Text>
        <Text style={styles.numerator}>
          {numerator}
          {denominator && (
            <Text style={styles.denominator}>{`/${denominator}`}</Text>
          )}
        </Text>
      </View>
    </View>
  )
}

const borderRadius = Theme.radius
const styles = StyleSheet.create({
  /* ... */
})
Enter fullscreen mode Exit fullscreen mode

Progress bar with clamped animation

Much better! Now we have a fun and glitch-free animation for the progress bar thanks to Reanimated 2.

You can find the final code on Github.

Further Learning

Reanimated 2 is incredibly powerful. Friend of React Native School, Aditya Pahilwani, wrote a fantastic article on using Reanimated 2 to build an animated tab bar.

What's an area in your app where you could benefit from some subtle animations? Let us know on Twitter.

Top comments (0)