Change the App Icon at Runtime for React Native by Creating a NativeModule

A step-by-step process to implement app icon changing on runtime in typescript for iOS and Android react-native app by building our own NativeModule

Chaudhry Talha 🇵🇸
Stackademic

--

I’ve seen this feature in many apps now a days where they give option to user to choose a custom app icon. As a use-case think of this feature as a value added service that reflects good with users and that is why you can see famous apps like Reddit, SnapChat has this features as well.

Image Credits: 9to5mac

In this article, I’ll show how you can change the app icon on runtime smoothly without using any third party library. We will be creating our own Native Module for both iOS and Android for this purpose. So by the end of this tutorial you’ll be learning the following:

  • How to change the app icon on runtime
  • How to create your own NativeModule

This will require understanding of native side of code a little bit but in this article I’ve kept it as simple as I could and it might feel lengthy but it’s very simple process to understand. While there are many libraries available that simplify this process like react-native-change-icon and others but it has some issues specially with android. What we’ll be doing in our NativeModule will be very similar to the react-native-change-icon library but with limitation of android covered.

How we’ll move about this article is we’ll pick Android first and implement Native Module code as well as the app icon change code and then we’ll move to iOS part.

Setting up UI of the screen:

The UI I am making is a single screen app which shows some <TouchableOpacity><Image>… components as shown below:

You can create your own icons and add them in pressable containers and I have chosen a star icon that’ll indicate the current active icon.

I have these different icons for my app, named Default, Black, and Premium.

Three Icons for 40 Day Goals app

Here is how one of the touchable component in my UI:

<TouchableOpacity style={styles.iconButton} onPress={() => { alert('coming soon') }}>
<Image source={require('../assets/app_icon_default.png')} style={styles.iconImage} />
<Star />
</TouchableOpacity>

The PNG stored in assets is just so that you can display the icon image to the user as for icons we’ll have to import these assets again. The <Star /> is an SVG that I have in my app.

After you set your UI then proceed to the next section where we set things on the android side.

Android

Let’s setup everything we need on the android side in 4 steps.

Here is how my newly created react-native project’s android looks like:

It has the default ic_launcher icon pngs

You can use .png images or you can use .xml to render app icon in android. I’ll be using the .xml aka Adaptive launcher icons approach as its a better than legacy launcher icon approach but despite which approach you’re taking the implementation will not have any impact.

Adaptive launcher icons are versatile and can be customised to fit different devices and launchers, while legacy launcher icons are simple and widely supported by older Android devices. Legacy launcher icons are intended for use on devices running Android 7.1 (API level 25) or lower, which don’t support adaptive icons.

https://developer.android.com/studio/write/create-app-icons#launcher

Our Main Approach

On android the recommended approach is to kill the app to change the icon. As in android each icon has it’s own activity name and not killing the app can run into issues like multiple app icons or others. So, we notify user that the app is going to get closed now and they have to manually open the app again.

Please check the EXTRAS section at the end of this article to see the approach of changing android app icon without killing the app

Step 1: Creating Adaptive Launcher Icons

As per the three icons of my app, I have exported the SVG of 40 DAYS text, for the background I’ll use colors in the XML that we’ll create shortly.

Next step is to convert those SVGs into an android vector drawable file. To do that there are many SVG to Android VectorDrawable converters available out there.

Way 1 (Preferred way if working on a real project): Android’s Image Asset Studio provides the ability to convert our SVG/PNG assets to VectorDrawables as well as easy to resize the background and foreground layers.

To proceed with this way I have added an example in the extras section at the end if you want to see how to import assets in VectorDrawable you can refer to that as well. Look for New Image Asset heading)

Way 2 (Preferred way if you’re learning): Using https://inloop.github.io/svg2android/ which works but we’ll have to edit sizing etc. The reason why I’m choosing this to help understand the full scope of what background, foreground layers are in adaptive icons.

Drag and drop your SVG image and it’ll give you the XML code of it. Click on Download and it’ll download a .xml file.

I’ve renamed that file to ic_launcher_40_days_dark_foreground.xml and added it to /android/app/src/main/res/drawable folder:

You can change the values of scaleX, scaleY, translateX, translateY to resize this vector

It is important to have them in 108x108 dpi. Here is a very good explanation of why this 108x108 dpi matters.

Similarly, I have also converted and added the second SVG — > XML that are in my use-case as ic_launcher_40_days_light_foreground.xml.

Now that we have the foregrounds ready we’ll next create three .xml files in drawable folder. I have mine named as:

  • ic_launcher_40_days_default_background.xml
  • ic_launcher_40_days_black_background.xml
  • ic_launcher_40_days_premium_background.xml

and for the default and black case it’s one single color and for premium it’s a gradient. I’ll share the code for ic_launcher_40_days_black_background and you can use the same one with just android:fillColor value change for default and then will show for gradient and in case you have an image you have to add it accordingly.

<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.10546875"
android:scaleY="0.10546875">
<path
android:pathData="M0,0h1024v1024h-1024z"
android:fillColor="#212427"/>
</group>
</vector>

For the ic_launcher_40_days_premium_background.xml here is the code:

<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.2109375"
android:scaleY="0.2109375">
<path
android:pathData="M0,0h512v512h-512z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="212.7"
android:startY="727.02"
android:endX="642.73"
android:endY="301.61"
android:type="linear">
<item android:offset="0" android:color="#3056D3"/>
<item android:offset="1" android:color="#527AFF"/>
</gradient>
</aapt:attr>
</path>
</group>
</vector>

You can update the android:color="#… accordingly to the gradient you need and you can also change values of startX, endX etc to adjust the angle of the gradient.

Now that out background and foregrounds are ready, time to make icons from them. Icon XML files are in mipmap folders. As we’re using adaptive approach so we don’t need to put anything in mipmap-hdpi, mipmap-xhdip etc folders but instead create a new folder name mipmap-anydpi-v26 where the other mipmap folders are:

This folder will be used to store adaptive launcher icon resources, and it is supported on all devices running Android API level 26 or higher hence it has to be named as specified.

Inside this folder create these three files:

  • ic_launcher.xml
  • ic_launcher_black.xml
  • ic_launcher_premium.xml

In each you just need to use the right background and foreground name and the rest of the code is the same. I’m sharing the code of ic_launcher_premium.xml here only:

<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_40_days_premium_background"/>
<foreground android:drawable="@drawable/ic_launcher_40_days_light_foreground"/>
</adaptive-icon>

You might still need to adjust the android:width or might need to <group… the <path… to ensure that you get the right proportions as I did as well in ic_launcher_40_days_dark_foreground.xml.

The best way to find out how the icon is looking is by opening the ic_launcher_premium.xml or other files in mipmap-anydpi-v26 folder in Android Studio:

If you run the app it should render the default icon for app. When I did I can see:

This is how you can render a perfect adaptive launcher icon. The ic_launcher.png in the mipmap-... folders are still there and for the lesser versions it’ll render. The key is to keep the names same like I did for ic_launcher.xml and the default icon mentioned in the AndroidManifest.xml is also ic_launcher.

If you’re using Legacy launcher icons approach you need to have PNGs of the icons in all square and circle sizes. Then just keep extra attention to the naming of the icons. You’ll not be doing anything in the drawable folder and will add the PNGs in the mipmap-... folders accordingly. Let’s more to the next part which is the modifications in AndroidManifest.xml file.

Step 2: Modifying AndroidManifest.xml

You’ll need a basic understanding of how Android reads the AndroidManifest.xml file. Here is mine:

AndroidManifest.xml file

In the above file we have <application... that has some properties like android:icon=... and android:roundIcon=... this is where we are defining the names of our icons. If I change the android:icon="@mipmap/ic_launcher" to android:icon="@mipmap/ic_launcher_premium" and it’ll render the premium icon instead. Inside we have <activity android:name=".MainActivity" which is the main “app” that react-native runs when the app runs on android.

Remove or comment out the lines of code as shown in the image below from the main <activity android:name=".MainActivity"....

To understand what android.intent.category.LAUNCHER or other means have a look at this https://stackoverflow.com/a/52009982/4337240 or the image below:

https://stackoverflow.com/a/52009982/4337240

We have commented out the MAIN and LAUNCHER from our main activity as our we’ll launch an alias activity instead i.e. Default, Black, or Premium activity, so let’s create that next.

Now, for each icon you want to display, create <activity-alias>. We’ll add three <activity-alias> after the …".MainActivity"...</activity> ends:

      <!-- Default Icon -->
<activity-alias
android:name="${applicationId}.MainActivityDefault"
android:enabled="true"
android:exported="true"
android:icon="@mipmap/ic_launcher"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

<!-- Black Icon -->
<activity-alias
android:name="${applicationId}.MainActivityBlack"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_black"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

<!-- Premium Icon -->
<activity-alias
android:name="${applicationId}.MainActivityPremium"
android:enabled="false"
android:exported="true"
android:icon="@mipmap/ic_launcher_premium"
android:targetActivity=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity-alias>

Here is how my AndroidManifest.xml looks now:

An important thing to notice in the code you just added; The <activity-alias with property android:enabled="true" should only be one of all the aliases you’ll have. In my case I gave it to .MainActivityDefault and the black and premium icons are false and later when we’ll implement the NativeModule then you’ll change it so that is why by default the default activity is true and the rest are false. You cannot test this yet, to test you have to finish step 3 first.

You have successfully finished exporting, importing and configuring icons both adaptive and legacy way and created the aliases for them in our main AndroidManifest.xml.

Step 3: Creating Icon Change NativeModule for Android

We want to handle the following three functionalities in our native module:

  1. getIcon Get Icon Name
  2. resetIcon Reset Icon
  3. changeIcon Change Icon

Create a new folder in /android/app/src/main/java/com/<YOUR_PROJECT_NAME>/ named JSBridge as shown below, and inside this folder create two files named:

  • RNChangeIconModule.java
  • RNChangeIconPackage.java
Screenshot of files tree in VS Code — Creating a new Java class in Android Studio — Screenshot of files tree in Android Studio

It’s usually a convention that we add RN as prefix to indicate that this will be called from react-native.

getIcon

We’ll start with getIcon as it’s simply going to return me the name of the current active icon i.e. premium, black, default as per the UI. So in RNChangeIconModule.java add this:

package com.<YOUR_APP_PACKAGE>.JSBridge;

import android.app.Activity;

import androidx.annotation.NonNull;

import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.module.annotations.ReactModule;

@ReactModule(name = RNChangeIconModule.NAME)
public class RNChangeIconModule extends ReactContextBaseJavaModule {
public static final String NAME = "RNChangeIcon";
public static final String MAIN_ACTVITY_BASE_NAME = ".MainActivity";
private final String packageName;
private String componentClass = "";
// Constructor
public RNChangeIconModule(ReactApplicationContext reactContext, String packageName) {
super(reactContext);
this.packageName = packageName;
}

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

@ReactMethod
public void getIcon(Promise promise) {
final Activity activity = getCurrentActivity();
if (activity == null) {
promise.reject("ACTIVITY_NOT_FOUND", "Activity was not found");
return;
}

final String activityName = activity.getComponentName().getClassName();

if (activityName.endsWith(MAIN_ACTVITY_BASE_NAME)) {
promise.resolve("Default");
return;
}
String[] activityNameSplit = activityName.split("MainActivity");
if (activityNameSplit.length != 2) {
promise.reject("ANDROID:UNEXPECTED_COMPONENT_CLASS:", this.componentClass);
return;
}
promise.resolve(activityNameSplit[1]);
return;
}

}

In the above file we have done the following:

  • We extend it with ReactContextBaseJavaModule that provides us things like ReactApplicationContext which is useful for Native Modules that need to hook into activity lifecycle methods.
  • The String NAME = "RNChangeIcon"; is the main name we’ll be calling this native module from. For example right now the above can be accessed in JS by simply doing; const { RNChangeIcon } = ReactNative.NativeModules; but we’ll test if our native module is working or not shortly.
  • I have also created a variable named MAIN_ACTVITY_BASE_NAME in which I have stored the name of the base activity because if you see the <activity-intent... names we did in AndroidManifest in step 2, there names are like .MainActivityDefault, .MainActivityPremium etc, so we add .MainActivity as a base or prefix for all our activities.
  • The @ReactModule annotation creates the bridge that allows you to call this module from JS using the name specified i.e. RNChangeIcon
  • The @ReactMethod annotation makes the next line which is our first method getIcon which takes a promise same as a JS-Promise for it’s parameter. This is how we implement a method that we want to call at JS side.
  • Inside the getIcon I have called a built-in method getCurrentActivity() which fetches the name of the current activity. Similar to this there are .getComponentName().getClassName() methods that fetches the names of the current class and component. This implementation is very similar to the getIcon method of the react-native-change-icon library.

changeIcon

After the getIcon method ends add the changeIcon method as follows:

package com.fortydaygoals.JSBridge;

import android.app.Activity;
import android.app.Application;
import android.content.ComponentName;
import android.content.pm.PackageManager;
import android.os.Bundle;

import androidx.annotation.NonNull;

import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.module.annotations.ReactModule;

import java.util.HashSet;
import java.util.Set;

@ReactModule(name = RNChangeIconModule.NAME)
public class RNChangeIconModule extends ReactContextBaseJavaModule implements Application.ActivityLifecycleCallbacks {

private final String packageName;

public static final String NAME = "RNChangeIcon";
public static final String MAIN_ACTVITY_BASE_NAME = ".MainActivity";

private String componentClass = "";
private final Set<String> classesToKill = new HashSet<>();

// ... getIcon, Constructor, and getName methods

@ReactMethod
public void changeIcon(String iconName, Promise promise) {

final Activity activity = getCurrentActivity();

if (activity == null) {
promise.reject("ACTIVITY_NOT_FOUND", "The activity is null. Check if the app is running properly.");
return;
}
if (iconName.isEmpty()) {
promise.reject("EMPTY_ICON_STRING", "Icon name is missing i.e. changeIcon('YOUR_ICON_NAME_HERE')");
return;
}
if (this.componentClass.isEmpty()) {
this.componentClass = activity.getComponentName().getClassName(); // i.e. MyActivity
}

final String newIconName = (iconName == null || iconName.isEmpty()) ? "Default" : iconName;
final String activeClass = this.packageName + MAIN_ACTVITY_BASE_NAME + newIconName;

if (this.componentClass.equals(activeClass)) {
promise.reject("ICON_ALREADY_USED", "This icons is the current active icon. " + this.componentClass);
return;
}

try {
activity.getPackageManager().setComponentEnabledSetting(
new ComponentName(this.packageName, activeClass),
PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
PackageManager.DONT_KILL_APP
);
promise.resolve(newIconName);
} catch (Exception e) {
promise.reject("ICON_INVALID", e.getLocalizedMessage());
return;
}

this.classesToKill.add(this.componentClass);
this.componentClass = activeClass;
activity.getApplication().registerActivityLifecycleCallbacks(this);
// The completeIconChange() is what makes the current active class disabled.
// Move it to onActivityPaused or onActivityStopped etc to change the icon only when the app closes or goes to background
completeIconChange();
}

private void completeIconChange() {
final Activity activity = getCurrentActivity();
if (activity == null) return;

// Works for minSdkVersion = 23
for (String className : classesToKill) {
activity.getPackageManager().setComponentEnabledSetting(
new ComponentName(this.packageName, className),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP
);
}
/*
// Works for minSdkVersion = 24 and above
classesToKill.forEach((cls) -> activity.getPackageManager().setComponentEnabledSetting(
new ComponentName(this.packageName, cls),
PackageManager.COMPONENT_ENABLED_STATE_DISABLED,
PackageManager.DONT_KILL_APP
));
*/
classesToKill.clear();
}

@Override
public void onActivityPaused(Activity activity) {
}

@Override
public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
}

@Override
public void onActivityStarted(Activity activity) {
}

@Override
public void onActivityResumed(Activity activity) {
}

@Override
public void onActivityStopped(Activity activity) {
}

@Override
public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
}

@Override
public void onActivityDestroyed(Activity activity) {
}
}

In the code above we have done the following:

  • Create a variable named classesToKill which will hold a HashSet (A HashSet is a collection that stores unique elements in no particular order) object.
  • Then for changeIcon we need to implement the lyfecycle callbacks of Android by implements Application.ActivityLifecycleCallbacks and for this we’ll need to @Overrider methods like onActivityPaused, onActivityCreated etc as shown in the code.
  • In the try {... block we’re attempting to update the icon after fetching the names of activity and package names etc. Here you’ll see why we have stored a base name MAIN_ACTVITY_BASE_NAME so that we can standardise the ActivityNames for ease of purpose otherwise you can use your own logic as long as you select the right name for your activeClass.
  • COMPONENT_ENABLED_STATE_DISABLED or COMPONENT_ENABLED_STATE_ENABLE is a flag that can be used to disable a component, such as an activity, receiver, service, or provider. When a component is disabled, it cannot be launched or used by the system.
  • After the new activity is enabled, we add the current activity in classesToKill and then registers this new activity to call the completeIconChange() method which will kill the current activity.

resetIcon

For our final method that we need named resetIcon we can simply call changeIcon("Default") from the JS side, which will revert it back to the .MainActivityDefault. So, no need to implement that in Android side.

Registering our RNChangeIconModule module (Android Only)

Now as a final step we need to register our module and requires two things. First add this code in RNChangeIconPackage.java

package com.<YOUR_APP_PACKAGE>.JSBridge;

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 RNChangeIconPackage implements ReactPackage {

private final String packageName;
// Constructor
public RNChangeIconPackage(String packageName) {
this.packageName = packageName;
}

@NonNull
@Override
public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactApplicationContext) {
List<NativeModule> modules = new ArrayList<>();
modules.add(new RNChangeIconModule(reactApplicationContext, this.packageName));
return modules;
}

@NonNull
@Override
public List<ViewManager> createViewManagers(@NonNull ReactApplicationContext reactApplicationContext) {
return Collections.emptyList();
}
}

In the above file we have done the following:

  • You need to implement the ReactPackage interface which comes with createNativeModules and createViewManagers methods we have to override.
  • We have added our module i.e. RNChangeIconModule in the list of all the NativeModules. We’re only working with one you can have more native modules for other libraries so you have to add them to a global list of NativeModules.

Second open your /android/app/src/main/java/com/<YOUR_PACKAGE_NAME>/MainApplication.java and there will be a protected List<ReactPackage> getPackages()... method. Inside this method after this line List<ReactPackage> packages = new PackageList(this).getPackages(); add:

packages.add(new RNChangeIconPackage(getPackageName()));

The getPackageName() is a built-in method that fetches the package name of the android app.

Step 4: Consume NativeModule on JS Side

Time to test this so we’ll start with testing the changeIcon method. In my TestScreen.tsx (the UI shared at the start of this article) file I did:

import { NativeModules } from 'react-native';
//... Other imports

function TestScreen() {

const { RNChangeIcon } = NativeModules;

return (
<>
<TouchableOpacity
style={styles.linkButton}
onPress={() => {
Alert.alert('Note',
"As you're using andoird so the app will exit and update the icon. Tap on the new icon to open the app again.",
[
{
text: 'Okay', onPress: () => {
RNChangeIcon.changeIcon('Default').then().catch((e: { message: string }) => alert(e.message))
}
},
{
text: 'Not now',
}
]
);
}}
>
<Image source={require('../assets/app_icon_default.png')} style={styles.iconImage} />
</TouchableOpacity>
//... Rest of the UI and other icon buttons

My Icon names are 'Premium' , 'Black’, and 'Default'. so I have three buttons like this. On each button onPress I’m simply providing the name and it’s changing accordingly.

Here is how it shows when I click on an icon to change. My current icon was default and I was successfully able to change it to premium:

Now to move the star indicator to the current active icon we’re going to use the other NativeMethod we had i.e. getIcon. In my TestScreen.tsx add:

import { NativeModules } from 'react-native';
//... Other imports

function TestScreen() {

const { RNChangeIcon } = NativeModules;

const iconNames = {
DEFAULT: "Default",
BLACK: "Black",
PREMIUM: "Premium",
}

const [currentIconName, setCurrentIconName] = useState('');

async function getIcon() {
const icon = await RNChangeIcon.getIcon();
setCurrentIconName(icon);
console.log(icon);
}

useEffect(() => {
getIcon();
}, [currentIconName])

return (
<>
<TouchableOpacity
style={styles.linkButton}
onPress={() => {
Alert.alert(...);
}}
>
<Image source={require('../assets/app_icon_default.png')} style={styles.iconImage} />
{currentIconName === iconNames.DEFAULT ? <Star /> : null}
</TouchableOpacity>
//... Rest of the UI and other icon buttons

The above returns Default, Black, or Premium then you can use this to render the star indicator accordingly. Here is how I did it:

🎉 🎉 🎉 You have successfully implemented custom icon change on runtime using NativeModule from react-native for android app.

iOS

For iOS we’ll do the same 4 steps process to implement all the required things. We need PNGs of the icon in all sizes. We’ll also do one less thing for NativeModules for iOS and so on.

Step 1: Icon assets for iOS

For iOS we will need PNGs in all sizes. You can use any app icon generator websites where you can submit 1024x1024 size image and export icons in sizes for iOS in this case but you can also do for android if you’re using the legacy approach.

When you have the icon PNGsof all three icons generated, go inside the Assets.xcassets folder and rename the AppIcons.appiconset files to -Black and -Premium postfix. The Default icons will remain as it is i.e. AppIcon.appiconset as shown below:

Now open your iOS project in XCode and drag the three folders in Images:

Step 2: Adding app icon sets to Info.plist

There is a cleaner way to do this using XCode’s Asset Catalog Compiler. Read More
Please refer to
Extras & Common Issues section at the end of this tutorial to see how to do it. The approach I have taken here is mostly used in all over the tutorials you’ll find on the internet.

Open your Info.plist as source code and add this code:

<!-- Cuustom App Icons  -->
<key>CFBundleIcons</key>
<dict>
<key>CFBundleAlternateIcons</key>
<dict>
<key>Black</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon-Black</string>
</array>
<key>UIPrerenderedIcon</key>
<false/>
</dict>
<key>Premium</key>
<dict>
<key>UIPrerenderedIcon</key>
<false/>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon-Premium</string>
</array>
</dict>
</dict>
<key>CFBundlePrimaryIcon</key>
<dict>
<key>CFBundleIconName</key>
<string>AppIcon</string>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon</string>
</array>
<key>UIPrerenderedIcon</key>
<false/>
</dict>
<key>UINewsstandIcon</key>
<dict>
<key>CFBundleIconFiles</key>
<array>
<string>AppIcon</string>
</array>
<key>UINewsstandBindingType</key>
<string>UINewsstandBindingTypeMagazine</string>
<key>UINewsstandBindingEdge</key>
<string>UINewsstandBindingEdgeLeft</string>
</dict>
</dict>
<!-- Cuustom App Icons END -->

The above code is overriding the value of default and alternate icons. If you want to add a new icon you can just add that as Black or Premium is added above, and the default in the case of iOS keep AppIcon.

Side by side source code and property list we added in Info.plist file

A side note for the Newsstand Icon please read this https://apple.fandom.com/wiki/Newsstand.

TLDR; Remove the newstand icons if your app has nothing to do with newsstand feature.

As per my knowledge apps today don’t need it because the Newsstand app was removed from iOS in 2015 and from macOS in 2018. Apple might reject your app if you have the above icon mentioned without having a UINewsstandApp=true in your Info.plist file, so if your app still supports that ONLY then you’ll need to have the newsstand icons.

App rejection for an app because of newstand icon

The reason of including the newstand icon in this tutorial is to give a complete picture of the App Icon query, as my target is to cover as many aspects of the topic in this one place as I can. So as the newsstand is still supported by the Apple today but is no longer a part of any OS in their system so I have included it.

Next go to project General settings and scroll down to turn ON the Include all app icon assets

That’s all for this step. Lets create a NativeModule next.

Step 3: Creating Icon Change NativeModule for iOS

Same as we did for Android, let’s create a new folder named JSBridge and inside add these two files:

  • RNChangeIconModule.m
  • RNChangeIconModule.h
Add a new Objective-C File
Adding the Header File
The default RNChangeIconModule.m & RNChangeIconModule.h files

Now in the RNChangeIconModule.h file add:

#import <React/RCTBridgeModule.h>
#import <UIKit/UIKit.h>

@interface RNChangeIconModule : NSObject <RCTBridgeModule, UIApplicationDelegate>

@end

The RCTBridgeModule protocol provides functionality for communicating between React Native and native code, and the UIApplicationDelegate protocol provides functionality for handling application lifecycle events.

getIcon

We’ll start with getIcon as it’s simply going to return me the name of the current active icon i.e. premium, black, default as per the UI. So in RNChangeIconModule.m add this:

#import "RNChangeIconModule.h"

@implementation RNChangeIconModule
RCT_EXPORT_MODULE(RNChangeIcon); // The name we'll be using at JS side

+ (BOOL)requiresMainQueueSetup {
return NO;
}

RCT_REMAP_METHOD(getIcon, resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
dispatch_async(dispatch_get_main_queue(), ^{
NSString *currentIcon = [[UIApplication sharedApplication] alternateIconName];
// Return the value as is.
// - string: alternate app icon
// - nil: primary (a.k.a. AppIcon) app icon
if (currentIcon) {
resolve(currentIcon); // AppIcon-Black, AppIcon-Premium
} else {
resolve(@"AppIcon");
}
});
}

@end

In the above code we’re doing the following:

  • The RCT_EXPORT_MODULE is no name is given will be accessible at JS side as RNChangeIconModule but for consistency I have added RNChangeIcon string literal. (yes without ""read more here).
  • requiresMainQueueSetup is a Boolean property that native modules in React Native can implement to indicate whether they need to be initialized on the main thread.
  • The RCT_REMAP_METHOD is the equivalent of @ReactMethod annotation which we did in android. It’s name is getIcon and it takes a Promise as an argument i.e. resolver and rejector.
    Here is a great resource to see examples and documentations of most iOS and Android annotations.
  • The alternateIconName property in [[UIApplication sharedApplication] alternateIconName] returns the name of the alternate app icon that is currently being used, or nil if the default app icon is being used. When the system is displaying one of your app’s alternate icons, the value of this property is the name of the alternate icon (from your app’s Info.plist file).
  • We return "AppIcon” if we get nil which is the default name of AppIcon.

You can see in the Step 4 where I have consumed getIcon for iOS to see the above in action. For now make sure the code you’re adding doesn’t have any error and compiles successfully on iOS side.

changeIcon

After the getIcon ends add this code:

RCT_REMAP_METHOD(changeIcon, iconName:(NSString *)iconName resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) {
dispatch_async(dispatch_get_main_queue(), ^{
NSError *error = nil;

if ([[UIApplication sharedApplication] supportsAlternateIcons] == NO) {
reject(@"Error", @"IOS:NOT_SUPPORTED", error);
return;
}

NSString *currentIcon = [[UIApplication sharedApplication] alternateIconName];

if ([iconName isEqualToString:currentIcon]) {
reject(@"Error", @"IOS:ICON_ALREADY_USED", error);
return;
}

NSString *newIconName;
if (iconName == nil || [iconName length] == 0 || [iconName isEqualToString:@"Default"]) {
newIconName = nil;
resolve(@"Default");
} else {
newIconName = iconName;
resolve(newIconName);
}

[[UIApplication sharedApplication] setAlternateIconName:newIconName completionHandler:^(NSError * _Nullable error) {
return;
}];
});
}

Here is what we’re doing in the code above:

  • Same as in getIcon we’re getting hold of the main thread.
  • Then we have bunch of checks for empty iconName or same currentIcon
  • In iOS we need to submit nil as iconName in order to set it back to default. So, for consistency we’ll pass an enum named Default from JS side in both iOS and Android cases. So the condition where we’re checking iconName == nil || .... [iconName isEqualToString:@”Default”] is checking if the user pass Default or nil it should work in both cases, to set the icon to default icon in iOS.
  • Main method here is setAlternateIconName which is setting the alternate icon. Now for this the names have to be the names you set in .appiconset which are AppIcon-Premium and AppIcon-Black. For default in iOS we need to pass null to set it back to default one.

You can keep the same names as for android i.e. Premium, Black, Default for consistency but since there are two different methods to add icon assets for iOs & Android I prefer this way, as it clearly distinguish between icons in both OS. For consumption see Step 4 where I have used these methods without using Platform from react-native which you can use to pick the proper iconName is cases of ios or android.

Step 4: Consume NativeModule on JS Side

Let’s start with changeIcon first and then we’ll see how we can getIcon to set the star indicator accordingly.

changeIcon

Here is the code for the button which calls to change the app icon to Premium

import React, { useState } from 'react';

//... other imports

function TestScreen() {

const iconNames = {
DEFAULT: null,
BLACK: "AppIcon-Black",
PREMIUM: "AppIcon-Premium",
}

const { RNChangeIcon } = NativeModules;
const [currentIconName, setCurrentIconName] = useState('');

return (
<>
{/* ...Other UI... */}
<TouchableOpacity style={styles.linkButton} onPress={() => {
RNChangeIcon.changeIcon(iconNames.PREMIUM).then().catch((e: { message: string }) => alert(e.message))
}}>
<Image source={require('../assets/app_icon_premium.png')} style={styles.iconImage} />
{currentIconName === iconNames.PREMIUM ? <Star /> : null}
</TouchableOpacity>
{/* ...Other UI... */}
</>
)}

export default TestScreen;

In the code above I have the three iconNames according to iOS as on android they were DEFAULT: "Default", BLACK: "Black", PREMIUM: "Premium". The names are most of the times the main reason of why the icons are not changing to make sure you use the right names based on the Platform.

On iOS there is a system generated Alert that tells the user that the icon is updated as shown in the GIF below.

There are ways to show your own Alert but I wouldn’t recommend that it would be the equivalent of trying to overwrite the default location Alert that iOS shows. But if you’re still looking for a way here is a way https://stackoverflow.com/a/49730130 or you can scroll down to Extras section of this article and see how to implement it.

getIcon

Now we’ll use getIcon to change the star to indicate the current active icon. Here is the code:

import React, { useState, useEffect } from 'react';

//... other imports

function TestScreen() {

const iconNames = {
DEFAULT: null,
BLACK: "AppIcon-Black",
PREMIUM: "AppIcon-Premium",
}

const { RNChangeIcon } = NativeModules;
const [currentIconName, setCurrentIconName] = useState('');

async function getIcon() {
const icon = await RNChangeIcon.getIcon();
setCurrentIconName(icon);
// console.log(icon);
}

useEffect(() => {
getIcon();
}, [currentIconName])

return (
<>
{/* ...Other UI... */}
<TouchableOpacity style={styles.linkButton} onPress={() => {
RNChangeIcon.changeIcon(iconNames.DEFAULT).then(() => {
getIcon();
}).catch((e: { message: string }) => alert(e.message))
}}>
<Image source={require('../assets/app_icon_default.png')} style={styles.iconImage} />
{currentIconName === iconNames.DEFAULT ? <Star /> : null}
</TouchableOpacity>
{/* ...Other UI... */}
</>
)}

export default TestScreen;

In the above code we did two things:

  • Added a useEffect to call getIcon when the currentIconName is changed.
  • Added a then to the Promise of changeIcon to trigger a getIcon every-time there is a resolve.

resetIcon

I have used Default in the above example and it’s value is null which is how iOS will reset the icon.

Congratulations on making it to the end. If you have any questions or are stuck on a step feel free to connect on LinkedIn and ask.

If you find this article useful do press 👏👏👏 so that others can also find this article.

Like to support? Scan the QR to go to my BuyMeACoffee profile:

https://www.buymeacoffee.com/chaudhrytalha

Want to see app icon change in action? Download 40 Day Goals app from play store: https://play.google.com/store/apps/details?id=com.ibjects.fortydaygoals

Extras & Common Issues

Here are some additional things you can do with change icon on runtime for react-native app.

How to add iOS assets using XCode’s Asset Catalog Compiler?

Make sure you haven’t added anything in the Info.plist regarding the alternate app icon as we added in the iOS step 2. If you have remove all that code.

After you have added all the icon assets to your iOS Images.xcassets then you can select your project Targets → Build Settings and search for Asset Catalog Compiler - Options in which you’ll find Alternate App Icon Sets.

Add alternate icon names in the Alternate App Icon Sets and then in Primary App Icon Set Name should be the default icon.

This will automatically add what is needed to be added in Info.plist and you don’t have to manually add anything there, and this is a preferred method and has less chance of human error.

How to overwrite the default dialog for change icon on iOS?

According to the stack overflow link given below there is a way where if you add this method below, it’ll basically overwrite the setAlternateIconName and change the icon without an alert as the setAlternateIconName method does not overwrite the default alert that is displayed to the user when the app icon is changed. The default alert is displayed by a private method of the UIApplication class.

- (void)setAlternateIconName:(NSString *)iconName completionHandler:(nullable void (^)(NSError *_Nullable error))completionHandler {
if ([[UIApplication sharedApplication] respondsToSelector:@selector(supportsAlternateIcons)] &&
[[UIApplication sharedApplication] supportsAlternateIcons])
{
NSMutableString *selectorString = [[NSMutableString alloc] initWithCapacity:40];
[selectorString appendString:@"_setAlternate"];
[selectorString appendString:@"IconName:"];
[selectorString appendString:@"completionHandler:"];

SEL selector = NSSelectorFromString(selectorString);
IMP imp = [[UIApplication sharedApplication] methodForSelector:selector];
void (*func)(id, SEL, id, id) = (void (*)(id, SEL, id, id))imp;
if (func)
{
func([UIApplication sharedApplication], selector, iconName, completionHandler);
}
}
}

//... Mode other code
// RCT_REMAP_METHOD(changeIcon...
// Replace [[UIApplication sharedApplication] setAlternateIconName:n... with
// With [self setAlternateIconName:newIconName completionHandler:^(NSError * _Nullable error) {..

I haven’t tested it, but you can explore more from this point on how to overwrite the default icon.

How to change android app icon without killing the app?

As there are many android models available and each has it’s own processing speed etc. This technique is not recommended due to the same reason as we cannot be 100% sure that it’ll work exactly as it works on emulator or in release hence we choose the KILL the app approach. Using this tutorial you can easily switch between both modes by simply moving completeIconChange method to a different place in the RNChangeIconModule.java file. Here is how:

Open the RNChangeIconModule.java and move the call to completeIconChange() into these methods which we are overriding in RNChangeIconModule because of the Application.ActivityLifecycleCallbacks:

//... More Code
this.classesToKill.add(this.componentClass);
this.componentClass = activeClass;
activity.getApplication().registerActivityLifecycleCallbacks(this);
// completeIconChange(); // Remove or comment this
}

@Override
public void onActivityPaused(Activity activity) {
completeIconChange(); // To change icon when app goes to BG.
}

@Override
public void onActivityStopped(Activity activity) {
completeIconChange(); // To change icon when app is stopped.
}

@Override
public void onActivityDestroyed(Activity activity) {
completeIconChange(); // This then makes the current active class disabled
}

This runs the completeIconChange function when the app goes to background or is stopped or terminated for any-reason. So, this gives you a NON-KILL option. But as shown below it takes about 1–2 seconds might take 3–4 on some devices so better to know what is your use-case and then you can choose accordingly. Here is the test I did with the above changes, and after this sharing I’ll share the observations I have in using this approach:

Not to kill app for android app change icon in react-native
  • If the change icon is clicked multiple times it’ll create multiple icons and then will take 1–3 seconds to remove the older activities when the app enters background, stops or terminated,
  • If an icon is already changed from JS again in the same session it’ll show a prompt which I have added message as “Failed” but the message reads that the icon is already active, so handle this error accordingly.
  • If you have 10 or more icons it’s worth to try with different ways to make sure it should work as expected.

However, this approach is not recommended because as per my experience if user say change icon simultaneously meaning they change icons one after another and then close the app there are behaviours like the merging of icon on pause is not efficient and often times leave you with multiple app icons or app bundle getting corrupted I suggest to test it on your release build too on different android devices like pixel, oppo, and others before getting the feature out there to the public.

What if I have deep linking in my android app?

If you have Deep links in your app keep it’s intent main <activity and not in any <activity-alias because eventually its targetActivity is .MainActivity and it should work as usual.

Issue: Android app Gets stuck on splash screen after icon is changed and the app exits successfully

If you face this issue on android build that the first opening of app after the change icon, makes it stuck on splash screen and you have to force quit that instance and then on second start it starts fine.

This happens because the code doesn’t fully kill all the processes that were running from you app, so if you have any background thread running or any sort of code that might be the reason for this.

It’s solution is RNChangeIconModule.java file we have an overiding method named onActivityDestroyed add the following line of code in it:

android.os.Process.killProcess(android.os.Process.myPid());

This makes sure the app completely kills all the processes and should solve this issue. More Context and here is why it’s not a good idea to use it, check last comment on this answer: https://stackoverflow.com/a/11082028/4337240

How to convert PNG to SVG to VectorDrawable using Android Image Assets Studio?

There are two ways:

  • New Vector Asset
  • New Image Asset

New Vector Asset

I’m going to use an example of a PNG image, which we’ll use as a background of the icon and then same ic_launcher_40_days_light_foreground.xml as a foreground of that the new icon I’m making in this article. So here are the first two step:

  1. Go to https://www.autotracer.org/ and convert your exported background image PNG to SVG
  2. Open the android project in Android studio and right-click on the drawable folder then New > Vector Asset.

3. In the Asset Studio select Local file (SVG, PSD) and then select the SVG from finder and give it 108x108 in size and keep the opacity to 100%. Click Next and then Finish to create the file. You can vide the file you just added in the preview.

Now you’ll need to go to the mipmap-anydpi-v26 folder and create a new .xml file and name it ic_launcher_matrix.xml and add this code in it:

<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/app_icon_matrix_background"/>
<foreground android:drawable="@drawable/ic_launcher_40_days_light_foreground"/>
</adaptive-icon>

So this is how now only can you add Image as a BG of the image but also you have a new icon that now you’ll have to add an activity-alias and then make sure you pass the right name from JS side and it’ll work as others.

New Image Asset

This is the most preferred way. Similar to the above you can also add an Image Asset which basically creates foreground, background and PNGs for both legacy and adaptive icons.

After that a window will open where you can select your .svg and .png as backgrounds and foregrounds. Here is mine:

Notice how it’s very easy for the foreground and background to be resized accordingly to the perfect preview. Then press Next and then Finish and you’ll have VectorDrawables for your icon background, foreground and can easily resize it.

Idea for locking some icons for Premium users?

To lock an icon from changing all work has to be done on the JS side. Where you check for user premium or not status and call the changeIcon method accordingly.

You can also add a useEffect on main screen that checks for this every time or other ways on a root level so that the app can change icon if user’s status changes from premium to non-premium.

Basic Source Code

Since I didn’t had any Github so I created an app on code-sandbox and placed the files accordingly in the folders as they are in my react-native project.

--

--