Native App (No Shared UI)
SkillDev toolsBuild a KMP Starter app with NO shared UI — Compose on Android, native SwiftUI on iOS. Create an ios-runtime module exported to the iOS app via SKIE, start Koin once in Swift, and build previewable MVI SwiftUI screens.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Native App (No Shared UI) skill
What this skill tells your AI
The instructions your AI receives, as published by devatrii/kmp-starter-template in .agents/skills/kmp-starter/native-app/SKILL.md and read by ahel’s review.
Use when the user wants separate UIs per platform: Compose Multiplatform on Android, native SwiftUI on iOS. Shared code is still KMP — domain, data, database, DI — but there is no shared UI between Android and iOS.
- Android — uses the existing
composeAppmodule as-is. - iOS — native SwiftUI app, backed by a thin
ios-runtimeKotlin module that exports shared logic (no Compose).
Architecture overview
shared (KMP): starter/* features/* (data + domain + database — NO presentation/Compose)
│ │
▼ ▼
composeApp (Android, Compose) ios-runtime (KMP framework) → iosApp (SwiftUI)
The iOS binary must not contain any Compose UI code. Only domain/data/database modules are linked into the iOS framework.
1. Create the ios-runtime module
Add a new Gradle module ios-runtime (KMP, iOS targets only) and register it in settings.gradle.kts:
include(":iosRuntime")
It should:
- Target
iosArm64()+iosSimulatorArm64()with aframework { baseName = "iosRuntime"; isStatic = true }. - Depend on the non-UI modules only:
starter/core,features/core/domain,features/core/data,features/database,features/your-feature/domain,features/your-feature/data,starter/utils, etc. Do not includecomposeApp,starter/ui/*, or any*-presentationmodule. - Export those dependencies (
api(...)) so their types surface in the generated Swift API.
Apply SKIE
Add the SKIE Gradle plugin to the ios-runtime module (the module that builds the Xcode framework). It enhances the Kotlin→Swift export (Flow interop, better enums/optionals/suspending functions).
plugins {
// ...
id("co.touchlab.skie") version "0.10.14"
}
Disable SKIE analytics:
skie {
analytics {
enabled.set(false)
}
}
The plugin must be applied only in the module(s) that create the Xcode framework. It instruments everything exported in that framework (including dependency types). See SKIE installation and configuration.
2. Wire the Xcode build phase to iosRuntime
Update the "Run Script" / embedAndSignAppleFrameworkForXcode build phase in all iOS targets so the Swift app imports iosRuntime instead of ComposeApp:
cd "$SRCROOT/.."
./gradlew :iosRuntime:embedAndSignAppleFrameworkForXcode
Then in Swift:
import iosRuntime
3. Bootstrap — start Koin once
Create a thin bootstrap object (like SharedRuntime) in ios-runtime (.../shared/SharedRuntime.kt). It starts Koin once and exposes repositories/logics to Swift.
Key rules for Swift-friendliness (learned from a real Starter project):
- Start Koin once, guard with
@Volatileso repeated calls are no-ops. - Kotlin default params are invisible to Swift — expose no-arg wrappers and CSV string factories instead of Kotlin
List/Mapconstructors. - Prefer primitive/
String/ByteArrayreturn types over complex Kotlin generics where possible.
@OptIn(ExperimentalStarterApi::class)
object SharedRuntime {
@Volatile private var started = false
fun start(config: KoinAppDeclaration? = null) {
if (started) return
startKoin {
config?.invoke(this)
modules(
utilsModule,
databaseModule,
coreDataModule,
coreDomainModule,
yourFeatureDataModule,
yourFeatureDomainModule,
)
}
started = true
}
fun isDebug(): Boolean = platform.debug
fun platform(): Platform.Ios = platform as Platform.Ios
fun yourRepository(): YourRepository = KoinPlatform.getKoin().get()
fun yourLogic(): YourLogic = KoinPlatform.getKoin().get()
// Swift-friendly factory: CSV strings instead of Kotlin lists
fun makeThing(name: String, tagsCsv: String, count: Int): Thing =
Thing(name = name, tags = tagsCsv.split(',').map { it.trim() }.filter { it.isNotEmpty() })
}
In iosApp/iOSApp.swift, start Koin once at launch (in App.init() or the AppDelegate):
import SwiftUI
import iosRuntime
@main
struct iOSApp: App {
init() {
SharedRuntime.shared.start(config: nil)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
4. iOS screen structure — Screen + Root + ViewModel
Mirror the Compose Screen / Content split with native SwiftUI. Each feature folder holds:
FeatureX/
HomeScreen.swift # pure, previewable view
HomeRootScreen.swift # wires ViewModel + lifecycle + sheets/alerts
HomeViewModel.swift # @MainActor ObservableObject, MVI
HomeScreen — dumb, previewable
Pure SwiftUI, primitives + closures only, no ViewModel. Add #Preview so it previews in Xcode:
import SwiftUI
struct HomeScreen: View {
let greeting: String
let items: [HomeItem]
let isLoading: Bool
let onStart: () -> Void
let onOpen: (Int) -> Void
var body: some View {
List {
Section {
Button(action: onStart) { Label("New", systemImage: "plus") }
}
Section {
if isLoading {
ProgressView()
} else if items.isEmpty {
ContentUnavailableView("Nothing here", systemImage: "tray")
} else {
ForEach(items) { item in
Button { onOpen(item.id) } label: { Text(item.title) }
}
}
}
}
.listStyle(.insetGrouped)
.navigationTitle(greeting)
}
}
#Preview("Home") {
NavigationStack {
HomeScreen(
greeting: "Hello, Jamie",
items: [HomeItem(id: 1, title: "First")],
isLoading: false,
onStart: {},
onOpen: { _ in }
)
}
}
HomeViewModel — MVI (State / Actions / Events)
@MainActor final class ... : ObservableObject. Follow the Starter MVI semantics in Swift:
- State —
@Published private(set)properties (the screen's state). - Actions — methods the UI calls (
openEditor,save, ...). - Events — one-shot UI signals (presented via sheets/alerts bindings, or a published
event+ConsumedEventpattern).
import SwiftUI
import iosRuntime
@MainActor
final class HomeViewModel: ObservableObject {
@Published private(set) var greeting: String = "Hello"
@Published private(set) var items: [HomeItem] = []
@Published private(set) var isLoading = true
@Published var errorMessage: String?
private var observeTask: Task<Void, Never>?
func start() {
refresh()
observeTask?.cancel()
observeTask = Task { await observe() }
}
func stop() {
observeTask?.cancel()
observeTask = nil
}
func open(_ id: Int) { /* action */ }
private func refresh() {
let runtime = SharedRuntime.shared
greeting = runtime.greeting()
}
private func observe() async {
let repo = SharedRuntime.shared.yourRepository()
for await data in repo.observeItems() { // SKIE turns Flow into AsyncSequence
if Task.isCancelled { return }
items = data.map { HomeItem(id: Int($0.id), title: $0.title) }
isLoading = false
}
}
}
HomeRootScreen — wire it together
Owns the @StateObject ViewModel, calls start()/stop(), and presents sheets/alerts from ViewModel state:
struct HomeRootScreen: View {
@StateObject private var viewModel = HomeViewModel()
var body: some View {
HomeScreen(
greeting: viewModel.greeting,
items: viewModel.items,
isLoading: viewModel.isLoading,
onStart: { viewModel.open(0) },
onOpen: { viewModel.open($0) }
)
.task { viewModel.start() }
.onDisappear { viewModel.stop() }
.alert("Error", isPresented: Binding(
get: { viewModel.errorMessage != nil },
set: { if !$0 { viewModel.errorMessage = nil } }
)) {
Button("OK", role: .cancel) { viewModel.errorMessage = nil }
} message: {
Text(viewModel.errorMessage ?? "")
}
}
}
Rules
- No shared UI. Android =
composeApp(Compose), iOS = native SwiftUI. - The iOS framework must export no Compose code — only data/domain/database modules.
- Apply SKIE only in the framework-building module (
ios-runtime); disable its analytics. - Start Koin exactly once in the iOS app entry point.
- Every screen gets a previewable, dumb
Screenview + aRootthat wires the ViewModel. - Follow the Apple Human Interface Guidelines for SwiftUI views (native
List,NavigationStack,.insetGrouped,ContentUnavailableView, SF Symbols, large titles). - Keep MVI:
@Publishedstate, action methods, one-shot events via bindings. - Bridge Swift-unfriendly Kotlin (default params,
List/Map) with explicit wrappers inSharedRuntime.
Reference
- SKIE: https://github.com/touchlab/SKIE · installation · configuration
- MVI semantics:
../mvi/SKILL.md - Koin wiring:
../koin/SKILL.md - Feature slices (data/domain):
../architecture/SKILL.md
Signals
- GitHub stars
- 162
- Forks
- 28
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
kmp-starter-native-app- Source
- github.com/devatrii/kmp-starter-template