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.
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.
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.
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.
| Methods | Type | Description |
|---|---|---|
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. |
| iOS | Android | Web | |
|---|---|---|---|
| Switch | System alert | Alias swap | No effect |
| Setup | Icon sets | Launcher aliases | None |
| Current | Name or empty | Name or empty | Empty |
| Support | Per device | Per manifest | Never |
setIcon calls reject as busy;
await each before offering the next.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.
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.