Mixing SwiftUI and Jetpack Compose in a Kotlin Multiplatform Project for iOS

Cazimir Roman
5 min read1 day ago

--

Photo by rajat sarki on Unsplash

Kotlin Multiplatform (KMP) is a powerful tool for sharing code between different platforms. But what if you want to leverage the native UI frameworks of each platform — SwiftUI for iOS and Jetpack Compose for Android — within your KMP project? This article guides you through the process of integrating both, allowing you to use platform-specific UI elements where needed while still sharing business logic and other non-UI code.

The Goal: Platform-Specific UI, Shared Logic

Our objective is to build a simple application that displays a button. On Android, this button will be a standard Jetpack Compose `Button`. On iOS, it will be a native SwiftUI `Button`. We’ll also include a shared Compose `Button` to demonstrate how to mix both approaches. This pattern allows you to maximize the native look and feel of each platform while minimizing code duplication.

What You Need

Before we dive into the code, let’s ensure you have the necessary prerequisites:

  • Kotlin Multiplatform Project: Set up a KMP project in your IDE (IntelliJ IDEA or Android Studio with the KMP plugin).
  • Basic Knowledge of Kotlin, SwiftUI, and Jetpack Compose: Familiarity with these technologies is essential.
  • XCode: For compiling IOS code.

Steps

We’ll break down the process into manageable steps, starting with the shared code and then moving to the platform-specific implementations.

The `expect` Declaratio

We start in the commonMain source set of your shared KMP module. Here, we’ll define an `expect` declaration for our platform-specific button. This declaration tells the compiler that we *expect* a platform-specific implementation (`actual` implementation) to be provided in both `androidMain` and `iosMain`. This is a core concept of KMP’s expect/actual mechanism.

@Composable
expect fun PlatformSpecificButton()

Android Implementation

In the `androidMain` source set, we provide the actual implementation for Android. This is straightforward, as we can directly use Jetpack Compose’s Button. We’re fulfilling the “expectation” set in commonMain.

@Composable
actual fun PlatformSpecificButton() {
Button(onClick = {}) {
Text(text = "Click me")
}
}

iOS Implementation (actual): Bridging to SwiftUI

This is where the integration with SwiftUI happens. We’ll use a combination of Kotlin/Native, Compose for iOS, and a factory pattern to create a bridge between our shared KMP code and the native SwiftUI Button.

// 1. Define the NativeViewFactory interface
interface NativeViewFactory {
fun createButton(text: String): UIViewController
}

// 2. Define LocalNativeViewFactory
val LocalNativeViewFactory = staticCompositionLocalOf<NativeViewFactory> {
error("LocalNativeViewFactory not provided")
}

// 3. Create the ViewController function (to be called from Swift UI)
fun ViewController(
nativeViewFactory: NativeViewFactory
) = ComposeUIViewController {
CompositionLocalProvider(LocalNativeViewFactory provides nativeViewFactory) {
Column {
// Native iOS Button (using PlatformSpecificButton)
PlatformSpecificButton {
Text("Click me") // This text is Compose Text, not swiftUI
}

// Shared Compose Button (for comparison)
Button(onClick = {}) {
Text(text = "Click me")
}
}
}
}

// 4. Create the ViewController function (to be called from Swift UI)
@Composable
actual fun PlatformSpecificButton() {

val factory = LocalNativeViewFactory.current

UIKitViewController(
factory = { factory.createButton("Click me") },
modifier = Modifier.size(100.dp)
)
}

Let’s break this down:

  • NativeViewFactory interface: This is our bridge to SwiftUI. We’re defining a way to create native UI elements (in this case, a button) from within our Compose code. We will implement this interface in SwiftUI.
  • LocalNativeViewFactory: The LocalNativeViewFactory acts as a dependency injection mechanism, similar to how you might use LocalContext in Android Compose. It allows us to provide the NativeViewFactory instance to our Composable functions.
  • ComposeUIViewController: This is a crucial part of the KMP Compose UI library. It’s a subclass of UIViewController that hosts a Compose UI. We’ll use it as a container to host both our Compose UI and our bridged SwiftUI components.
  • PlatformSpecificButton Implementation: This is the actual implementation for iOS. Notice how it uses LocalNativeViewFactory.current to get the factory and, via UIKitView, create the native button view. The content (our Compose Text) is passed into the native button.
  • Column: We put both the native SwiftUI button and the shared Compose button in a Column so they stack vertically. This demonstrates how you can combine Compose and native UI elements.
  • CompositionLocalProvider: This makes the NativeViewFactory available to all Composables within its scope (our Column in this case). It’s how we inject the factory.
  • UIKitView: We use the factory pattern so we can initialize any SwiftUI element within our KMP code by creating UIKitView and passing the SwiftUI element into the factory parameter.

SwiftUI Implementation (iOS Project)

Now, we move to the Xcode project to implement the Swift side of the bridge. This involves creating a concrete implementation of our NativeViewFactory and integrating our KMP-defined view controller into a SwiftUI view.

import Foundation
import SwiftUI
import UIKit
import shared

class iOSNativeViewFactory : NativeViewFactory {

static var shared = iOSNativeViewFactory()

func createButton(text: String) -> UIViewController {
let swiftUIView = Button(text){
print("Clicked")
}
return UIHostingController(rootView: swiftUIView)
}
}
struct ContentView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> some UIViewController {
// Notice how we call our Kotlin-defined ViewController function here
MainViewControllerKt.ViewController(nativeViewFactory: iOSNativeViewFactory.shared)
}

func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {

}

}
@main
struct iOSApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}

Key points here:

  • iOSNativeViewFactory: This class implements the createButton method, creating a SwiftUI Button and wrapping it in a UIHostingController. UIHostingController is SwiftUI’s way of embedding SwiftUI views within UIKit view controllers.
  • ContentView: This struct conforms to UIViewControllerRepresentable. This protocol is SwiftUI’s way of using UIKit view controllers within SwiftUI views. makeUIViewController is the crucial function; it creates and returns our Kotlin-defined ViewController.
  • MainViewControllerKt.ViewController(…): We’re calling the Kotlin function directly! This is possible because KMP generates the necessary bridging code. We pass in our iOSNativeViewFactory.shared instance.

Running the App

Now, let’s see the results!

Build and run both the Android and iOS apps. You should see:

  • Android: A single Compose Button (platform-specific).
  • iOS: A native SwiftUI button (created via our factory) and a Compose Button (from the shared code). The SwiftUI button will have the native iOS look and feel.

Conclusion: The Power of Hybrid UI in KMP

This example demonstrates a powerful technique for building truly cross-platform applications with Kotlin Multiplatform. By combining the declarative power of Jetpack Compose with the native capabilities of SwiftUI, you can create apps that:

  • Share core logic: Reduce code duplication and maintenance effort by sharing business logic, data models, and networking code between platforms.
  • Provide a native user experience: Leverage the platform-specific UI frameworks to create a familiar and performant experience for users on each platform.
  • Maintain flexibility: Choose the right tool for the job — use Compose for shared UI elements or platform-specific UI frameworks where you need that extra level of native integration.

This approach gives you the best of both worlds: code reusability and native performance. It’s a strategy worth considering for any KMP project where UI fidelity is a priority. The provided example can be easily extended and you could provide your very complex native implementation for both platforms.

Did you find this article helpful? Let me know on Medium by giving it a clap! 👏 The more claps, the more I know to create content you love.

If you have any queries related to Android or KMP, I’m always happy to help you. You can reach me on LinkedIn.

Happy Learning🚀 Happy Coding📱

--

--

Cazimir Roman
Cazimir Roman

Written by Cazimir Roman

A curious developer with a passion for learning and creating innovative solutions to complex problems.

No responses yet