Sitemap

Native at heart: Mixing SwiftUi with Compose Multiplatform

Sammuigai
4 min readJun 3, 2025

With Compose Multiplatform now stable for IOS, it’s an encouraging time for developers to consider using it in production. While CMP allows for sharing UI code across platforms, there are situations where incorporating native UI code becomes necessary. In this article, we’ll explore the reasons for using SwiftUI within your project and demonstrate how to integrate it effectively.

Why?

While there aren’t many reasons to use native UI when working with Compose Multiplatform, one of the most common is the need to integrate libraries that don’t support Compose Multiplatform. Some libraries depend heavily on the native UI framework of their respective platforms, leaving developers with no choice but to use native UI components. A good example of this is the Stripe payment library, among others.

Let’s get started

According to the documentation, there is an way to implement this — you can check it out here. While the approach works, I believe it can become problematic as the app grows, especially if multiple native UI components are needed in different parts of the app. This is because it would require passing all these components down through the composable hierarchy, which can clutter the parameter lists and reduce code readability.

In the section below, we’ll explore an alternative approach that helps keep the codebase clean and scalable.

In a new Compose Multiplatform project, we expose the App() composable to iOS via a UIViewController.

import androidx.compose.ui.window.ComposeUIViewController

fun MainViewController() = ComposeUIViewController { App() }

The ComposeUiViewController function returns a UIViewController, which is a UIKit component. From the iOS side, we can access this function to embed the Compose UI.

import UIKit
import SwiftUI
import ComposeApp

struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController()
}

func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

In the above code, we call the MainViewController function inside makeUIViewController. This function is part of the UIViewControllerRepresentable protocol (interface), so we are providing its implementation here. UIViewControllerRepresentable acts as a bridge, allowing us to integrate UIKit with SwiftUI. Therefore, ComposeView is a SwiftUI component.

With this in mind, let’s see how we can access a SwiftUI component to create a multiplatform UI.

For this example, we’ll use a button. To access the button across platforms, we can follow the expect/actual pattern. On the Android side, we can implement the button using standard Jetpack Compose.

// Common code
@Composable
expect fun NativeButton(modifier: Modifier = Modifier, onClick: () -> Unit)

// Android
@Composable
actual fun NativeButton(modifier: Modifier,onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = modifier
) {
Text("HELLO FROM ANDROID")
}
}

// Ios
@Composable
actual fun NativeButton(modifier: Modifier,onClick: () -> Unit) {
// Implementation goes here
}

We can only access SwiftUI as a UIKit component within Compose. Therefore, we can provide an interface that will be implemented in Swift code, with methods that return a UIViewController.

interface NativeViewFactory {
fun nativeButtonView(onClick: () -> Unit): UIViewController
}

In the swift code we can provide the implementation as shown below:

struct NativeButton:View {
let onClick:() -> Void
var body: some View {
Button(action: onClick,label: {
Text("HELLO FROM IOS")
.padding(16)
.background(Color.red)

})
}
}

class NativeViewFactoryImpl: NativeViewFactory {
func nativeButtonView(onClick: @escaping () -> Void) -> UIViewController {
UIHostingController(rootView: NativeButton(onClick: onClick))
}
}

In the code, we provide the implementation of NativeViewFactory and use UIHostingController, which helps convert SwiftUI views to UIKit. Now, we need to provide NativeViewFactory within our App() so that we can easily use the views wherever needed. We take advantage of CompositionLocals to achieve this. You can learn more about CompositionLocals here.

We pass the implementation of NativeViewFactory to the MainViewController function and use CompositionLocalProvider to inject the provided nativeViewFactory into the Compose hierarchy, making it accessible deeper in the UI tree.

fun MainViewController(
nativeViewFactory: NativeViewFactory
): UIViewController = ComposeUIViewController(
content = {
CompositionLocalProvider(LocalNativeViewFactory provides nativeViewFactory){
App()
}
}
)

val LocalNativeViewFactory = compositionLocalOf<NativeViewFactory> { error("NativeViewFactory not provided") }

interface NativeViewFactory {
fun nativeButtonView(onClick: () -> Unit): UIViewController
}

We can now retrieve the injected NativeViewFactory from the CompositionLocal and use it in the UIKitViewController composable function. UIKitViewController is a helper that embeds a UIViewController inside Compose.

// Ios
@Composable
actual fun NativeButton(modifier: Modifier,onClick: () -> Unit) {
val factory = LocalNativeViewFactory.current
UIKitViewController(
factory = { factory.nativeButtonView(onClick = onClick) },
modifier = modifier
.fillMaxWidth()
.height(50.dp),
)
}

When we call the MainViewController function in Swift, we can now provide the implementation of the NativeViewFactory.

struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController(nativeViewFactory: NativeViewFactoryImpl())
}

func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}

And just like that, we can use a SwiftUI view in a Compose Multiplatform app. This makes it easier to add new SwiftUI views whenever needed. Thank you so much for reading! If you found this helpful, don’t forget to leave a clap.

--

--

Sammuigai
Sammuigai

Responses (1)