KMP-Splash
1.1.4indexedAutomates native splash asset generation and creates a matching runtime transition layer to eliminate startup flicker; single-build config, project file patching and dark‑mode support.
Automates native splash asset generation and creates a matching runtime transition layer to eliminate startup flicker; single-build config, project file patching and dark‑mode support.
Professional Splash Screens for Kotlin Multiplatform, configured in seconds.
A clean startup in Kotlin Multiplatform is harder than it should be. Between the native Android SplashScreen API and iOS UILaunchScreen, you usually get a white flash between the native boot sequence and the moment your UI is ready to draw.
KMP Splash closes that gap. It generates the native splash assets for you and adds a transition layer on top — Compose Multiplatform by default, or native SwiftUI on iOS via uiFramework = UiFramework.Native — so there's no visible jump from boot to your first screen.
Ensure you have mavenCentral() in your settings.gradle.kts:
pluginManagement {
repositories {
google()
gradlePluginPortal()
mavenCentral()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
In your gradle/libs.versions.toml:
[versions]
kmpSplash = "<version>"
[libraries]
kmpSplash-runtime = { module = "io.github.kmpbits:splash-runtime", version.ref = "kmpSplash" }
[plugins]
kmpSplash = { id = "io.github.kmpbits.splash", version.ref = "kmpSplash" }
In your Compose App module build.gradle.kts:
plugins {
alias(libs.plugins.kmpSplash)
}
splashScreen {
backgroundColor = SplashColor.hex()
backgroundColorNight = SplashColor.hex()
logo = SplashLogo.resource()
logoDark = SplashLogo.resource()
exitAnimation = ExitAnimation.FadeOut()
iosProjectPath =
androidAppPath =
resourcePackage =
generateAppIcon =
}
androidApp + shared)Newer KMP project templates separate the Android entry point into a dedicated androidApp module, independent from composeApp. In this case, androidAppPath is required. Without it the plugin targets the current module's androidMain sourcesets and the Android app module will not be configured.
// composeApp/build.gradle.kts
splashScreen {
backgroundColor = SplashColor.white
androidAppPath = "androidApp" // Path to the androidApp module, relative to the root project
}
When androidAppPath is set, the plugin generates everything into the module's build/generated/kmpSplash/ folder (the resources including dark mode, the splash Kotlin source, and the splash theme plus provider) and wires all of it directly into the androidApp module's own build variants via AGP's Variant API. Your src/main/ files are never modified.
This assumes androidApp has a project dependency on the module applying the plugin (e.g. implementation(project(":shared"))), so the generated splash initialization class is on its runtime classpath. That's the case for every current KMP-wizard "separate androidApp module" template.
One required setup step: add evaluationDependsOn(":shared") (replace ":shared" with the actual path of the module that applies the splash plugin) to the top of your androidApp module's build.gradle.kts. Gradle evaluates subprojects in path-alphabetical order by default, and if androidApp happens to sort before the module applying the plugin, the plugin's wiring would otherwise run too late. The plugin detects this case and logs a warning naming the exact fix if you forget this step.
SplashColor.hex("#FFFFFF") // Hex string, accepts both #RRGGBB and RRGGBB
SplashColor.rgb(255, 255, 255) // RGB values (0-255 each)
SplashColor.white // Named constant
SplashColor.black // Named constant
SplashLogo.resource("logo.png") // File in composeResources/drawable/
SplashLogo.path("src/commonMain/composeResources/drawable/logo.png") // Custom path relative to module
By default, the plugin reads the Compose resource package from compose { resources { packageOfResClass = ... } } if you've set it, or replicates Compose's own default naming if you haven't. If auto-detection doesn't find the right value for your project (e.g. an unusual module setup), override it explicitly:
splashScreen {
resourcePackage = "com.example.myapp.generated.resources"
}
Set generateAppIcon = true to also generate the app icon on both platforms from your existing logo and backgroundColor, with no separate icon assets required:
splashScreen {
backgroundColor = SplashColor.white
logo = SplashLogo.resource("logo.png")
generateAppIcon = true
}
This requires logo to be set, in a rasterizable format (PNG, JPEG, GIF, or BMP, but not WebP, .svg, or Android vector .xml). The plugin generates:
mipmap-anydpi-v26) with as the background layer and a trimmed, re-centered copy of as the foreground, legacy square/round PNG fallbacks at all five mipmap densities for devices below Android 13 (API 26), and / in the manifest pointing at the generated icon.It's off by default. Changing your app's launcher icon is a visible, home-screen-facing change, so it's opt-in rather than automatic whenever logo is set. backgroundColorNight and logoDark aren't used for the icon on either platform: launchers/springboards don't resolve dark-mode resource qualifiers for app icons.
ExitAnimation.None // No animation, splash disappears instantly (default)
ExitAnimation.FadeOut(300) // Fade out over 300ms
ExitAnimation.SlideUp(400) // Slide upward to reveal the app
ExitAnimation.SlideDown(400) // Slide downward to reveal the app
The duration parameter is optional. The values above are the defaults.
iosProjectPathshould point to the inner folder that containsInfo.plistandAssets.xcassets, typicallyiosApp/iosApp, not the rootiosAppfolder.
androidAppPathis required when your project uses the new KMP module structure where the Android app lives in a dedicated module separate fromcomposeApp, when and your project has an Android target (see — the plugin fails fast with an actionable message if it's missing there). Set it to the path of that module relative to the root project (e.g. ). Leave it unset for the classic structure where Android is part of with .
In your Compose App module build.gradle.kts:
commonMain.dependencies {
implementation(libs.kmpSplash.runtime)
}
androidMain.dependencies {
implementation("androidx.core:core-splashscreen:1.2.0")
}
KMP Splash is integrated into the Gradle build process. On both Android and iOS, the splash assets are generated automatically when you build or run your app.
If you ever want to trigger the generation manually, you can run:
# Generate iOS assets (Info.plist, pbxproj, xcassets)
./gradlew generateLaunchScreen
# Generate Android assets (themes.xml, logo)
./gradlew generateAndroidSplash
[!IMPORTANT] iOS Simulator Caching: iOS heavily caches the launch screen. If you change the background color or logo and don't see the changes in the simulator, you must restart the simulator (or sometimes even delete and reinstall the app) for the new assets to be reflected.
[!WARNING] Avoid naming your logo file
logo.pngon iOS. The filename becomes theUIImageNameused byUILaunchScreen. The namelogoconflicts with iOS internals and causes the image to be displayed fullscreen instead of at its natural size. Use a more specific name such as , , or .
[!TIP] Recommended logo size: 512×512 px. On iOS, the native launch screen renders the image at its natural point size (pixels ÷ screen scale). A 512 px PNG displays at about 171 pt on @3x iPhones, roughly 45% of the screen width, which looks right for a centred logo. Smaller sources (say 250 px) will appear too large; larger sources will appear smaller.
Extend SplashActivity in your MainActivity:
class MainActivity : SplashActivity() {
override suspend fun isReady(): Boolean {
delay(1000) // Load data, check auth, etc.
return true
}
override {
setContent {
App()
}
}
}
If you need to call enableEdgeToEdge(), override onPreCreate() instead of onCreate(). This hook runs at exactly the right moment, after installSplashScreen() but before super.onCreate(), which is the order Android requires:
class MainActivity : SplashActivity() {
override fun onPreCreate() {
enableEdgeToEdge()
}
override suspend fun isReady(): Boolean { ... }
override { ... }
}
[!IMPORTANT] Do not call
enableEdgeToEdge()inside your ownonCreate()override. Doing so runs it beforeinstallSplashScreen(), which causes a stray toolbar to appear on the first frame.
If MainActivity needs to extend something other than SplashActivity, for example AppCompatActivity (required for AppCompatDelegate.setApplicationLocales() and other AppCompat-only APIs), call installKmpSplash() directly from onCreate() instead of extending SplashActivity:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installKmpSplash(
isReady = {
delay(1000) // Load data, check auth, etc.
true
},
onFinished = {
setContent {
App()
}
},
)
super.onCreate(savedInstanceState)
}
}
installKmpSplash() must be called before super.onCreate(), for the same reason SplashActivity calls it there internally. It works on any ComponentActivity subclass, so it's also the right choice for any other custom base class.
Call SplashConfig in your MainViewController, passing your app content as the trailing lambda:
fun MainViewController() = ComposeUIViewController {
SplashConfig(
isReady = {
delay(1500) // Your initialization logic
true
}
) {
App()
}
}
SplashConfig manages the splash/content transition internally, with no state boilerplate needed. Colors and logo are picked up automatically from your Gradle configuration.
If your KMP app uses SwiftUI for the iOS UI instead of Compose Multiplatform, set uiFramework:
splashScreen {
backgroundColor = SplashColor.hex("#FFFFFF")
logo = SplashLogo.resource("splash_logo.png")
exitAnimation = ExitAnimation.FadeOut(300)
uiFramework = UiFramework.Native
}
The plugin still generates the native UILaunchScreen, the Assets.xcassets, and the app icon.
Instead of the Compose SplashInit.kt, it generates KmpSplashView.swift into your Xcode
project (iosProjectPath) and wires it into the build. You do not need the
io.github.kmpbits:splash-runtime dependency in this mode.
uiFramework only affects iOS — Android's generated splash always imports androidx.compose.*
and needs Compose on its classpath, regardless of uiFramework. This is unrelated to whether
your iOS UI is Compose or SwiftUI; it's Android's own current requirement. the
plugin needs to know which module actually has Compose on its classpath — the
module applying this plugin is often a shared/business-logic module with no Compose dependency
once iOS moves to SwiftUI. See the section above.
Wrap your root view:
struct ContentView: View {
var body: some View {
KmpSplashView(awaitReady: {
await AppGraph.shared.warmUp() // your own suspend fun in :shared, bridged to async
}, content: {
RootView()
})
}
}
awaitReady is optional — omit it to just hold the launch screen until SwiftUI's first frame,
then run the exit animation. With only one closure to pass, trailing closure syntax is fine here:
KmpSplashView { RootView() }
[!NOTE] Both examples above pass
contentwith an explicit label rather than as a trailing closure whenawaitReadyis also given.KmpSplashView(awaitReady: {...}) { ... }compiles identically, but SwiftLint's defaultmultiple_closures_with_trailing_closurerule flags any trailing closure on a call with more than one closure argument — not just Swift's dedicated multiple-trailing-closure syntax (foo { } second: { }). Fully-labeled calls satisfy that rule either way, so that's what's shown here.
KmpSplashView.swift is regenerated on every Gradle sync — do not edit it. For projects created
with Xcode 16+ (synchronized folder groups) no .pbxproj change is needed; for older projects the
plugin patches the Sources build phase automatically in the common case. If it can't locate that
build phase (an unusually structured .pbxproj), it logs a warning instead of failing the build —
in that rare case, drag KmpSplashView.swift into your app target in Xcode once, and it stays wired
in for every subsequent regeneration.
The Gradle plugin does the heavy lifting at build time so you rarely touch XML or native config files manually (the one exception is noted in SwiftUI (native iOS UI) above):
When a KMP app starts on iOS, the OS shows the native launch screen right away. Once the Kotlin runtime and your UI framework finish initializing (which can take 500ms or more), the screen usually flashes white or black before your first screen renders.
KMP Splash makes the transition layer — SplashConfig for Compose, KmpSplashView for native SwiftUI — visually identical to the native launch screen, so your branding stays on screen until the app is actually ready.
App-level dark mode overrides
If your app has its own appearance setting (e.g. a dark mode toggle independent of the system setting), the native splash screen will not respect it. Both iOS UILaunchScreen and Android's SplashScreen API are rendered by the OS before any app code runs, they read the system dark mode setting directly. There is no way for any library to work around this.
The Compose layer (SplashConfig / SplashActivity) does run app code, so it can respond to your app's own preference. For the native layer, the options are:
This is a system limitation, not a bug in the library.
KmpSplashView (uiFramework = UiFramework.Native) has the same constraint as the native layer, not the Compose one. Unlike SplashConfig, which explicitly reads app state, the generated KmpSplashView only references the SplashBackground/logo asset catalog entries — the same ones uses. Named colors and image sets in an asset catalog follow the appearance automatically, but there's currently no way to override that with an app-level preference from within the generated file. The same two options above apply.
Contributions are welcome! If you find a bug or have a feature request, please open an issue or a pull request.
Copyright 2026 KMP Bits
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Surfaced from shared tags and platforms — no rankings paid for.
build.gradle.kts.Assets.xcassets for iOS and themes.xml for Android.SplashConfig composable (or, for a native SwiftUI iOS UI, a generated KmpSplashView) covers the switch from native boot to your app's own UI..pbxproj and Info.plist for you, no Storyboards, for both the Compose Multiplatform and native SwiftUI paths. One rare case still needs a manual step — see SwiftUI (native iOS UI).androidx.compose.*, regardless of uiFramework — this is required on Android even if your iOS UI is native SwiftUI (uiFramework = UiFramework.Native). It's only optional on iOS, and only when uiFramework = UiFramework.Native.androidx.core:core-splashscreen 1.2.0+, AGP 8.0+UILaunchScreen plist key). uiFramework = UiFramework.Native additionally needs an iOS 15+ deployment target, for KmpSplashView's use of SwiftUI's .task modifier and async/await.[!WARNING]
A vector (.svg) logo works for iOS and Compose, but not for Android's native splash. iOS's Assets.xcassets and Compose Multiplatform's resource system both handle .svg directly. Android's native res/drawable copy (used by the themes.xml-based launch screen, independent of uiFramework) does not — logo is copied there as-is, and AGP's resource merger rejects anything but .png/.xml, failing the build with The file name must end with .xml or .png. There is currently no per-platform logo override, so an .svg logo works everywhere except a real Android build: use a .png/.jpg/.gif/.bmp (or hand-author an Android Vector Drawable .xml at that path) instead. The same raster requirement applies to generateAppIcon (see below).
backgroundColorlogoandroid:iconandroid:roundIconAppIcon.appiconset image (Xcode 14+ derives every other required size from it automatically), composited from logo over backgroundColor the same way as Android's legacy fallback icon.uiFramework = UiFramework.Native"androidApp"composeAppuiFramework = UiFramework.Composesplash_logo.pngapp_logo.pngic_splash.png[!IMPORTANT]
AppCompatActivity requires an AppCompat-descended theme to be active by the time setContentView() runs, and the plugin's generated theme intentionally isn't one, so pure-Compose apps aren't forced onto AppCompat. Without a fix, you'll hit IllegalStateException: You need to use a Theme.AppCompat theme (or descendant) with this activity. Don't try to fix this by redefining Theme.App yourself in src/main/res: the plugin's generated resources take priority over your module's own in AGP's resource merge, so your definition is silently ignored. Instead, define your own, differently-named theme and point androidPostSplashTheme at it:
// res/values/themes.xml
// <style name="Theme.MyApp" parent="Theme.AppCompat.DayNight.NoActionBar" />
splashScreen {
androidPostSplashTheme = "@style/Theme.MyApp"
}
See sample/androidApp for a full working example.
androidAppPath is required when uiFramework = UiFramework.Native:androidAppPathandroidAppPath[!IMPORTANT]
awaitReady is called once and awaited to completion — it is not a condition SwiftUI
re-checks. It's the same contract as isReady on the Compose/Android side (a suspend
function you await until your app is ready), just without the Boolean return value Kotlin
needs and Swift doesn't. A common mistake is passing a snapshot check instead of an actual wait:
// Wrong: evaluated once, immediately, regardless of whether viewModel is actually ready —
// the splash dismisses instantly and this Bool is silently discarded ("Result of operator
// '!=' is unused"), because awaitReady's return type is Void, not Bool.
KmpSplashView(awaitReady: {
viewModel.destination != .splash
}) { ... }
If your readiness signal is a value that changes over time — e.g. a @Published property on
an ObservableObject view model, which is the common shape when a KMP StateFlow is bridged
into SwiftUI — awaitReady needs to itself suspend until that value settles, then return.
Combine's .values async sequence does this cleanly:
import Combine
struct SplashView: View {
@StateObject private var viewModel = SplashViewModelWrapper()
var body: some View {
KmpSplashView(awaitReady: {
guard viewModel.destination == .splash else { return } // already resolved
for await destination in viewModel.$destination.values {
if destination != .splash { break }
}
}, content: {
switch viewModel.destination {
case .splash: EmptyView() // hidden behind the KmpSplashView overlay anyway
case .main: MainView()
case .login: LoginView()
// ...
}
})
}
}
content is free to keep re-rendering off viewModel.destination the whole time — it's simply
invisible until the overlay animates away, so by the time awaitReady returns, content is
already showing the right screen.
themes.xml (and values-night), copies your logo drawable, and writes a patched copy of the AndroidManifest.xml, all into the build/ folder, to apply the splash theme and register a ContentProvider that initialises runtime config before your Activity starts. Your source files are never modified.SplashBackground color asset and logo imageset in Assets.xcassets, and patches Info.plist and project.pbxproj to wire up UILaunchScreen. No Storyboard or Xcode required.| Platform | Native (Booting) | Transition layer (Loading) |
|---|
| Android | themes.xml + a patched copy of AndroidManifest.xml generated into build/. Uses installSplashScreen(). | SplashActivity controls visibility and runs the exit animation via setOnExitAnimationListener. Always Compose, regardless of uiFramework (iOS-only). |
iOS, uiFramework = UiFramework.Compose (default) | Patches Info.plist with UILaunchScreen, generates SplashBackground color asset and logo imageset in Assets.xcassets. | SplashConfig uses isSystemInDarkTheme() to match the native screen exactly, then animates the exit with AnimatedVisibility. |
iOS, uiFramework = UiFramework.Native | Same Info.plist/Assets.xcassets generation as above. | Generated KmpSplashView.swift reads the same SplashBackground/logo assets and drives the exit transition — see SwiftUI (native iOS UI) above. |
UILaunchScreen