Share Everything Except UI: KMP Native UI Practice with Compose and SwiftUI
The author shares their KMP engineering practice of sharing all business logic via Kotlin while implementing platform-native UIs with Jetpack Compose on Android and SwiftUI on iOS, detailing the module structure, state observation via SKIE, screen layering, and why native UI beats Compose Multiplatform for iOS fidelity.
Why Share Logic But Not UI in KMP
After delivering several Kotlin Multiplatform (KMP) projects, the author adopted a counterintuitive approach: use Jetpack Compose for Android, SwiftUI for iOS, and share all non‑UI code (ViewModels, use cases, domain, data, networking, database, DI) in a single Kotlin module. The goal is to give each platform its native feel while maximizing logic reuse.
KMP vs. Compose Multiplatform (CMP)
KMP compiles shared Kotlin to native binaries — bytecode on Android (ART) and machine code on iOS (via Kotlin/Native). There is no “wrapper” at the binary level. The “non‑native” perception comes entirely from the UI layer. CMP renders Compose components to Skia drawing commands on a Canvas; it does not use UIKit or SwiftUI controls. While CMP allows embedding individual native views via UIKitView, mixing 90% Canvas‑drawn UI with 10% native controls creates interoperability seams that are error‑prone.
What CMP Loses on iOS
List recycling. Native UITableView / List recycles cells automatically; a Canvas‑based list must implement its own recycling, memory management, and scroll performance.
Scrolling and visual effects. System‑provided scroll parallax, rubber‑banding, and liquid‑glass/blur effects are absent on a plain Canvas and must be re‑implemented in Skia — difficult to match exactly.
Navigation transitions. SwiftUI’s push/pop transitions and large‑title collapse behavior belong to the system navigation stack; Canvas drawing has no access to them.
Input method and text details. Keyboard toolbar, text selection menu, long‑press magnifier, and other platform‑standard behaviors are costly to replicate and easy to get wrong.
CMP remains a valid choice when targeting Android, Desktop, and Web with a Compose‑centric look, or when only a few native controls are needed. The “separate UI” strategy applies when the entire iOS interface must feel fully native.
Case Study: Minimal Note App
A demo note app shares ViewModel, DI, domain, data, network, and database — all Kotlin. Android uses Material 3: filled text fields, FAB, filter/sort chips. iOS uses SF Symbols, top‑placed create button, iOS‑style alert buttons, large‑title collapse, and system blur. User capabilities are identical; visual/interaction language is platform‑specific.
Even for heavily custom design systems, the author prefers native implementation because platform conventions (dialog menus, scroll bounce, text selection, keyboard toolbar) come free and correct, whereas Canvas replication is high effort and fragile.
AI Agents Reduce “Write UI Twice” Cost
Because behavior logic lives in shared ViewModels, UI translation becomes a syntax/style mapping task — exactly what AI agents excel at. Feed the Android Compose screen to an agent; it emits SwiftUI that respects the already‑fixed contracts (state sources, click handlers, loading/error states). Human review focuses on native feel: using NavigationStack not Scaffold, placing buttons per platform guidelines. These rules can be codified as agent instructions or templates.
Engineering Challenge: State Observation
SwiftUI doesn’t understand Kotlin StateFlow. The solution is Touchlab’s SKIE (Swift Kotlin Interface Enhancer), a Gradle plugin that enhances the exported Swift headers: Flow / StateFlow → Swift AsyncSequence, consumable with for await.
Sealed interfaces/classes → genuine Swift enums.
Suspend functions and default parameters → Swift‑idiomatic signatures.
This lets SwiftUI observe shared state with familiar concurrency syntax.
Module Structure
composeApp/ # Android entry: Scaffold, Snackbar, TopAppBar
# + NoteListScreen / NoteDetailScreen (pure Compose)
shared/ # KMP shared module: ViewModel, UseCase, domain / data
# + networking, database, DI (all Kotlin)
iosApp/ # iOS entry: ListScreen / DetailScreen (pure SwiftUI)
# only consumes shared ViewModel and stateEach app module is a standard platform entry point; only the UI layer is duplicated.
Screen Layering: Root View + Pure Presentational View
To keep SwiftUI Previews working, each screen splits into:
Pure presentational view — receives a plain state struct and event callbacks; no ViewModel, no dependencies; fully previewable with mock data (empty, loading, error).
Root view — holds the shared ViewModel, bridges StateFlow emissions to local @State, passes them to the presentational view. Not previewable; its only job is wiring.
Example (Swift):
struct NoteDetailRootView: View {
let viewModel: NoteDetailViewModel
@State private var state: NoteDetailState = .initial
var body: some View {
NoteDetailView(
state: state,
onSave: { viewModel.save() },
onDelete: { viewModel.delete() }
)
.task {
for await newState in viewModel.state {
state = newState
}
}
.onDisappear { viewModel.dispose() }
}
}One‑off events (e.g., “save succeeded → pop”) use a Kotlin Channel exposed as an AsyncSequence via SKIE; SwiftUI consumes with for await and triggers navigation.
Critical trade‑off: The shared ViewModel isn’t bound to Android’s lifecycle, so onCleared() isn’t called on iOS. You must manually call viewModel.dispose() in .onDisappear to cancel coroutines and release resources. This line is easily forgotten; the author recommends baking it into code templates.
Alternative: don’t share ViewModels, write a pure Swift presentation layer. Benefit: native lifecycle and DI. Cost: duplicate state‑mapping logic. For state‑heavy apps, sharing pays off — one bug fix, one test suite.
Resources, Other Platforms, and Version Coupling
Resources. CMP’s cross‑platform resource system is unused. Shared code emits only semantic enums (e.g., error types: network / not‑found / unauthorized); each platform maps them to local strings and icons. This keeps the shared layer presentation‑agnostic.
Desktop & Web. Android, Desktop, and Web share one Compose Multiplatform UI (they already align with Compose’s look/feel). Only iOS gets a dedicated SwiftUI implementation — the platform where native feel matters most and divergence is greatest.
SKIE version coupling. SKIE operates at the compiler/export layer and is tightly bound to the Kotlin version. Touchlab updates it promptly, but a brief window may exist where a new Kotlin version isn’t yet supported. Treat SKIE as a critical component that must be upgraded in lockstep.
Conclusion: A Worthwhile Trade‑off
A small amount of Root View boilerplate, disciplined dispose() calls, and a semantic resource layer buy fully native experiences on both platforms. When “feels like iOS” is a requirement, native scrolling, transitions, controls, and system behaviors are hard to replicate with Canvas. KMP’s strength is precisely that it doesn’t force an all‑or‑nothing choice — you share the vast majority of logic while returning the UI layer to each platform. Combined with AI‑assisted UI translation, the cost of “writing UI twice” continues to drop. Teams that value both logic consistency and platform‑grade polish should try this “share everything except UI” split in their next KMP project.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
AndroidPub
Senior Android Developer & Interviewer, regularly sharing original tech articles, learning resources, and practical interview guides. Welcome to follow and contribute!
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
