Animating Splash Screen With React Native Reanimated

Animating Splash Screen With React Native Reanimated

ยท

6 min read

We recently shipped one of the biggest updates to the Hashnode App. If you have been using the new app, the first thing you'll notice is the new splash screen.

Install the new app if you haven't already.

You start with a hashnode logo that animates nicely to get you into the app. I am going to share how we built this snappy 60 fps animation with React Native Reanimated v2.
We will also learn some of the React Native gotchas so make sure you stick to the end.

Approach - Part I and II

We will divide the approach into 2 parts to make things easier to understand.

  1. We will first learn how to build an animated splash screen.
  2. Then we will see how to integrate it into the app.

Part 1

Step 1: Building Splash Screen

Let's start with creating a full-screen view that places a logo at the center.

return (
  <View
    style={{
      flex: 1,
      justifyContent: "center",
      alignItems: "center",
      backgroundColor: Constants.manifest?.splash?.backgroundColor,
    }}
  >
    <LogoSVG width={SIZE} height={SIZE} fill="#3466F6" />
  </View>
);

Simple enough? We can start talking about the animation logic now ๐Ÿ˜

Animation strategy is simply scaling the logo and making it big enough so that it's out of view. Let's look at multiple approaches and their tradeoff.

  1. Approach 1: Simply scale the logo until it moves out of the screen.
    The problem with this approach is, you'll see pixels distorting as the logo gets bigger. We don't want a pixelated logo even for a fraction of a second.

  2. Approach 2: Scale SIZE value and pass it to width and height props. It's doable but we noticed that animation became very laggy when using animated props. This was also not a viable solution.

  3. Approach 3: Start with a scaled-down logo and slowly scale it up. This way, the scaling won't distort the pixels. Also, we are animating styles instead of props so it will run smoothly at 60 fps. Let's dig into the code now.

    // SCALE_FACTOR determines how much we are scaling down initially
    const SCALE_FACTOR = 10;
    
    // Let's start with creating a shared value for scale property
    const scale = useSharedValue(1 / SCALE_FACTOR);
    
    // Call this function to start animation
    // Find the sweet spot for scaling and time duration
    const animateSize = () => {
      scale.value = withTiming(SCALE_FACTOR / 2, {
        duration: 350,
      });
    };
    
    // create animated styles
    const animatedStyle = useAnimatedStyle(() => ({
      transform: [{ scale: scale.value }],
    }));
    
    // don't forget to create animated svg component for logo.
    return (
      <View
        style={{
          flex: 1,
          justifyContent: "center",
          alignItems: "center",
          backgroundColor: Constants.manifest?.splash?.backgroundColor,
        }}
      >
        <AnimatedLogoSVG
          fill="#3466F6"
          width={SIZE * SCALE_FACTOR}
          height={SIZE * SCALE_FACTOR}
          style={animatedStyle}
        />
      </View>
    );
    

Step 3: Calculate How Much To Scale

We need to get into the app as soon as the logo is out of view. Since we are relying on scale property, we need to know how much scale is enough to make the logo completely disappear.
We know Hashnode Logo is a ring-like shape. It's safe to say that once the inner circle is out of view, the logo won't be visible. This happens when the inner circle is bigger than the two extreme corners of the device. Can you guess what connects these 2 corners?

You're right, it's a diagonal ๐Ÿ’ก

Group 11(1).png

We have width and height of the screen so diagonal can be easily calculated using the formula below:

const diagonal = Math.sqrt(width * width + height * height);

Now you can configure the scale to match ceil of this value and you're done. Let's integrate it into the app.

Part 2

This is the fun part. There are a couple of things we need to consider while adding an animated splash screen to the app.

  1. The animation should start once we have the loading complete. This can include fetching data, loading fonts, theme preference, etc. We will call this app-ready state.

  2. The home screen should be instantly visible once the animation is finished.

  3. There shouldn't be any flashes/blank screen when we switch from splash screen to App.

The approach will conditionally show a splash screen and load data in the background. Once data loading is complete, we will flip a flag and render routes instead.

Step 1: Setup Context Providers

As discussed above, we want to show a splash screen until the app is ready and then switch to application routes.

return isAppReady ? <Router /> : <AnimatedSplashScreen />

If we simply switch to Router, we will have to load the whole Router which will take some time.

We are using react-navigation and wrapping with NavigationContainer. We can see when the navigation container is ready using the onReady prop.

return (
  <NavigationContainer onReady={markNavigationContainerReady}>
    {children}
  </NavigationContainer>
);

Now we will have to update the app entry point to factor NavigationContainer.

// isReady is react state that tracks when all data fetching is complete. It includes loading fonts, navigation, etc
return (
    <NavigationContainer
      onReady={markNavigationContainerReady}>
      <AnimatedSplashScreen isReady={isReady}>
        <Router />
      </AnimatedSplashScreen>
    </NavigationContainer>
  );

Now we can update the AnimatedSplashScreen to receive Router as children and render it only when splash screen animation is finished.

// track animation complete state
const [isAppReady, setIsAppReady] = useState(false);

useEffect(() => {
  // start animation when data loading is complete
  if (isReady) {
    animateSize();
  }
}, [isReady]);

const animateSize = () => {
  scale.value = withTiming(
    SCALE_FACTOR / 2,
    {
      duration: 350,
    },
    // toggle app ready state when animation is finished
    (isFinished) => {
      if (isFinished) {
        runOnJS(hideAnimation)();
      }
    }
  );
};

const hideAnimation = () => {
  setIsAppReady(true);
};

return (
  <View style={{ flex: 1 }}>
    {isAppReady ? (
      children
    ) : (
      <View
        style={{
          flex: 1,
          justifyContent: "center",
          alignItems: "center",
          backgroundColor: Constants.manifest?.splash?.backgroundColor,
        }}
      >
        <AnimatedLogoSVG
          fill="#3466F6"
          width={SIZE * SCALE_FACTOR}
          height={SIZE * SCALE_FACTOR}
          style={animatedStyle}
        />
      </View>
    )}
  </View>
);

Looks good, right? But wait, there are a couple of problems with this approach. ๐Ÿค”

Simply loading <Router /> will result in a momentarily blank screen. It's a huge app, react-navigation takes some microseconds to prepare and load routes. We will have to load the Router in the background so that it's available when the SplashScreen disappears.

Let's code this workaround:

return (
  <View style={{ flex: 1 }}>
    {!isAppReady && (
      <View
        style={{
          zIndex: 999,
          elevation: 9,
          ...StyleSheet.absoluteFillObject,
        }}
      >
        <View
          style={{
            flex: 1,
            justifyContent: "center",
            alignItems: "center",
            backgroundColor: Constants.manifest?.splash?.backgroundColor,
          }}
        >
          <AnimatedLogoSVG
            fill="#3466F6"
            width={SIZE * SCALE_FACTOR}
            height={SIZE * SCALE_FACTOR}
            style={animatedStyle}
          />
        </View>
      </View>
    )}
    {children}
  </View>
);

We are rendering the Router along with animating the splash screen. Since the splash screen is positioned absolute, it will always be above the routes.
This will allow us to show the app as soon as the animation is complete. There won't any delay in loading Router and hence we won't see any blank space.

Do you want to see the final result? Install the app now and try it on your phone ๐Ÿ˜‰.