Running React Native dependent animations on UI thread using Reanimated

Sangamesh Somawar

Sangamesh Somawar

March 13, 2023

Slider

Here we have a slider. When user slides the slider then the loader needs to show how much is loaded. We want to animate the loader component when the slider moves. In other words the loader animation is "dependent" on the slider animation.

This is an example of Dependent Animations on the UI thread. Dependent Animation is when one view is animated based on some another element.

first we need a gesture handler to detect the slider events. Then based on the slider events we need to animate the progress of loader component.

Building gesture handler to detect the slider events

const [width, setWidth] = useState(200);
const x = useSharedValue(0);

const gestureHandler = useAnimatedGestureHandler({
  onStart: (event, ctx) => {
    x.value = event.absoluteX;
  },
  onActive: (event, ctx) => {
    x.value = event.absoluteX;
    // We need to calculate `progress` here based on the slider position.
  },
});

const animatedStyle = useAnimatedStyle(() => {
  return {
    transform: [
      {
        translateX: x.value,
      },
    ],
  };
});

return (
  <PanGestureHandler onGestureEvent={gestureHandler}>
    <Animated.View style={[{ height: 20 }]}>
      <Animated.View
        pointerEvents="none"
        style={[
          {
            backgroundColor: "blue",
            height: 20,
            width: 20,
            borderRadius: 10,
          },
          animatedStyle,
        ]}
      />
      <View
        pointerEvents="none"
        style={{
          backgroundColor: "black",
          height: 2,
          width: "100%",
          position: "absolute",
          top: 10,
        }}
      />
    </Animated.View>
  </PanGestureHandler>
);

Loader component:

<View style={{ height: 20, marginTop: 10 }}>
  <Lottie
    style={{
      alignSelf: "center",
      width: "100%",
    }}
    progress={"Calculated `progress` based on slider position."}
    source={require("./progress")}
    autoPlay={false}
    loop={false}
  />
</View>

Animate the loader based on the slider position

Before we move further, let's understand the difference between UI Thread and JS Thread. The UI Thread handles rendering and gestures of Android and iOS views, whereas the JS Thread takes care of all the logic of the React Native application.

We have two approaches for animating the loader.

In the first approach, when the slider moves, we can store the progress in react state and pass it to Lottie animation. With this approach, the entire component rerenders on setting the progress.

const [progress, setProgress] = useState(0);
const gestureHandler = useAnimatedGestureHandler({
  onStart: (event, ctx) => {
    x.value = event.absoluteX;
  },
  onActive: (event, ctx) => {
    x.value = event.absoluteX;
    runOnJS(setProgress)(x.value / width);
  },
});

<Lottie
  style={{
    alignSelf: "center",
    width: "100%",
  }}
  progress={progress}
  source={require("./progress")}
  autoPlay={false}
  loop={false}
/>;

In the second approach, when the slider moves, we can calculate progress and pass it to the loader component via the useAnimatedProps. In this way, the progress gets calculated on the UI thread itself. Hence it avoids rerenders.

const LottieAnimated = Animated.createAnimatedComponent(Lottie);
const [progress, setProgress] = useState(0);
const lottieAnimatedProps = useAnimatedProps(() => progress: x.value / width);
const gestureHandler = useAnimatedGestureHandler({
  onStart: (event, ctx) => {
    x.value = event.absoluteX;
  },
  onActive: (event, ctx) => {
    x.value = event.absoluteX;
  },
});

<LottieAnimated
  style={{
    alignSelf: "center",
    width: "100%",
  }}
  animatedProps={lottieAnimatedProps}
  source={require("./progress")}
  autoPlay={false}
  loop={false}
/>;

Conclusion

With the first approach, whenever the slider moves, the UI thread will pass the gesture event to the JS thread to store the progress value in react state, and when the progress value changes in the JS thread, it causes re-render. This approach creates a lot of traffic over Communication Bridge because of message exchange between UI and JS threads.

So we should prefer the second approach to run any calculations on the UI thread instead of the JS thread. Here is the entire code for reference:

let approach1ReRenderCount1 = 0;
const Approach1: () => Node = () => {
  const [width, setWidth] = useState(1);
  const x = useSharedValue(0);

  const [progress, setProgress] = useState(0);
  const gestureHandler = useAnimatedGestureHandler({
    onStart: (event, ctx) => {
      x.value = event.absoluteX;
    },
    onActive: (event, ctx) => {
      x.value = event.absoluteX;
      runOnJS(setProgress)(x.value / width);
    },
  });

  const animatedStyle = useAnimatedStyle(() => {
    return {
      transform: [
        {
          translateX: x.value,
        },
      ],
    };
  });

  return (
    <SafeAreaView>
      <View
        style={{
          height: 150,
          paddingHorizontal: 20,
          borderWidth: 1,
          margin: 10,
          justifyContent: "center",
        }}
        onLayout={({
          nativeEvent: {
            layout: { width },
          },
        }) => {
          setWidth(width);
        }}
      >
        <Text style={{ fontSize: 20, paddingBottom: 10 }}>Approach 1</Text>
        <Text style={{ fontSize: 20 }}>
          Rerender Count : {approach1ReRenderCount1++}
        </Text>

        <View style={{ height: 20, marginTop: 10 }}>
          <LottieAnimated
            style={{
              alignSelf: "center",
              width: "100%",
            }}
            progress={progress}
            source={require("./progress")}
            autoPlay={false}
            loop={false}
          />
        </View>
        <PanGestureHandler onGestureEvent={gestureHandler}>
          <Animated.View style={[{ height: 20 }]}>
            <Animated.View
              style={[
                {
                  backgroundColor: "blue",
                  height: 20,
                  width: 20,
                  borderRadius: 10,
                },
                animatedStyle,
              ]}
            />
            <View
              style={{
                backgroundColor: "black",
                height: 2,
                width: "100%",
                position: "absolute",
                top: 10,
              }}
            />
          </Animated.View>
        </PanGestureHandler>
      </View>
    </SafeAreaView>
  );
};

const LottieAnimated = Animated.createAnimatedComponent(Lottie);
let approach2ReRenderCount = 0;
const Approach2: () => Node = () => {
  const [width, setWidth] = useState(1);
  const x = useSharedValue(0);

  const lottieAnimatedProps = useAnimatedProps(() => progress: x.value / width);

  const gestureHandler = useAnimatedGestureHandler({
    onStart: (event, ctx) => {
      x.value = event.absoluteX;
    },
    onActive: (event, ctx) => {
      x.value = event.absoluteX;
    },
  });

  const animatedStyle = useAnimatedStyle(() => {
    return {
      transform: [
        {
          translateX: x.value,
        },
      ],
    };
  });

  return (
    <SafeAreaView>
      <View
        style={{
          height: 150,
          paddingHorizontal: 20,
          borderWidth: 1,
          margin: 10,
          justifyContent: "center",
        }}
        onLayout={({
          nativeEvent: {
            layout: { width },
          },
        }) => {
          setWidth(width);
        }}
      >
        <Text style={{ fontSize: 20, paddingBottom: 10 }}> Approach 2</Text>
        <Text style={{ fontSize: 20 }}>
          Rerender Count : {approach2ReRenderCount++}
        </Text>
        <View style={{ height: 20, marginTop: 10 }}>
          <LottieAnimated
            animatedProps={lottieAnimatedProps}
            style={{
              alignSelf: "center",
              width: "100%",
            }}
            source={require("./progress")}
            autoPlay={false}
            loop={false}
          />
        </View>
        <PanGestureHandler onGestureEvent={gestureHandler}>
          <Animated.View style={[{ height: 20 }]}>
            <Animated.View
              pointerEvents="none"
              style={[
                {
                  backgroundColor: "blue",
                  height: 20,
                  width: 20,
                  borderRadius: 10,
                },
                animatedStyle,
              ]}
            />
            <View
              pointerEvents="none"
              style={{
                backgroundColor: "black",
                height: 2,
                width: "100%",
                position: "absolute",
                top: 10,
              }}
            />
          </Animated.View>
        </PanGestureHandler>
      </View>
    </SafeAreaView>
  );
};

If this blog was helpful, check out our full blog archive.

Stay up to date with our blogs.

Subscribe to receive email notifications for new blog posts.