loading...
Cover image for The story about one progress bar animation with React Native

The story about one progress bar animation with React Native

serzmerz profile image Sergey Tansky ・6 min read

I had a chance to work closely with animations in web applications and mobile(React Native). Sometimes I was confused by complex react native animations and didn't understand why people created react-native-reanimated library.
In this tutorial I wanted to explain the difference in web/mobile animations, benefits from react-native-reanimated, cross-platform React components.

Let's see how to build an animated progress bar in a browser.

Firstly, we create a React component:

 const ProgressBar = ({ total, current, fill }) => {
   const percent = current / total;
   return (
     <div class="container">
       <div class="progress"></div>
     </div>
   )
 }

CSS styles:

.container {
  background-color: #eee;
  height: 4px;
  border-radius: 2px;
  margin: 20px;
  position: relative;
  overflow: hidden;
}

.progress {
  position: absolute;
  left: -100%;
  width: 100%;
  top: 0;
  bottom: 0;
  border-radius: 2px;
}

Next step is applying styles from props to our layout:

...
  <div class="progress" style={{ transform: `translateX(${percent * 100}%)`, backgroundColor: fill }}></div>
...

Let's see the result:

Progress bar

What about animations?

In web applications, it's really easy to animate properties like transform or background color, the browser will do all stuff for animation without us.

Just add transition property to our styles:

.progress {
  ...
  transition: all 0.2s;
}

That's a result:

Progress bar animation

Seems like component very easy, why I show you this example?

Let's try to implement this component with React Native:

const AnimatedProgress = ({ fill, current, total }) => {
     const percent = current / total;

     return (
       <View style={styles.container}>
         <View style={[styles.progress, { backgroundColor: fill, transform: [{ translateX: `${percent * 100}%` }] }]} />
       </View>
     );
   };

Ooops, our component doesn't work as expected, because translateX must be a number (from documentation).

So, how we can get the width of the element?

Let's add useOnLayout hook:

export default function useOnLayout() {
  const [layout, setLayout] = useState({ x: 0, y: 0, width: 0, height: 0 });
  const onLayout = useCallback((event) => setLayout(event.nativeEvent.layout), []);

  return [layout, onLayout];
}

Pass onLayout handler to our wrapper View:

const AnimatedProgress = ({ fill, current, total }) => {
     const percent = current / total;

    // we need only width property
     const [{ width }, onLayout] = useOnLayout();

     return (
       <View style={styles.container} onLayout={onLayout}>
         <View style={[styles.progress, { backgroundColor: fill, transform: [{ translateX: width * percent }] }]} />
       </View>
     );
   };

Next step is animate our translateX property:

import { Animated } from "react-native";

// wrap our Animated.Value to useState for memoize it, alternatively you can use useRef
const [translateX] = useState(new Animated.Value(0));

useEffect(() => {
 Animated.timing(translateX, {
   toValue: width * percent,
   duration: 200,
   easing: Easing.inOut(Easing.ease),
   // using native driver for animation in UI thread 
   useNativeDriver: true
   }).start();
// call our animation when width or percent change
}, [width, percent]);

....

// Change View => Animated.View and translateX to our Animated.Value
<Animated.View style={[styles.progress, { backgroundColor: fill, transform: [{ translateX }] }]} />

Last animation for today - backgroundColor animation.

Is it so easy like in web applications?

Before we start writing color animation let's switch to react-native-reanimated library, which more flexible and has much more defined functions:

react-native-reanimated has backward capability API, so we can easily move to it without rewriting our codebase.

  • Note: we removed useNativeDriver: true property from config because react-native-reanimated already run all animations in the native UI thread.
import Animated, { Easing } from "react-native-reanimated";

useEffect(() => {
    Animated.timing(translateX, {
      toValue: width * percent,
      duration: 200,
      easing: Easing.inOut(Easing.ease)
    }).start();
  }, [width, percent]);

The main problem with animating colors in react native is a lack of transitions similar to the browser. Honestly, browsers do lots of stuff under the hood, like all those animations with CSS, interpolation colors, etc.

So, we should create a transition for color by ourselves. Previously we switched to a react-native-reanimated library, it has many useful functions which we will use.
Imagine the flow of color interpolation:

1) Convert color: Firstly let's convert color to one format - for example, RGBA(you can choose a different one - HSV, HSL)
2) we need steam of animation which we can iterate - it can be usual for us *Animated.Value*, which we can animate from 0 to some value.
3) In this time we will make interpolation of animation this value each of color part (r, g, b, a);
4) combine all these values into one color.

One important requirement - animations should work in the native UI thread. That's why we can't use simple interpolation from react-native to string colors, like this:

 const interpolateColor = animatedValue.interpolate({
   inputRange: [0, 150],
   outputRange: ['rgb(0,0,0)', 'rgb(51, 250, 170)']
 })

Native UI thread animations had a strong limitation, you can only animate non-layout properties, things like transform and opacity will work but Flexbox and position properties won't.
That's because we should define our animation before start it.

More about animations you can read here: https://reactnative.dev/blog/2017/02/14/using-native-driver-for-animated

We want to run animation when our prop fill has changed, for this reason, we should store previous fill value for running our interpolation.

Create the custom hook for color interpolation, and store previous value:

export default function useAnimatedColor(color) {  
  // store our previous color in ref
  const prevColor = useRef(color);

  // TODO write color interpolation

  // updating previous value after creating interpolation
  prevColor.current = color;

  // TODO return color interpolation
  return color;
}

The next step is adding color value which we will interpolate and run animation on color change. Of course, we can use useEffect from react-native for it, but react-native-reanimated has their own useCode hook.

// define input range for interpolation
const inputRange = [0, 50];

export default function useAnimatedColor(color) {
  // store our value to ref for memoization
  const colorValue = useRef(new Animated.Value(0));
  ...
  useCode(() => {
      const [from, to] = inputRange;
      // TODO iterate colorValue in range
    }, [color]);
}

react-native-reanimated has his mechanism for control each frame tick - Clock. And common function runTiming - for timing animation(it contain lots of boilerplate, you can find source code in the documentation or full code of this tutorial).
https://github.com/serzmerz/react-native-progress-bar

import Animated, { Clock } from "react-native-reanimated";

const { set, useCode } = Animated;

export default function useAnimatedColor(color) {
  const colorValue = useRef(new Animated.Value(0));
  ...
  // create clock instance and memoize it
  const clock = useRef(new Clock());

    useCode(() => {
      const [from, to] = inputRange;
      return [set(colorValue.current, runTiming(clock.current, from, to))];
    }, [color]);
}

Last thing which we do in this hook - colors interpolation, full code of this hook bellow:

const inputRange = [0, 50];

export default function useAnimatedColor(color) {
  const colorValue = useRef(new Animated.Value(0));
  const prevColor = useRef(color);

  // call our interpolateColors and wrap it to useMemo
  const backgroundColor = useMemo(
    () =>
      interpolateColors(colorValue.current, {
        inputRange,
        outputColorRange: [prevColor.current, color]
      }),
    [color]
  );

  prevColor.current = color;

  const clock = useRef(new Clock());

  useCode(() => {
    const [from, to] = inputRange;
    return [set(colorValue.current, runTiming(clock.current, from, to))];
  }, [color]);

  return backgroundColor;
}

What about interpolateColors function. For now, react-native-reanimated has implemented it in the codebase, but not published. if you read this tutorial, and the version of react-native-reanimated is above 1.9.0, this function should be inside.

By the way, we deep dive to this function for understanding how it works:

import { processColor } from "react-native";
import Animated, { round, color, interpolate, Extrapolate } from "react-native-reanimated";

// functions for getting each part of our color
function red(c) {
  return (c >> 16) & 255;
}
function green(c) {
  return (c >> 8) & 255;
}
function blue(c) {
  return c & 255;
}
function opacity(c) {
  return ((c >> 24) & 255) / 255;
}

/**
 * Use this if you want to interpolate an `Animated.Value` into color values.
 *
 * #### Why is this needed?
 *
 * Unfortunately, if you'll pass color values directly into the `outputRange` option
 * of `interpolate()` function, that won't really work (at least at the moment).
 * See https://github.com/software-mansion/react-native-reanimated/issues/181 .
 *
 * So, for now you can just use this helper instead.
 */
export default function interpolateColors(animationValue, options) {
  const { inputRange, outputColorRange } = options;
  // convert our colors to rgba format 
  const colors = outputColorRange.map(processColor);

  // interpolate each part of our color
  const r = round(
    interpolate(animationValue, {
      inputRange,
      // map only necessary part 
      outputRange: colors.map(red),
      extrapolate: Extrapolate.CLAMP
    })
  );
  const g = round(
    interpolate(animationValue, {
      inputRange,
      outputRange: colors.map(green),
      extrapolate: Extrapolate.CLAMP
    })
  );
  const b = round(
    interpolate(animationValue, {
      inputRange,
      outputRange: colors.map(blue),
      extrapolate: Extrapolate.CLAMP
    })
  );
  const a = interpolate(animationValue, {
    inputRange,
    outputRange: colors.map(opacity),
    extrapolate: Extrapolate.CLAMP
  });

  // combine all parts to one color interpolation
  return color(r, g, b, a);
}

That's it, you can call our hook inside AnimatedProgress component:

const AnimatedProgress = ({ fill, current, total }) => {
  const backgroundColor = useAnimatedColor(fill);

  ...
  // pass animated props to view
  <Animated.View style={[styles.progress, { backgroundColor, transform: [{ translateX }] }]} />
  ...
}

Have you noticed that the layout for Web and mobile are the same?

The last thing for today is making progress bar component cross-platform.
For achieving this goal we need to do two steps:
1) Split our hooks to two hooks:
- useAnimatedColor.js/useAnimatedColor.native.js
- useAnimatedProgress.js/useAnimatedProgress.native.js

.native.js extensions will load by the metro bundler on mobile platforms.
.js extensions will load on web.

For the web, we just make these hooks simple. All animations will be done by transition property.

useAnimatedColor.js:

export default function useAnimatedColor(color) {
 return color;
}

useAnimatedProgress.js

export default function useAnimatedProgress(width, percent) {
  return width * percent;
}

2) Add transition for web application in styles:

export default StyleSheet.create({
  ...
  progress: {
    ...
    // select only web for avoiding error on mobile devices
    ...Platform.select({ web: { transition: "0.3s all ease-in-out" } })
  }
});

Wow! We've built a cross-platform component with native animations for each platform.

You can find all source code in github: https://github.com/serzmerz/react-native-progress-bar

Example of usage: https://github.com/serzmerz/TestReactNativeProgressBar

And install the finished library for your own purpose.

yarn add react-native-reanimated-progress-bar

Posted on Jun 24 by:

Discussion

markdown guide