Splash screens on React Native without libs

Rodrigo Prado Fontes
17 min readApr 22, 2020
Photo by Alex Perez on Unsplash

I recently had to upgrade the splash screen in the BLZ app to comply with App Store’s new rules requiring the use of storyboards for launch screens after April 30, 2020. Since I did not find any guide to help with the process, I decided to write this article to share how we implemented splash screens for both platforms without adding any new dependency to our app.

A point about splash screen libs

Most guides you find will encourage you to use react-native-splash-screen as a cross-platform solution for splash screens on React Native. If we chose to use the lib, almost all of the following steps would still have to be performed, only the native code part would be abstracted alway by the library (although we would still need to edit the native files to call it). On iOS there would also be one more level of complexity, as the lib still doesn’t support getting the view from a storyboard.

Here at BLZ we strongly believe that if some task is simple enough, we should develop our own solution and not rely on third-party libs as they might make the project harder to update and maintain. Although react-native-splash-screen is a great library with very detailed documentation, the parts of the code that it would abstract for us on Android just doesn’t make the cut.

If you don’t feel comfortable writing your own native code, or want to use third-party libs for any other reason, this guide will still be useful for you, as a way to setup the initial splash screen of you project. Just skip the parts with native code and you’ll be fine.

With that out of the way, let’s start 🤩

Start project

Let’s start with the basics. First, follow RN docs to setup your environment and initialise a RN project (if you already have an app, skip this step). Make sure you are not using Expo.

$ npx react-native init awesome_app
$ cd awesome_app

iOS

Photo by Rob Janoff on Wikipedia

First, let’s open our project on Xcode. You can launch Xcode, click in “File > Open”, navigate to the project root and select ios/awesome_app.xcworkspace, or you can use the terminal:

$ open ios/awesome_app.xcworkspace

All new React Native projects come with a file called LaunchScreen.xib . This is the old way of doing splash screens on iOS and will no longer be supported at the end of this month. Let’s go ahead and delete it. Right-click the file, select “delete” and then “Move to Trash”.

Delete LaunchScreen.xib

Now we are going to create our storyboard. Go to “File > New > File” (or press ⌘N). Select “Launch Screen” in the bottom left corner and click Next. Choose where to save it and, if your project has more than one target that is going to use this splash screen, be sure to select them.

After “File > New > File”, select “Launch Screen” and click “Next”
Choose where to save and the targets of your file (the rest of this tutorial assumes the name was left as “Launch Screen”)
Launch Screen created

Now that we have our storyboard, we need to import the images we are going to use in our Splash Screen. The project contains a Images.xcassets files to organize all our images, so let’s use it.

Select Images.xcassets , click the “+” button on the bottom part of the screen and select “New Image Set”

New Image Set

Xcode will ask you for three image sizes. “1x” is the size of the image in the splash screen. If you have logo that will occupy 100x100px, you should create an image of it with 100x100px and drag it to “1x”, one with 200x200px to “2x” and one with 300x300px to “3x”. Xcode will automatically copy the file to your project.

I’m going to use the images of the BLZ Splash Screen because I already have the files at hand and they will give me the chance to show how to fill the entire width of the screen. Here are the results for my project:

Imported images

With our images imported, we can go back to the storyboard and draw our Splash Screen.

Select Launch Screen.storyboard on the navigation menu. Expand “View Controller Scene”, “View Controller” and “View” in the Document Outline. There are two label objects wich Xcode automatically added to the screen. Select each of them and press “delete (⌫)”. Now select the “View”, click on the the icon at the top right corner of the Xcode window to show the inspectors and disable “Safe Area Layout Guide”.

Delete labels
Select “View” and show inspectors (where my mouse pointer is)
In the inspectors, click on the Size tab and disable “Safe Area Layout Guide”

If your splash screen has a background color, you can set it here. Just go to the Attributes tab in the inspectors and change the Background. Blz uses a white background, so I won’t change it in my project.

Change the view’s background color if you want

Now let’s add our logo. Click the “+” button on the top right corner of the screen (near the “show inspectors” button). A search modal will appear with all the elements we can add. Type “image” and drag the item “Image View” to inside the View.

Add Image View

With the added Image View selected, we can go to the Attributes tab in the inspectors, click on the Images dropdown and select the logo.

Select the logo image

Now we can change the image size and position it anywhere we want. Most apps (including Blz) use the logo in the center of the screen, so let’s do that.

First we’ll change the size of the Image View rectangle so that it has the same aspect ratio as the logo (it’s possible to drag the white squares or set the width and height in the inspectors to match the dimensions of the 1x image). Once we have the image in the correct size, we can use the Align constraints to make sure it’s always in the center of the screen. Click on the “Align” icon in the bottom right corner of the screen and select “Horizontally in Container” and “Vertically in Container”. Then click in “Add 2 Constraints”.

Add align constraints

Our icon is now centered. If we click in the bottom part of the screen where it says “View as: iPhone 11”, a panel will open and we can see how our splash screen will appear in different iPhone screen sizes and orientations.

For most apps, this is the last layout edit the splash screen needs. If this is your case, go to the paragraph that says “continue here if you left at the icon part”.

For Blz however, we still need to add the top and bottom images and make sure the icon size changes according to the screen size (so it’s not too big even in small phones).

Let’s finish with the logo first.

Because our design team gave us the icon with white padding on the sides to make sure it’s always in the correct proportion to the screen, the icon’s width will always be the same as the width of the screen. This means the constraint to align it horizontally is not necessary. We can then select it in the Size tab in the inspectors and delete it. To make it’s size dynamic, we are going to add position constrants. It’s distance to the left and right of the View must always be zero (so they have the same width) and it’s aspect ratio must be constant.

Add positioning and aspect ratio constraints

We will repeat the same steps for the top and bottom images, with the following differences: the top will have a positioning restraint of distance 0 to the top of the View and the bottom will have a positioning restraint of distance 0 to the bottom of the View.

So, the procedure is the same: add a Image View to our view, select the desired image, transform it to the desired size and add constraints.

Adding top constraints
Adding bottom constraints
Final Splash Screen

Continue here if you left at the icon part.

We now have to give the View Controller in our storyboard an ID, so we can find it later on our code. To do that, we neeed to select th View Controller in the Document Outline column, go to the Identity tab in the inspectors and then give it a “Storyboard ID”. You can give it any ID you like. I’ll use LaunchScreenViewController.

Ok, now we will register the storyboard as the first thing shown by our app when it launches.

Select awesome_app (or your project) in the project navigator. In the General tab, find “Launch Screen File” and select “Launch Screen” (it doesn’t show an extension, so make sure the name matches the storyboard).

Select the Launch Screen File

Now we can run our project and the first thing that appears when the app opens is our splash screen! It is, however, still followed by a blank screen while React Native loads. We will fix that in a few more steps.

If you splash screen is still not showing, there is problably some problem with your build cache. Please refere to this question to find ways to clean your build and device caches. Make sure the splash screen works before proceeding, as it will become more difficult to see it after the next steps.

So, let’s handle the React Native blank screen.

There is a documentation entry for handling this white screen, we only need to adapt it to load the view from a storyboard.

First, let’s select AppDelegate.m on our project navigator. Now, we find the method:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions

Between the lines:

  [self.window makeKeyAndVisible];
return YES;

We will insert the following code:

  [self.window makeKeyAndVisible];  // Make sure the name matches the one used for the storyboard file
UIStoryboard *launchScreenStoryboard = [UIStoryboard storyboardWithName:@"Launch Screen" bundle:nil];
// This must match the Storyboard ID we chose for our View Controller
UIViewController *launchScreenViewController = [launchScreenStoryboard instantiateViewControllerWithIdentifier:@"LaunchScreenViewController"];
UIView *launchScreenView = [launchScreenViewController view];
launchScreenView.frame = self.window.bounds;
rootView.loadingView = launchScreenView;
return YES;

That’s it! Run your project and enjoy an iOS world free of flashing white screens.

Android

Photo by Google on Wikipedia

On Android, the process wil involve a few more steps. If you’re familiar with the process of creating native modules, however, it will be fairly simple.

It is not necessary to use Android Studio for the next steps, but since it has some very nice auto-complete functionalities that make our work easier, we will use it in this guide.

First, let’s open the android folder of our app on Android Studio and make sure that we are using the project view.

Select Project view

Now, on the project navigator, we expand android/app/src/main/res, right-click on the res folder and select “New > Directory”. Wee need to create five folders: drawable-mdpi, drawable-hdpi, drawable-xhdpi, drawable-xxhdpi and drawable-xxxhdpi. Each of this will hold a different resolution of the image assets in our splash screen.

Create drawable density folders

The mdpi folder will hold our reference image. Its files should have the same same resolution as is desired in the final splash screen. The other folders should have images according to the following table:

| density | multiplier | e.g. resolution |
+---------+------------+-----------------+
| mdpi | 1x | 100x100 px |
| hdpi | 1.5x | 150x150 px |
| xhdpi | 2x | 200x200 px |
| xxhdpi | 3x | 300x300 px |
| xxxhdpi | 4x | 400x400 px |

To add the images, we can drag and drop them to the folders in the IDE, or copy them using Finder (Android Studio will automatically add them to the project). The images in my project are called bottom.png, logo.png and top.png.

It’s a good practice to keep all the colors used in our project in a dedicated colors.xml file. To create it, right click on res/values and select “New > Values Resource File”. Give it the name colors. Now we can use the <color> tag to define the background and any other color used in our splash screen. Since BLZ uses a white background, that’s what I’m going to declare:

Still in the res folder, we need to create a new folder called drawable (without the density specified), then we can right-click it and select “New > Drawable Resource File” and give it the name launch_screen. It should look like this:

New launch_screen.xml file in the drawable folder

Now we will draw our splash screen. Just like we did on iOS, we will first develop a simpler solution that consists of only a colored background and a centralized icon. This should be enough for the great majority of apps. After that, we will adapt our solution for layouts that use the upper and lower extremities of the screen.

Let’s start by replacing the tag<selector> for <layer-list> and adding the property android:opacity="opaque" so we avoid a black flash when the app opens.

Criar uma layer-list com opacidade

Each item inside a layer-list is placed in front of its siblings and is stretched to fill its container (which in our case is the cellphone screen). Some tags change this behaviour, like <bitmap> with a android:gravity property. You can use multiple gravity values for a single tag by joining them with a |. More info is available here.

We will define a layer consisting of only a color, which will be our background, a a centered bitmap containing our icon. The bitmap also has its sides clipped as we decrease the screen size.

<!-- android/app/src/main/res/drawable/launch_screen.xml -->
<?xml version="1.0" encoding="utf-8"?>
<layer-list
xmlns:android="http://schemas.android.com/apk/res/android"
android:opacity="opaque">
<item android:drawable="@color/white" />
<item>
<bitmap android:src="@drawable/logo" android:gravity="center|clip_horizontal" />
</item>
</layer-list>

With the splash screen drawn, we will go to res/values/styles.xml and use it as the app’s background. First we open the file. There is already a AppTheme defined here. It has a hard-coded color which we can pass to our colors.xml file. Then we can create a new style called AppTheme.Launcher with the properties we want. Using the dot notation guarantees that our style will inherit all the properties from AppTheme.

Warning: this solution uses android:windowBackground and therefore is not suitable for layouts that use the upper and lower parts of the screen. We will handle this cases further down.

<!-- android/app/src/main/res/values/styles.xml -->
<resources>

<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:textColor">@color/textColor</item>
</style>

<style name="AppTheme.Launcher">
<item name="android:windowBackground">@drawable/launch_screen</item>
</style>

</resources>

Now we need to go to our AndroidManifest.xml file, move the android:theme property from the application to the launcher activity and replace its value with AppTheme.Launcher:

<!-- android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.awesome_app">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false">
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
android:launchMode="singleTask"
android:theme="@style/AppTheme.Launcher"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
</application>

</manifest>

Now we need to transition back to our original theme before loading React Native. Let’s open our MainActivity.java and override its onCreate method:

// android/app/src/main/java/com/awesome_app/MainActivity.java
package com.awesome_app;
import android.os.Bundle;import com.facebook.react.ReactActivity;public class MainActivity extends ReactActivity {
@Override
protected void onCreate(Bundle savedInstance) {
// Call before `super.onCreate`
setTheme(R.style.AppTheme);
super.onCreate(savedInstance);
}
/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
@Override
protected String getMainComponentName() {
return "awesome_app";
}
}

With this, we can run our app and check that it shows our splash screen when it opens. It will still show a blank screen while React Native is loading. If this solution is already good enough for your layout, you can skip to the paragraph that says “continue here if you your layout uses only an icon”.

If your splash screen uses the upper and lower parts of the screen, you probably have realised by now that using android:windowBackground will put parts of your layouf behind the status bar and the action buttons of the cellphone. Now, a simple solution would be to replace it with android:background. Doing that will make a red error screen appear and prevent React Native from loading.

Since React Native explicitly does not support changing the background, what we need to do is create a new activity class that will be our app’s launcher, contain the customised background and will start the MainActivity. So, let’s do that.

First, we crate a new Java class with the name SplashActivity.

create new Java class

It needs to extend AppCompatActivity and in its onCreate we are just going to fire MainActivity and finish SplashActivity.

// android/app/src/main/java/com/awesome_app/SplashActivity.java
package com.awesome_app;

import android.content.Intent;
import android.os.Bundle;

import androidx.appcompat.app.AppCompatActivity;

public class SplashActivity extends AppCompatActivity {
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);

Intent intent = new Intent(this, MainActivity.class);
startActivity(intent);

finish();
}
}

We need to register this new activity in our manifest, set its theme to AppTheme.Launcher, make it our app’s launcher and restore the theme of MainActivity to AppTheme.

<!-- android/app/src/main/AndroidManifest.xml -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.awesome_app">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:name=".MainApplication"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:allowBackup="false">
<activity
android:name=".SplashActivity"
android:label="@string/app_name"
android:theme="@style/AppTheme.Launcher">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/AppTheme"
android:windowSoftInputMode="adjustResize" />
<activity android:name="com.facebook.react.devsupport.DevSettingsActivity" />
</application>

</manifest>

Now we can change our style to use android:background:

<!-- android/app/src/main/res/values/styles.xml -->
<resources>

<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:textColor">@color/textColor</item>
</style>

<style name="AppTheme.Launcher">
<item name="android:background">@drawable/launch_screen</item>
</style>

</resources>

Since the MainActivity’s theme is now AppTheme, the line we added to its onCreate method is no longer necessary (do not remove the onCreate method, we will use it later):

// android/app/src/main/java/com/awesome_app/MainActivity.java
package com.awesome_app;

import android.os.Bundle;

import com.facebook.react.ReactActivity;

public class MainActivity extends ReactActivity {
@Override
protected void onCreate(Bundle savedInstance) {
// Call this before `super.onCreate` if you are using a custom theme for MainActivity
// setTheme(R.style.AppTheme);

super.onCreate(savedInstance);
}

/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
@Override
protected String getMainComponentName() {
return "awesome_app";
}
}

We can also add the top and bottom layers of our splash screen now that the status bar and system buttons won’t hide parts of them.

<!-- android/app/src/main/res/drawable/launch_screen.xml -->
<?xml version="1.0" encoding="utf-8"?>
<layer-list
xmlns:android="http://schemas.android.com/apk/res/android"
android:opacity="opaque">
<item android:drawable="@color/white" />
<item>
<bitmap android:src="@drawable/bottom" android:gravity="left|right|bottom|clip_vertical" />
</item>
<item>
<bitmap android:src="@drawable/top" android:gravity="left|right|top|clip_vertical" />
</item>
<item>
<bitmap android:src="@drawable/logo" android:gravity="center|clip_horizontal" />
</item>
</layer-list>

Running the app now will show the complete splash screen \o/

Continue here if you your layout uses only an icon.

Now we need a way to keep showing our splash screen while React Native loads. There is no official way to do this on Android, so we are going to use a simplified version of crazycodeboy’s idea to show a full-screen dialog containing the splash screen as its background before RN starts to load and hide it when the JS part of our app is running.

First we will define a style for the animation of our splash screen being dismissed. You can skip this if you dan’t want any animation.

<!-- android/app/src/main/res/values/styles.xml -->
<resources>

<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:textColor">@color/textColor</item>
</style>

<style name="AppTheme.Launcher">
<item name="android:background">@drawable/launch_screen</item>
</style>

<style name="WindowExitFadeOut">
<item name="android:windowExitAnimation">@android:anim/fade_out</item>
</style>

<style name="AppTheme.SplashDialog">
<item name="android:windowAnimationStyle">@style/WindowExitFadeOut</item>
</style>
</resources>

We need to define a layout for our dialog. To match AppTheme.Launcher, we are going to use a LinearLayout that fills the entire screen and has our launch_screen as background. To do that, we need to create the folder android/app/src/main/res/layout and a file splash_screen.xml inside of it.

<!-- android/app/src/main/res/layout/splash_screen.xml -->
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/launch_screen"/>

Now we will create a native module called SplashScreenModule with a method show that will be called by MainActivity before it starts loading RN and a method hide wich JS will call after it’s done loading.

First we create a folder called modules, inside of it another called SplashScreen and inside of that two Java classes: SplashScreenModule.java and SplashScreenPackage.java.

The module:

// android/app/src/main/java/com/awesome_app/modules/SplashScreen/SplashScreenModule.java
package com.awesome_app.modules.SplashScreen;

import android.app.Activity;
import android.app.Dialog;
import android.os.Build;

import androidx.annotation.NonNull;

import com.awesome_app.R;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;

import java.lang.ref.WeakReference;

public class SplashScreenModule extends ReactContextBaseJavaModule {
private static WeakReference<Activity> mainActivityRef;
private static Dialog splashDialog;

/* Boilerplate */
SplashScreenModule(@NonNull ReactApplicationContext reactContext) {
super(reactContext);
}

@Override
@NonNull
public String getName() {
return "SplashScreenModule";
}

/* React Native method */
@ReactMethod
public void hide() {
Activity currentActivity = getCurrentActivity();

if (currentActivity == null && mainActivityRef != null) {
currentActivity = mainActivityRef.get();
}

if (currentActivity == null || splashDialog == null) {
return;
}

final Activity activity = currentActivity;

activity.runOnUiThread(() -> {
boolean isDestroyed = false;

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
isDestroyed = activity.isDestroyed();
}

if (
!activity.isFinishing() &&
!isDestroyed &&
splashDialog != null &&
splashDialog.isShowing()
) {
splashDialog.dismiss();
}

splashDialog = null;
});
}

/* Native util */
public static void show(@NonNull final Activity activity) {
mainActivityRef = new WeakReference<>(activity);

activity.runOnUiThread(() -> {
// Leave out the second argument if you're not using animations
splashDialog = new Dialog(activity, R.style.AppTheme_SplashDialog);
splashDialog.setContentView(R.layout.splash_screen);
splashDialog.setCancelable(false);

if (!splashDialog.isShowing() && !activity.isFinishing()) {
splashDialog.show();
}
});
}
}

The package:

// android/app/src/main/java/com/awesome_app/modules/SplashScreen/SplashScreenPackage.java
package com.awesome_app.modules.SplashScreen;

import androidx.annotation.NonNull;

import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class SplashScreenPackage implements ReactPackage {
@Override
@NonNull
public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactContext) {
return Collections.emptyList();
}

@Override
@NonNull
public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactContext) {
List<NativeModule> modules = new ArrayList<>();

modules.add(new SplashScreenModule(reactContext));

return modules;
}
}

If we want to be able to access the hide method from inside JS, we also need to register our package in the MainApplication:

// android/app/src/main/java/com/awesome_app/MainApplication.java
...
import com.awesome_app.modules.SplashScreen.SplashScreenPackage;
...
@Override
protected List<ReactPackage> getPackages() {
List<ReactPackage> packages = new PackageList(this).getPackages();
packages.add(new SplashScreenPackage());
return packages;
}
...

One last step on the native side. Wee need to call the show method when RN is about to start loading. We can do this by editing the onCreate method of MainActivity:

// android/app/src/main/java/com/awesome_app/MainActivity.java
package com.awesome_app;

import android.os.Bundle;

import com.awesome_app.modules.SplashScreen.SplashScreenModule;
import com.facebook.react.ReactActivity;

public class MainActivity extends ReactActivity {
@Override
protected void onCreate(Bundle savedInstance) {
// Call this before `super.onCreate` if you are using a custom theme for MainActivity
// setTheme(R.style.AppTheme);

SplashScreenModule.show(this);

super.onCreate(savedInstance);
}

/**
* Returns the name of the main component registered from JavaScript. This is used to schedule
* rendering of the component.
*/
@Override
protected String getMainComponentName() {
return "awesome_app";
}
}

The native part is done 🎉

Let’s go to our JS app and hide the dialog after it loads.

React Native

Photo by Facebook on Wikipedia

We can call our hide method when the App component finished mounting by using the useEffects hook.

// App.js
import React, {useEffect} from 'react';
import {
NativeModules,
Platform,
...
} from 'react-native';
...
const App: () => React$Node = () => {
useEffect(() => {
if (Platform.OS === 'android') {
NativeModules.SplashScreenModule.hide();
}
}, []);
return ( ... );
};
...

Run the app and party hard 🤘

--

--