One Logo Pool Ball
wwwwwwwwwwwwwwwwwww

App Icon

Let people choose a bundled alternate app icon

One.AppIcon reads and changes the launcher icon. The primary icon and every alternate must be bundled into the binary; an over-the-air JavaScript update can never add icon images to an installed app.

On iOS it calls setAlternateIconName on the shared application. On Android it enables one launcher activity-alias and disables the rest. On web support reads false, the current name is empty, and changing it does nothing.

Usage

import { one } from 'one/vite'
export default {
plugins: [
one({
native: {
app: {
name: 'MyApp',
icon: { source: 'assets/icon.svg', backgroundColor: '#154a9c' },
alternateIcons: {
Blue: { source: 'assets/icon-blue.svg', backgroundColor: '#154a9c' },
Orange: { source: 'assets/icon-orange.svg', backgroundColor: '#d95918' },
},
ios: { bundleId: 'com.example.myapp' },
},
},
}),
],
}

Each source must be square and at least 1024 pixels wide. The alternateIcons keys are the names passed to setIcon (an identifier other than AppIcon or Primary, unique ignoring case). Prebuild creates the iOS icon sets and selects them in the Alternate App Icon Sets build setting, and on Android writes each icon’s launcher images, the launcher aliases and the forwarding activity described below. Rebuild the native app after changing icon configuration.

Dark and tinted icons

iOS 18 draws the home screen icon in dark and tinted appearances. Give icon (or any alternate) a dark and a tinted entry and prebuild writes them into the icon set beside the light one:

icon: {
source: 'assets/icon.svg',
backgroundColor: '#154a9c',
dark: { source: 'assets/icon-dark.svg' },
tinted: {},
}

Each entry is optional and takes its own source and backgroundColor. A missing source reuses the primary one. Dark keeps the source’s transparency unless you give it a backgroundColor, so the system supplies its dark ground. Tinted is flattened to grayscale on black (or its backgroundColor) for the system to tint. Older iOS versions and Android ignore both.

import { One } from 'one'
if (await One.AppIcon.isSupported()) {
const current = await One.AppIcon.getCurrentName()
if (current !== 'Orange') await One.AppIcon.setIcon('Orange')
}

getCurrentName returns the installed icon’s name, or undefined for the primary icon. Passing no name to setIcon restores the primary icon.

list returns the primary icon first and then each alternate, in config order, each with a bundled preview image. Prebuild writes the previews from the same sources as the icons, so a picker needs no second copy of the art:

import { Image, Pressable } from 'react-native'
import { One } from 'one'
function IconChoices() {
return One.AppIcon.list().map(({ name, preview }) => (
<Pressable key={name ?? 'primary'} onPress={() => One.AppIcon.setIcon(name)}>
<Image source={preview} style={{ borderRadius: 14 }} />
</Pressable>
))
}

name is exactly what setIcon takes, so it is undefined for the primary icon. Each preview is a 64 point source with its size set. The list is empty on web and when no alternateIcons are configured.

How it works

iOS shows a system alert on every icon change, so call setIcon from a user action while the app is active. Repeating the current selection returns without another alert. An unknown name rejects with an input error; unavailable switching, an inactive app, concurrent changes, and system failures each reject with their own code.

On Android, prebuild declares one launcher activity-alias for the primary icon (named Primary) and one per alternate, and moves the MAIN/LAUNCHER filter from the real host activity to the primary alias. Each alias targets a small launcher activity that forwards into the permanent host and finishes before React Native renders, because Android finishes activities launched through an alias when that alias is disabled: aliases never target the React Native host directly. The primary alias defaults enabled and each alternate defaults disabled. Pass the alternate’s name, such as Blue, to setIcon; passing no name restores the primary. The launcher’s empty task affinity plus a new-task flag keeps the host task rooted at the main activity. isSupported requires the primary alias plus at least one alternate, and getCurrentName returns undefined for the primary icon. Alternates use legacy launcher images (the primary also takes android.adaptiveIcon when set), so Android draws them without an adaptive mask. Without alternateIcons Android reports unsupported, and restoring the primary rejects. Rebuild after changing icons.

API reference

MethodsTypeDescription
isSupported() => Promise<boolean>Whether icon switching runs here.
() => Promise<boolean>Whether icon switching runs here.
getCurrentName() => Promise<string | undefined>The installed name. Undefined means primary.
() => Promise<string | undefined>The installed name. Undefined means primary.
setIcon(name?: string) => Promise<void>Switch icons. No name restores the primary.
(name?: string) => Promise<void>Switch icons. No name restores the primary.

Platform differences

iOSAndroidWeb
SwitchSystem alertAlias swapNo effect
SetupIcon setsLauncher aliasesNone
CurrentName or emptyName or emptyEmpty
SupportPer devicePer manifestNever

Limits and gotchas

Examples

Offer an icon picker

import { useEffect, useState } from 'react'
import { Image, Pressable } from 'react-native'
import { One } from 'one'
function IconPicker() {
const [current, setCurrent] = useState<string | undefined>(undefined)
const [supported, setSupported] = useState(false)
useEffect(() => {
One.AppIcon.isSupported().then(setSupported)
One.AppIcon.getCurrentName().then(setCurrent)
}, [])
if (!supported) return null
async function pick(name: string | undefined) {
await One.AppIcon.setIcon(name)
setCurrent(await One.AppIcon.getCurrentName())
}
return (
<>
{One.AppIcon.list().map(({ name, preview }) => (
<Pressable
key={name ?? 'primary'}
onPress={() => pick(name)}
style={{ opacity: current === name ? 1 : 0.5 }}
>
<Image source={preview} style={{ borderRadius: 14 }} />
</Pressable>
))}
</>
)
}

Support gates the whole picker, and re-reading after each switch keeps the marked row honest even if the system declines the change.

Restore the primary on sign-out

import { One } from 'one'
async function signOut(clearSession: () => Promise<void>) {
await clearSession()
if (await One.AppIcon.isSupported()) {
await One.AppIcon.setIcon()
}
}

Resetting personalization on sign-out keeps the next account’s first impression stock. The support check keeps web and misconfigured builds quiet.

Edit this page on GitHub.