
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.
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.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("#FFFFFF") // Light mode background
backgroundColorNight = SplashColor.hex("#1A1A2E") // Optional: dark mode background
logo = SplashLogo.resource("splash_logo.png") // File in composeResources/drawable/ (512x512 px recommended)
logoDark = SplashLogo.resource("logo_dark.png") // Optional: dark mode logo
exitAnimation = ExitAnimation.FadeOut(300) // Optional: exit animation (Android + iOS)
iosProjectPath = "iosApp/iosApp" // Optional: defaults to "iosApp/iosApp"
androidAppPath = "androidApp" // Required if using the new KMP module structure, or uiFramework = UiFramework.Native
resourcePackage = "com.example.myapp.generated.resources" // Optional: override the inferred Compose resource package
generateAppIcon = true // Optional: generate the app icon (Android + iOS) from logo + backgroundColor
}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 constantSplashLogo.resource("logo.png") // File in composeResources/drawable/
SplashLogo.path("src/commonMain/composeResources/drawable/logo.png") // Custom path relative to module[!WARNING] A vector (
.svg) logo works for iOS and Compose, but not for Android's native splash. iOS'sAssets.xcassetsand Compose Multiplatform's resource system both handle.svgdirectly. Android's nativeres/drawablecopy (used by thethemes.xml-based launch screen, independent ofuiFramework) does not —logois copied there as-is, and AGP's resource merger rejects anything but.png/.xml, failing the build withThe file name must end with .xml or .png. There is currently no per-platformlogooverride, so an.svglogo works everywhere except a real Android build: use a.png/.jpg/.gif/.bmp(or hand-author an Android Vector Drawable.xmlat that path) instead. The same raster requirement applies togenerateAppIcon(see below).
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 backgroundColor as the background layer and a trimmed, re-centered copy of logo as the foreground, legacy square/round PNG fallbacks at all five mipmap densities for devices below Android 13 (API 26), and android:icon/android:roundIcon in the manifest pointing at the generated icon.AppIcon.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.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 appThe 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, and whenuiFramework = UiFramework.Nativeand your project has an Android target (see SwiftUI (native iOS UI) — 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."androidApp"). Leave it unset for the classic structure where Android is part ofcomposeAppwithuiFramework = UiFramework.Compose.
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 assplash_logo.png,app_logo.png, oric_splash.png.
[!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 fun onFinished() {
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 fun onFinished() { ... }
}[!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.
[!IMPORTANT]
AppCompatActivityrequires an AppCompat-descended theme to be active by the timesetContentView()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 hitIllegalStateException: You need to use a Theme.AppCompat theme (or descendant) with this activity.Don't try to fix this by redefiningTheme.Appyourself insrc/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 pointandroidPostSplashThemeat it:// res/values/themes.xml // <style name="Theme.MyApp" parent="Theme.AppCompat.DayNight.NoActionBar" /> splashScreen { androidPostSplashTheme = "@style/Theme.MyApp" }See
sample/androidAppfor a full working example.
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. If your project has
an Android target, androidAppPath is required when uiFramework = UiFramework.Native: the
plugin needs androidAppPath 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 androidAppPath 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.
[!IMPORTANT]
awaitReadyis called once and awaited to completion — it is not a condition SwiftUI re-checks. It's the same contract asisReadyon the Compose/Android side (a suspend function you await until your app is ready), just without theBooleanreturn 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
@Publishedproperty on anObservableObjectview model, which is the common shape when a KMPStateFlowis bridged into SwiftUI —awaitReadyneeds to itself suspend until that value settles, then return. Combine's.valuesasync 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() // ... } }) } }
contentis free to keep re-rendering offviewModel.destinationthe whole time — it's simply invisible until the overlay animates away, so by the timeawaitReadyreturns,contentis already showing the right screen.
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):
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. |
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 UILaunchScreen uses. Named colors and image sets in an asset catalog follow the system 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
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.
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.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("#FFFFFF") // Light mode background
backgroundColorNight = SplashColor.hex("#1A1A2E") // Optional: dark mode background
logo = SplashLogo.resource("splash_logo.png") // File in composeResources/drawable/ (512x512 px recommended)
logoDark = SplashLogo.resource("logo_dark.png") // Optional: dark mode logo
exitAnimation = ExitAnimation.FadeOut(300) // Optional: exit animation (Android + iOS)
iosProjectPath = "iosApp/iosApp" // Optional: defaults to "iosApp/iosApp"
androidAppPath = "androidApp" // Required if using the new KMP module structure, or uiFramework = UiFramework.Native
resourcePackage = "com.example.myapp.generated.resources" // Optional: override the inferred Compose resource package
generateAppIcon = true // Optional: generate the app icon (Android + iOS) from logo + backgroundColor
}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 constantSplashLogo.resource("logo.png") // File in composeResources/drawable/
SplashLogo.path("src/commonMain/composeResources/drawable/logo.png") // Custom path relative to module[!WARNING] A vector (
.svg) logo works for iOS and Compose, but not for Android's native splash. iOS'sAssets.xcassetsand Compose Multiplatform's resource system both handle.svgdirectly. Android's nativeres/drawablecopy (used by thethemes.xml-based launch screen, independent ofuiFramework) does not —logois copied there as-is, and AGP's resource merger rejects anything but.png/.xml, failing the build withThe file name must end with .xml or .png. There is currently no per-platformlogooverride, so an.svglogo works everywhere except a real Android build: use a.png/.jpg/.gif/.bmp(or hand-author an Android Vector Drawable.xmlat that path) instead. The same raster requirement applies togenerateAppIcon(see below).
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 backgroundColor as the background layer and a trimmed, re-centered copy of logo as the foreground, legacy square/round PNG fallbacks at all five mipmap densities for devices below Android 13 (API 26), and android:icon/android:roundIcon in the manifest pointing at the generated icon.AppIcon.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.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 appThe 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, and whenuiFramework = UiFramework.Nativeand your project has an Android target (see SwiftUI (native iOS UI) — 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."androidApp"). Leave it unset for the classic structure where Android is part ofcomposeAppwithuiFramework = UiFramework.Compose.
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 assplash_logo.png,app_logo.png, oric_splash.png.
[!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 fun onFinished() {
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 fun onFinished() { ... }
}[!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.
[!IMPORTANT]
AppCompatActivityrequires an AppCompat-descended theme to be active by the timesetContentView()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 hitIllegalStateException: You need to use a Theme.AppCompat theme (or descendant) with this activity.Don't try to fix this by redefiningTheme.Appyourself insrc/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 pointandroidPostSplashThemeat it:// res/values/themes.xml // <style name="Theme.MyApp" parent="Theme.AppCompat.DayNight.NoActionBar" /> splashScreen { androidPostSplashTheme = "@style/Theme.MyApp" }See
sample/androidAppfor a full working example.
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. If your project has
an Android target, androidAppPath is required when uiFramework = UiFramework.Native: the
plugin needs androidAppPath 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 androidAppPath 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.
[!IMPORTANT]
awaitReadyis called once and awaited to completion — it is not a condition SwiftUI re-checks. It's the same contract asisReadyon the Compose/Android side (a suspend function you await until your app is ready), just without theBooleanreturn 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
@Publishedproperty on anObservableObjectview model, which is the common shape when a KMPStateFlowis bridged into SwiftUI —awaitReadyneeds to itself suspend until that value settles, then return. Combine's.valuesasync 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() // ... } }) } }
contentis free to keep re-rendering offviewModel.destinationthe whole time — it's simply invisible until the overlay animates away, so by the timeawaitReadyreturns,contentis already showing the right screen.
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):
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. |
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 UILaunchScreen uses. Named colors and image sets in an asset catalog follow the system 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