The Android SplashScreen API in 2026: cold start without the white flash
A practical guide to Android's SplashScreen API — theme setup, the animated icon size limits nobody reads, and keepOnScreenCondition for data that isn't ready yet.
Every Android app has shown a splash screen since Android 12, whether you asked for it or not. The system draws one automatically from your app icon and theme colors the instant the user taps the launcher — the only choice you actually get is whether to accept the default or configure androidx.core.splashscreen to draw the one you meant to ship. Most of the bad splash screens I’ve seen aren’t bad because someone designed them badly. They’re bad because nobody configured them at all, and the system’s best guess filled the gap.
I hit every mistake in this post building Granyn, whose dashboard needs a first balance figure loaded from Room before the first frame is worth showing. This is the setup that stopped the white flash and stopped the splash from hanging around a beat too long.
Why you get a splash screen whether you want one or not
Starting with Android 12 (API 31), the platform intercepts every cold start and shows a system-drawn splash window before your first Activity even attaches its content view. This isn’t a library feature you can skip — it’s default framework behavior. What androidx.core.splashscreen gives you is a compatibility shim that lets you configure that system window consistently back to API 23, instead of getting the real thing on 12+ and a plain white or black rectangle on everything older.
Skip the setup and you get the system’s default: your launcher icon on a solid color pulled from your theme, no animation, no control over how long it holds. That’s not broken, but it’s rarely what a branded app wants, and on pre-12 devices without the compat library it can be a jarring flash of white before your theme even applies.
Wiring the theme
The splash is configured almost entirely in XML, as a theme applied before setContentView runs:
<!-- res/values/themes.xml -->
<style name="Theme.App.Starting" parent="Theme.SplashScreen">
<item name="windowSplashScreenBackground">@color/brand_background</item>
<item name="windowSplashScreenAnimatedIcon">@drawable/splash_icon</item>
<item name="windowSplashScreenAnimationDuration">500</item>
<item name="postSplashScreenTheme">@style/Theme.App</item>
</style>
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
setContent { AppTheme { GranynApp() } }
}
}
installSplashScreen() has to run before super.onCreate() — call it after and the compat library can’t intercept the window in time, and you’re back to the system default silently. postSplashScreenTheme is the theme the window switches to once the splash is dismissed; forgetting to set it leaves your Activity briefly stuck rendering with splash-window attributes applied to real content.
The icon size limit nobody reads until it bites
windowSplashScreenAnimatedIcon has a hard constraint: the drawable is rendered inside a 240×240dp circle, and anything larger gets silently cropped rather than scaled. This is the single most common splash bug I see — someone reuses the adaptive launcher icon, which is designed for a 108×108dp foreground layer inside a 72×72dp visible circle, and it either overflows past the splash boundary or gets clipped in a way that looks broken on exactly one screen density.
The fix is a dedicated splash icon, sized and centered for the 240dp constraint on its own, not a repurposed launcher asset:
res/drawable/splash_icon.xml (single vector, no adaptive layers, fits 240x240dp)
If the icon needs to be animated (an AnimatedVectorDrawable), the same size ceiling applies, and windowSplashScreenAnimationDuration caps how long the platform waits for it — 1000ms max on API 31+, though the compat library on older APIs doesn’t enforce that ceiling the same way, which is its own source of inconsistent testing if you only check one OS version.
When the data isn’t ready: keepOnScreenCondition
The part that actually mattered for Granyn wasn’t the icon — it was timing. The splash screen dismisses as soon as the first frame is ready to draw, which for a Compose screen can be before the ViewModel has anything to show. Left alone, that produces a flash of an empty dashboard between the splash disappearing and the balance loading in half a second later — worse than the splash just staying up that extra beat.
SplashScreen.setKeepOnScreenCondition holds the splash window open until a condition clears:
class MainActivity : ComponentActivity() {
private val viewModel: DashboardViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
val splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
splashScreen.setKeepOnScreenCondition {
!viewModel.isReady.value
}
setContent { AppTheme { GranynApp(viewModel) } }
}
}
isReady here is a StateFlow<Boolean> that flips true once the first Room query for the dashboard’s balance actually returns. The condition is polled on every frame, so keep it a cheap boolean read — never a suspend call or a database hit directly inside the lambda.
Two things make this safe rather than a way to hang the app indefinitely. First, set a ceiling — if isReady never flips (a corrupt database, a query that hangs), the splash should time out and let the UI show its own loading or error state rather than staying frozen forever:
private val isReady = MutableStateFlow(false)
init {
viewModelScope.launch {
withTimeoutOrNull(2000) {
dashboardFlow.first { it != null }
}
isReady.value = true
}
}
Second, setKeepOnScreenCondition only delays the exit — it doesn’t block input or the rest of app startup, so don’t use it as a substitute for a real loading state further down the tree. It buys you at most a couple hundred milliseconds of patience before it reads as the app hanging.
The exit animation, if the fade-out matters
By default the splash view is just removed. If a custom fade or scale on exit matters to the brand feel, setOnExitAnimationListener gives you the actual splash view to animate before you dismiss it yourself:
splashScreen.setOnExitAnimationListener { splashView ->
splashView.view.animate()
.alpha(0f)
.setDuration(200)
.withEndAction { splashView.remove() }
.start()
}
Skipping splashView.remove() at the end leaves the splash view attached over your content indefinitely — an easy one-line miss that looks fine in a hot-reload dev loop and then ships a permanently frozen launch screen.
What actually mattered
The default the system gives you when you configure nothing is not broken, but it’s not intentional either — install the splash theme before super.onCreate(), size the icon for the real 240dp constraint instead of reusing the adaptive launcher icon, and only reach for keepOnScreenCondition when there’s a specific, timed piece of data the first frame genuinely needs. The exit animation is the part that’s easy to skip entirely, and for most apps that’s the right call — the win here is removing the white flash and the empty-dashboard flicker, not adding motion for its own sake.
// Related reading
More from the journal
Material You dynamic color in Jetpack Compose: keeping your brand color when the wallpaper wins
dynamicColorScheme() replaces your palette with one built from the user's wallpaper. A practical guide to harmonizing brand colors instead of losing them.
The Android In-App Review API: asking for a rating without being annoying
A practical guide to Google's In-App Review API — how it actually works, where to trigger it, and why the usual 'rate us' popup is quietly hurting your Play Store rating.
Accessibility on Android in 2026: a TalkBack and Compose semantics checklist that ships
A practical Android accessibility checklist for 2026 — TalkBack, Compose semantics, touch targets, and the testing pass I run on every screen before release.