Overview
KMP Splash handles the entire splash screen setup for both Android and iOS from a single Gradle config block. No Xcode, no storyboards, no manual asset catalogs, for either UI framework.
On Android, it uses the Core Splashscreen API to render a native launch screen and optionally extend it until your app is ready. This part is the same regardless of which UI framework your iOS side uses.
On iOS, it generates a UILaunchScreen plist entry and the backing asset catalog entries, then bridges the gap between that native launch screen and your first screen with a generated transition layer, eliminating the white flash on cold start:
- Compose Multiplatform (default): a
SplashConfigcomposable. - Native SwiftUI: set
uiFramework = UiFramework.Nativeand the plugin generates aKmpSplashView.swiftinstead, with no Compose Multiplatform on iOS required.
Both platforms, and both iOS UI frameworks, read the same config. You define your colors and logo once.
Installation
1. Apply the plugin
In your composeApp/build.gradle.kts (or whichever module owns your shared UI resources), apply the plugin:
plugins {
id("io.github.kmpbits.splash") version "<version>"
}
2. Add the runtime dependency (Compose Multiplatform only)
commonMain.dependencies {
implementation("io.github.kmpbits:splash-runtime:<version>")
}
Skip this step entirely if uiFramework = UiFramework.Native: the generated KmpSplashView.swift has no dependency on splash-runtime.
Replace <version> with the latest release on GitHub or Maven Central.
3. Sync
Run a Gradle sync. The plugin registers two tasks, generateLaunchScreen for iOS and generateAndroidSplash for Android, and both run automatically before compilation.
Configuration
Add a splashScreen block anywhere in your composeApp/build.gradle.kts:
splashScreen {
backgroundColor = SplashColor.hex("#FFFFFF")
backgroundColorNight = SplashColor.hex("#1A1A2E") // optional
logo = SplashLogo.resource("logo.png")
logoDark = SplashLogo.resource("logo_dark.png") // optional
exitAnimation = ExitAnimation.FadeOut(300) // optional
uiFramework = UiFramework.Compose // optional, default. Set to .Native for SwiftUI
androidAppPath = "androidApp" // required for the new KMP module structure, or when uiFramework = .Native
generateAppIcon = true // optional: derive the app icon from logo + backgroundColor
resourcePackage = "com.example.app.generated.resources" // optional: override the inferred Compose resource package
androidPostSplashTheme = "@style/Theme.MyApp" // optional: needed if MainActivity extends AppCompatActivity
}
SplashColor
Defines the splash background color. Three factory methods are available:
| Method | Example | Notes |
|---|---|---|
SplashColor.hex(value) | SplashColor.hex("#FFFFFF") | Validates format at config time |
SplashColor.rgb(r, g, b) | SplashColor.rgb(26, 26, 46) | Integer values 0–255 |
SplashColor.white | N/A | Convenience constant |
SplashColor.black | N/A | Convenience constant |
backgroundColorNight is optional. If omitted, the light color is used for both modes.
SplashLogo
Defines the logo image. The resource() factory resolves the file from composeResources/drawable/ automatically, so you only supply the filename. path() takes a custom path relative to the module instead.
logo = SplashLogo.resource("logo.png")
logoDark is optional. If omitted, the light logo is used for both modes.
A vector (.svg) logo works for iOS’s asset catalog and for Compose Multiplatform’s resource system. It does not work for Android’s native res/drawable copy, used for the themes.xml-based launch screen regardless of uiFramework, which needs a raster format (PNG, JPEG, GIF, BMP) or a hand-authored Android Vector Drawable XML at that path. The same raster requirement applies to generateAppIcon.
ExitAnimation
Controls how the splash screen exits, consistently across Android, Compose iOS, and native SwiftUI iOS. All animations accept an optional duration in milliseconds.
| Value | Default duration | Description |
|---|---|---|
ExitAnimation.None | N/A | Splash disappears instantly (default) |
ExitAnimation.FadeOut(ms) | 300ms | Fades out |
ExitAnimation.SlideUp(ms) | 400ms | Slides upward to reveal the app |
ExitAnimation.SlideDown(ms) | 400ms | Slides downward to reveal the app |
On Android, the animation runs via setOnExitAnimationListener over the system splash view. On iOS, it runs via AnimatedVisibility in the Compose layer, or a matching SwiftUI transition in the generated KmpSplashView when uiFramework = UiFramework.Native.
uiFramework
Selects which UI toolkit your iOS app uses. Defaults to UiFramework.Compose. Doesn’t affect Android: Android’s generated splash always uses Compose, regardless of this setting.
splashScreen {
uiFramework = UiFramework.Native
}
See SwiftUI Setup below for what changes when you set this.
androidAppPath
Newer KMP project templates separate the Android entry point into a dedicated androidApp module, independent from composeApp. androidAppPath is required in two cases:
- Your project uses that separate-module structure: without it, the plugin targets the current module’s own
androidMainsourcesets and the Android app module won’t be configured. uiFramework = UiFramework.Nativeand your project has an Android target: the generated Android splash source still importsandroidx.compose.*, and the plugin needsandroidAppPathto know which module actually depends on Compose, since the module applying the plugin is often a shared/business-logic module without it once iOS moves to SwiftUI. The plugin fails fast with an actionable message if this is missing.
splashScreen {
backgroundColor = SplashColor.white
androidAppPath = "androidApp"
}
When set, the plugin generates everything (resources, the splash Kotlin source, and a manifest patch) into androidApp’s own build/generated/kmpSplash/ folder and wires it directly into that module’s build variants via AGP’s Variant API. Your androidApp/src/main/ files are never modified.
This assumes androidApp has a project dependency on the module applying the plugin (e.g. implementation(project(":shared"))), which is the case for every current KMP-wizard “separate androidApp module” template.
One required setup step: add evaluationDependsOn(":shared") (replace ":shared" with the module that applies the plugin) to the top of androidApp’s build.gradle.kts. Gradle evaluates subprojects in path-alphabetical order by default, and if androidApp sorts before the applying module, the plugin’s wiring would otherwise run too late. The plugin logs a warning naming this exact fix if you forget it.
Leave androidAppPath unset for the classic KMP structure where Android lives inside composeApp with uiFramework = UiFramework.Compose.
generateAppIcon
Generates the app icon on both platforms from your existing logo and backgroundColor, so no separate icon assets are needed. Off by default, since changing the launcher icon is a visible, home-screen-facing change.
splashScreen {
generateAppIcon = true
}
Requires logo to be a rasterizable format (PNG, JPEG, GIF, BMP, not WebP, .svg, or Android vector XML). Generates an Android adaptive icon plus legacy fallbacks, and a single 1024×1024 iOS AppIcon.appiconset image. backgroundColorNight/logoDark aren’t used for the icon on either platform, since launchers don’t resolve dark-mode qualifiers for app icons.
androidPostSplashTheme
Needed only if MainActivity extends AppCompatActivity via installKmpSplash(). AppCompatActivity requires an AppCompat-descended theme active by setContentView() time, and the plugin’s generated Theme.App intentionally isn’t one. Point this at your own theme instead:
splashScreen {
androidPostSplashTheme = "@style/Theme.MyApp"
}
Android Setup
Extend SplashActivity in your MainActivity. Override isReady() to hold the splash until your app has finished loading, and onFinished() to hand off to your Compose UI:
// androidMain/MainActivity.kt
class MainActivity : SplashActivity() {
override suspend fun isReady(): Boolean {
// Load data, check auth state, warm up caches, etc.
delay(1000)
return true
}
override fun onFinished() {
setContent { App() }
}
}
isReady() runs on a background coroutine. Return true when the app is ready to show. The splash stays visible until you do.
The plugin generates themes.xml, patches AndroidManifest.xml with the splash theme, and registers a ContentProvider that initialises runtime config (colors, logo, exit animation) before your Activity starts. No manual manifest changes required.
Edge to Edge
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() { ... }
}
Using a Different Base Class (AppCompat, etc.)
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:
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
installKmpSplash(
isReady = {
delay(1000)
true
},
onFinished = {
setContent { App() }
},
)
super.onCreate(savedInstanceState)
}
}
installKmpSplash() must be called before super.onCreate(), and works on any ComponentActivity subclass. Since AppCompatActivity requires an AppCompat-descended theme active by setContentView() time, also set androidPostSplashTheme.
iOS Setup
Compose Multiplatform
Call SplashConfig from your MainViewController and swap it out once the app is ready:
// iosMain/MainViewController.kt
fun MainViewController() = ComposeUIViewController {
SplashConfig(
isReady = {
delay(1500) // Your initialization logic
true
}
) {
App()
}
}
SplashConfig renders a Compose screen that is visually identical to the native launch screen: same background color, same logo. It holds that screen until isReady returns true, then calls onFinished.
The colors and logo are read from your Gradle config automatically. You don’t pass them at the call site.
SwiftUI
Set uiFramework = UiFramework.Native in your Gradle config (see uiFramework above). The plugin generates KmpSplashView.swift into your Xcode project instead of the Compose SplashInit.kt, and no splash-runtime dependency is needed on iOS. Requires an iOS 15+ deployment target, for KmpSplashView’s use of SwiftUI’s .task modifier.
Wrap your root view, passing both closures with an explicit label rather than as two trailing closures, to stay clean under SwiftLint’s default multiple_closures_with_trailing_closure rule:
struct ContentView: View {
var body: some View {
KmpSplashView(awaitReady: {
await AppGraph.shared.warmUp() // your own suspend fun, 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 a single closure, trailing syntax is fine:
KmpSplashView { RootView() }
awaitReady is called once and awaited to completion. It is not a condition SwiftUI re-checks. If your readiness signal is a value that changes over time (a @Published property on an ObservableObject, the common shape for a bridged KMP StateFlow), awaitReady needs to itself suspend until that value settles, for example via Combine’s .values async sequence:
KmpSplashView(awaitReady: {
guard viewModel.destination == .splash else { return }
for await destination in viewModel.$destination.values {
if destination != .splash { break }
}
}, content: {
// switch over viewModel.destination
})
KmpSplashView.swift is regenerated on every Gradle sync, so don’t 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 rare case it can’t locate that build phase, it logs a warning instead of failing the build. Drag KmpSplashView.swift into your app target in Xcode once, and it stays wired in for every later regeneration.
If your project has an Android target, androidAppPath is required alongside uiFramework = UiFramework.Native: see androidAppPath above.
Dark Mode
Both backgroundColorNight and logoDark in the config block are optional.
If you provide them, the plugin generates separate dark-mode assets for Android (themes-night.xml) and the appropriate asset catalog entries for iOS.
If you omit them, the light values are used for both modes.
System vs. app dark mode
The native launch screen reads the system dark mode setting, not your app’s preference. If your app has its own appearance toggle and the user has set it to dark while the device is in light mode, the native splash will still render in light mode. This is an OS-level limitation: the native splash runs before any app code.
- Compose Multiplatform: the
SplashConfiglayer does run app code, so it can correct to your app’s preference immediately after the native splash. - Native SwiftUI: the generated
KmpSplashViewonly references the asset catalog’sSplashBackground/logo entries, the same ones the native launch screen uses. It follows the system appearance, same as the native layer, with no current way to override that with an app-level preference from the generated file.
For most apps this mismatch is not noticeable. If it matters, use a background color that works in both modes.
Known Limitations
- Minimum iOS version:
UILaunchScreenrequires iOS 14+.uiFramework = UiFramework.Nativeadditionally requires iOS 15+, forKmpSplashView’s use of SwiftUI’s.taskmodifier andasync/await. - System dark mode only: neither the native splash nor (for
uiFramework = UiFramework.Native)KmpSplashViewcan respond to app-level appearance overrides. See Dark Mode above. - Raster logos only on Android: Android’s native
res/drawablecopy needs a PNG/JPEG/GIF/BMP file (or a hand-authored Android Vector Drawable XML). A.svglogo works for iOS and Compose Multiplatform’s resource system, but fails Android’s resource merge. The same applies togenerateAppIcon.