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.
- We will first learn how to build an animated splash screen.
- 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 ๐
Step 2: Animating Logo
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.
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.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.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 ๐ก
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.
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.
The home screen should be instantly visible once the animation is finished.
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 ๐.