CryptoNetHub

Share Your Entire UI Layer Across Android, iOS, and Desktop — A Real iOS Walkthrough

9 min readJul 4, 2026--Press enter or click to view image in full sizeFor about six years, the pitch for Kotlin Multiplatform came with an asterisk. “Share your business logic across Android and iOS!” —. . .

By @uiuxcom · · 11 min read

Share Your Entire UI Layer Across Android, iOS, and Desktop — A Real iOS Walkthrough

9 min readJul 4, 2026--Press enter or click to view image in full sizeFor about six years, the pitch for Kotlin Multiplatform came with an asterisk. “Share your business logic across Android and iOS!” — great. “Share your UI too!” — and then everyone got quiet, because the iOS UI story was, for a long time,experimental, thenalpha, thenbeta, and iOS teams had every reason to wait it out. The scrolling felt slightly wrong. Text selection was fiddly. Accessibility was patchy. You could ship it, but you were an early adopter and you knew it. That asterisk is gone. Compose Multiplatform for iOS wentstablewith 1. 8. 0 back in May 2025, and the version I’m writing against — 1. 11. 0, from May 2026 — has kept sanding down the rough edges (it even added an experimental native text-input implementation built onUIView). The core APIs are finalized with compatibility guarantees. Real apps ship on it; JetBrains likes to point at Respawn sharing 96% of its code across Android and iOS, and that number isn't marketing fluff anymore, it's roughly what you'll actually hit. So this is the walkthrough I wish someone had handed me: how to share yourentire UI layer— not just the logic — across Android, iOS, and desktop, with the iOS-specific interop parts spelled out, because that’s the part that used to be the blocker and it’s the part people still worry about. Let me be honest up front about who this is for and where the seams are, because a walkthrough that pretends there are no seams is lying to you. The mental model: one tree, three hostsHere’s the thing to get straight before any code. Compose Multiplatform is Jetpack Compose — the same declarative UI toolkit — rendered on top of Skia instead of only Android’s canvas. On iOS it draws through Metal. On desktop it runs on the JVM. Your@Composablefunctions don't know or care which of these they're running on. AColumn, aButton, aLazyColumn— they compose to the same UI tree, and each platform just supplies a host to hang that tree on. That’s the whole trick. You write the UI once incommonMain, and each platform provides an entry point:Androidhosts it in anActivityviasetContent { }, exactly like a normal Compose app. iOShosts it in aUIViewControllerviaComposeUIViewController { }. Desktophosts it in aWindowviaapplication { Window { } }. Three tiny host files, one shared UI. Everything interesting lives in common. What actually goes whereLet me map a realistic project, because “share your UI” doesn’t mean “delete your platform folders. ” A well-structured shared-UI app looks roughly like this:commonMain— the big one. Your composables, your screens, your navigation graph, your ViewModels (Jetpack ViewModel is multiplatform now, as are Room and DataStore), your design system, your state. If you're doing it right, this is 85–95% of your UI code. androidMain— theActivity, the manifest, anything that touches Android-specific APIs you chose not to abstract. iosMain— theComposeUIViewControllerentry point, plus Kotlin bindings to any iOS-only APIs. desktopMain— themain()andWindow. And then two Swift/Xcode files on the iOS side that most Android developers don’t expect and briefly panic about. We’ll get to those; they’re small. The Android host (five seconds, you already know this)// androidMainclass MainActivity : ComponentActivity() {override fun onCreate(savedInstanceState: Bundle?) {super. onCreate(savedInstanceState)setContent { App() } // App() lives in commonMain}}App()is your shared root composable. Nothing here is new. This is the part that already worked. The desktop host (also basically free)// desktopMainfun main() = application {Window(onCloseRequest = ::exitApplication, title = "My App") {App() // the same App() from commonMain}}That’s a real, running desktop app. The same screens you see on the phone, in a resizable window, for the cost of one file. This is the part people forget is even included — desktop comes along for the ride, and it’s genuinely useful for internal tools, admin panels, or just debugging your layouts on a big screen with a mouse. The iOS host — the part that used to scare peopleHere’s where an Android dev takes a breath, because now there’s Swift involved. It’s less than you fear. Two pieces. Piece one, in Kotlin (iosMain):wrap your sharedApp()in aUIViewController. Compose gives you exactly the function for this:// iosMainfun MainViewController(): UIViewController =ComposeUIViewController {App() // again, the same shared root}ComposeUIViewControlleris aUIViewControllersubclass that hosts a Compose scene. That's the bridge. Your entire Compose UI is now, as far as iOS is concerned, a view controller. Piece two, in Swift (iosApp):hand that view controller to SwiftUI. SwiftUI can't host aUIViewControllerdirectly, so you wrap it in aUIViewControllerRepresentable— the standard Apple bridge for putting UIKit inside SwiftUI:// iOS app, Swiftimport SwiftUIimport ComposeApp // your Kotlin frameworkstruct ComposeView: UIViewControllerRepresentable {func makeUIViewController(context: Context) -> UIViewController {MainViewControllerKt. MainViewController() // your Kotlin function}func updateUIViewController(_ vc: UIViewController, context: Context) {}}@mainstruct iOSApp: App {var body: some Scene {WindowGroup { ComposeView(). ignoresSafeArea() }}}And that’s a complete, shipping iOS app running your shared Compose UI. NoteMainViewControllerKt— Kotlin's top-level functions get exposed to Swift with aKtsuffix on the file name, which is the kind of detail that makes you feel lost for exactly one minute the first time. Get Reza Ramesh’s stories in your inboxJoin Medium for free to get updates from this writer. Remember me for faster sign inIf you stopped reading here, you’d have a fully functional three-platform app. But the reason iOS stabilitymatters, and the reason this article targets interop specifically, is what happens when “share everything” meets “…except this one thing that has to be native. ” So let’s talk about the seams, because that’s where real projects live. Going the other direction: native iOS views inside ComposeThis is the scenario that separates a toy from a shippable app. Sometimes youmustuse a native component. A few real reasons:A nativeMKMapView(MapKit) because you want Apple's real maps, not a web embed. WKWebViewfor genuine native web rendering. Camera viaAVCaptureSession. A vendor SDK whose UI only exists as UIKit/SwiftUI — Stripe’s payment sheet is the classic example. You don’t get to reimplement that; you embed it. Compose Multiplatform embeds native views throughUIKitView(for aUIView) andUIKitViewController(for aUIViewController). Here's the shape for a simple native view:// A native UITextField living inside a Compose layout@OptIn(ExperimentalForeignApi::class)@Composablefun NativeTextField(modifier: Modifier = Modifier) {var message by remember { mutableStateOf("Hello, World!") }UIKitView(factory = {val field = UITextField(CGRectMake(0. 0, 0. 0, 0. 0, 0. 0))// wire up native change events back into Compose state. . . field},modifier = modifier. fillMaxWidth(). height(40. dp),update = { field -> field. text = message } // Compose state -> native)}The two parameters that matter:factorycreates the native view once, andupdateruns whenever your Compose state changes so you can push new values into the native view. Thatupdateblock is the synchronization point between two worlds, and getting it right is 90% of clean interop. The pattern that scales: a native view factoryEmbedding a rawUITextFieldis fine for a demo. In a real app you want yoursharedCompose code to be able to say "give me the native payment button here" withoutcommonMainknowing anything about UIKit — becausecommonMaincompiles for Android and desktop too, where UIKit doesn't exist. The clean way to do this is a factory interface defined in Kotlin, implemented in Swift, and injected through aCompositionLocal. This is the pattern JetBrains' own samples use, and it's worth learning once:// commonMain (or iosMain) — Kotlin sideinterface NativeViewFactory {fun createPaymentButton(onPaid: () -> Unit): UIViewController}val LocalNativeViewFactory =staticCompositionLocalOf {error("NativeViewFactory not provided")}// The iOS entry point now takes the factory and provides it down the treefun MainViewController(factory: NativeViewFactory): UIViewController =ComposeUIViewController {CompositionLocalProvider(LocalNativeViewFactory provides factory) {App()}}Then anywhere deep in your shared UI, on iOS, you pull the factory out of the composition and drop the native view in:@Composablefun PaymentScreen() {val factory = LocalNativeViewFactory. currentColumn {Text("Complete your purchase")UIKitViewController(factory = { factory. createPaymentButton(onPaid = { /* . . . */ }) },modifier = Modifier. fillMaxWidth(). height(56. dp),)}}On the Swift side, you implement the factory with real SwiftUI/UIKit, wrapping SwiftUI views inUIHostingController:class IOSNativeViewFactory: NativeViewFactory {func createPaymentButton(onPaid: @escaping () -> Void) -> UIViewController {UIHostingController(rootView: PaymentButton(onPaid: onPaid))}}Now your shared code declaresintent(“a payment button goes here”) and the platform supplies theimplementation. Android would satisfy the same abstraction with an Android view; desktop with something else or a no-op. That’s the philosophy of the whole framework in one pattern: share the UI, keep an escape hatch to native, and make the escape hatch explicit rather than magic. The honest caveats — read this partI’d be doing you a disservice if I made this sound seamless, so here are the things that will actually cost you time. You need a Mac and Xcode. Building the iOS app means the Apple toolchain. There’s no way around it, same as any iOS development. The nice part is you can deploy straight to the iOS Simulator from Android Studio and even get Compose Previews and Hot Reload, so the day-to-day loop is better than it sounds. The K2 compiler is mandatoryfor native and web targets (Kotlin 2. 1. 0+). If you’re coming from an old KMP project, you must recompile your Compose libraries with K2 to avoid ABI mismatches. This bit people during the 1. 8 migration and it’s still the first thing to check. Interop has a real cost, so don’t over-embed. Every native view you bridge is a synchronization surface between two UI systems, and complex bidirectional state sharing between Compose and SwiftUI gets fiddly fast — Touchlab’s write-ups are candid that there’s “no silver bullet” here. Use native embedding for the things that genuinely need it (maps, camera, vendor SDKs) and resist the urge to sprinkle it everywhere. The 96%-shared apps got that number by keeping the native surface small and deliberate. “Native feel” is close, not identical. JetBrains put serious work into iOS scrolling physics, native text selection, RTL, drag-and-drop, and accessibility (VoiceOver, AssistiveTouch, Full Keyboard Access all supported). It’s genuinely good now. But a discerning iOS usermightstill catch a moment where something feels 95% right instead of 100%. For most apps that gap is invisible; for a design-obsessed consumer app it’s worth prototyping the trickiest screen before you commit. Some iOS devs will prefer a native shell. There’s a legitimate architectural choice here: Compose-Multiplatform-as-the-whole-app (great for Android-led teams) versus a UIKit/SwiftUI app shell that hosts individual shared Compose screens (more palatable to iOS-led teams who want to keep native navigation). Both are supported. Which one fits depends on who’s on your team and what they’re comfortable owning long-term. There’s no universally right answer, and anyone who tells you there is hasn’t shipped both ways. So, should you share your whole UI layer?If you’re building a new app with a small-to-medium team and you want one codebase driving Android, iOS, and desktop — yes. This is the moment the technology actually earned that recommendation, and it took years to get here. The stable label isn’t a vibe; it’s finalized APIs with compatibility guarantees, which is exactly what you need to build something you’ll maintain for five years. If you have a large, mature, design-critical iOS app with a dedicated iOS team that loves SwiftUI — you probably don’t rip it out. But the interop story is good enough that you can sharesomescreens, adopt gradually, and let the shared surface grow only where it pays off. That incremental path is the underrated part of this whole release. The old asterisk was “…but not the UI, not really. ” That asterisk is what’s gone. You can share the UI now. Not perfectly, not for every app, but genuinely, in production, for real. Start with the desktop target if you want a zero-risk taste of it — it’s one file — and work up to the iOS bridge when you’re ready. Then go wrap anApp()in aComposeUIViewControllerand watch your Android UI boot up on an iPhone. It's still a little bit magic the first time. Written against Compose Multiplatform 1. 11. 0 (iOS stable since 1. 8. 0). Interop APIs likeComposeUIViewController,UIKitView, andUIKitViewControllerare the current stable surface — but always check the Kotlin Multiplatform docs for the exact signatures in your version, since the experimental bits (like native text input) are still moving. If you hit a seam I didn't cover, that's the nature of interop; tell me and I'll dig in. Enjoyed this article?If this article helped you solve a problem or learn something new, consider supporting my work. Your support helps me continue writing free, in-depth Android tutorials for the developer community. Press enter or click to view image in full sizeSupport Me — Buy Me a Coffee☕️

Source: endpolicesurveillance.com

More Layer 2 news