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
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.
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.
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:
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:
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 inic_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:
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:
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:
getIcon
Get Icon NameresetIcon
Reset IconchangeIcon
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
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 likeReactApplicationContext
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 methodgetIcon
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 methodgetCurrentActivity()
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 byimplements Application.ActivityLifecycleCallbacks
and for this we’ll need to@Overrider
methods likeonActivityPaused
,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 nameMAIN_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 youractiveClass
. COMPONENT_ENABLED_STATE_DISABLED
orCOMPONENT_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 thecompleteIconChange()
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 withcreateNativeModules
andcreateViewManagers
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
.
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.
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
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 asRNChangeIconModule
but for consistency I have addedRNChangeIcon
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 isgetIcon
and it takes a Promise as an argument i.e.resolver
andrejector
.
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, ornil
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’sInfo.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 samecurrentIcon
- 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 namedDefault
from JS side in both iOS and Android cases. So the condition where we’re checkingiconName == nil || .... [iconName isEqualToString:@”Default”]
is checking if the user passDefault
ornil
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 areAppIcon-Premium
andAppIcon-Black
. For default in iOS we need to passnull
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 callgetIcon
when thecurrentIconName
is changed. - Added a
then
to the Promise ofchangeIcon
to trigger a getIcon every-time there is aresolve
.
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:
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:
- 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:
- Go to https://www.autotracer.org/ and convert your exported background image PNG to SVG
- Open the android project in Android studio and right-click on the
drawable
folder thenNew > 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.