
Compose Multiplatform is an amazing library that allows you to share UI code between Android and iOS. However, there are cases where you need to implement native UI components. A common example is when you want to use a UI component from a platform-specific library, which requires a native implementation.
On Android, this is straightforward since Compose is already native. But on iOS, integrating Objective-C code via UIKitView can be difficult—or even impossible in some cases. That’s when creating a native SwiftUI component becomes the best solution.
In this post, we’ll explore how to build a SwiftUI component, manage its view state to make it reactive, and connect it to your Kotlin code in a Compose Multiplatform project.
In this post, we’ll build a simple native text view for both Android and iOS, using Jetpack Compose and SwiftUI, respectively. A shared ViewModel will emit values every 10 seconds, and our native views will reactively display the latest emitted value.
This example is intentionally simple to focus on how to create native views and manage their reactive state, especially on the SwiftUI side, which can be more challenging.
The same result could be achieved entirely with Compose Multiplatform, but here our goal is to explore interoperability and state management with native components.
In this example, we’ll use Koin to easily manage our ViewModel:
[versions]
koin = "4.0.4"
[libraries]
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-androidx-compose = { module = "io.insert-koin:koin-androidx-compose", version.ref = "koin" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
In this post, I won’t cover the Koin setup, as it’s not the focus. However, you can always refer to the full code in the linked GitHub repository.
For this example, we’ll have just a single Home screen.
We’ll create a HomeViewModel, which will be responsible for managing the uiState and updating the displayed string every 10 seconds.
class HomeViewModel: ViewModel() {
private val _uiState = MutableStateFlow(HomeUIState())
val uiState = _uiState.asStateFlow()
private val words = listOf("kotlin", "programming", "android", "mobile", "iOS", "swift", "KMP")
init {
getRandomWord()
}
private fun getRandomWord() {
viewModelScope.launch(Dispatchers.IO) {
_uiState.update { it.copy(randomWord = words.random()) }
delay(duration = 10.seconds)
getRandomWord()
}
}
}
Let’s begin implementing the native view by creating a new Kotlin file named NativeTextView inside the commonMain folder. In this file, we’ll define an expect composable function that will be responsible for rendering the native TextView on each platform.
@Composable
expect fun NativeTextView(
modifier: Modifier,
value: String,
)
Now let’s implement the Android-specific version, which simply calls the Jetpack Compose Text function and passes the arguments received by NativeTextView. It's a straightforward implementation.
@Composable
actual fun NativeTextView(
modifier: Modifier,
value: String,
) {
Text(
modifier = modifier,
text = value,
textAlign = TextAlign.Center,
)
}
On iOS, things are a bit more complex. We’ll define a Kotlin interface and implement it in Swift, allowing us to call Swift code from Kotlin through this bridge.
Before we build the interface, we need a way to send a mutable state that updates over time. If we simply pass the current value received in NativeTextView, any future updates to that value won't be reflected.
To handle this properly, we need to send a Flow that emits updates — but since Flow isn't directly supported in Swift, we'll need to create a wrapper that bridges the Flow to something consumable in Swift code.
To build the Flow wrapper, we’ll begin by creating a functional interface called DisposableHandle in the iosMain source set. This interface will implement kotlinx.coroutines.DisposableHandle, enabling us to manage the lifecycle of observers and ensuring the wrapper is usable from Swift code.
fun interface DisposableHandle: kotlinx.coroutines.DisposableHandle
Now we need to create a CommonFlow class inside the iosMain folder, which will act as a wrapper around a generic Flow<T>. This wrapper will expose a public subscribe function that can be called from Swift code. The subscribe function will take a callback to deliver the most recent emitted value from the flow.
Additionally, we’ll implement an extension function to make it easier to convert any regular Kotlin Flow<T> into a CommonFlow<T>.
class CommonFlow<T>(
private val flow: Flow<T>
): Flow<T> by flow {
private fun subscribe(
coroutineScope: CoroutineScope,
dispatcher: CoroutineDispatcher,
onCollect: (T) -> Unit,
): DisposableHandle {
val job = coroutineScope.launch(dispatcher) {
flow.collect(onCollect)
}
return DisposableHandle { job.cancel() }
}
@OptIn(DelicateCoroutinesApi::class)
fun subscribe(
onCollect: (T) -> Unit
): DisposableHandle {
return subscribe(
coroutineScope = GlobalScope,
dispatcher = Dispatchers.Main,
onCollect = onCollect,
)
}
}
fun <T> Flow<T>.toCommonFlow() = CommonFlow(this)
Now that we’ve created the wrapper to handle reactive state, we’ll move on to setting up the connection between Kotlin and Swift. Inside the iosMain folder, we’re going to define an interface called NativeViewFactory.
This interface will act as a bridge between Kotlin and Swift, allowing us to declare functions in Kotlin that will be implemented natively in Swift. You can define multiple functions here for different native components. In this example, we’ll have just a single function to create our native SwiftUI TextView.
interface NativeViewFactory {
fun createTextView(
value: CommonFlow<String>,
): UIViewController
}
Now it’s time to switch over to Xcode — every mobile developer’s favorite playground 😄 — and create our native iOS component, which in this case is a simple TextView.
First, we need to create an ObservableObject called TextViewStateObserver. This class will observe the Flow sent from the Kotlin side. In this example, we’re only observing a single flow. However, if you need to observe multiple values, you can either pass multiple flows or wrap the values in a data class and send it as a single flow. Using a single flow helps to reduce boilerplate code.
import ComposeApp
class TextViewStateObserver: ObservableObject {
@Published var value: String = ""
private var disposableHandle: DisposableHandle?
func startObserving(flow: CommonFlow<NSString>) {
guard disposableHandle == nil else { return }
disposableHandle = flow.subscribe(onCollect: { newValue in
self.value = (newValue ?? "") as String
})
}
func stopObserving() {
disposableHandle?.dispose()
disposableHandle = nil
}
deinit {
stopObserving()
}
}
Note: Don’t forget to build the shared Kotlin Multiplatform module before working in Xcode — otherwise, DisposableHandle and CommonFlow won’t be recognized by the Swift compiler.
The next step is to build our SwiftUI component. Let’s create a new struct called TextView and use the previously created TextViewStateOberver to manage our mutable state.
import SwiftUI
import ComposeApp
struct TextView: View {
@StateObject private var textViewStateObserver = TextViewStateObserver()
var valueFlow: CommonFlow<NSString>
var body: some View {
Text(textViewStateObserver.value)
.onAppear {
textViewStateObserver.startObserving(flow: valueFlow)
}
.onDisappear {
textViewStateObserver.stopObserving()
}
}
}
At this moment let's create the IOSNativeViewFactory class, which implements the NativeViewFactory interface and returns the TextView component we previously implemented.
import SwiftUI
import ComposeApp
class IOSNativeViewFactory: NativeViewFactory {
static var shared = IOSNativeViewFactory()
func createTextView(value: CommonFlow<NSString>) -> UIViewController {
let view = TextView(valueFlow: value)
return UIHostingController(rootView: view)
}
}
Now that we’ve implemented NativeViewFactory, let's switch back to Android Studio and open the MainViewController inside the iosMain folder. Here, we'll create a CompositionLocalProvider to hold the NativeViewFactory instance.
This will allow us to access it from any Compose function that needs to render a native component defined in NativeViewFactory.
val LocalNativeViewFactory = staticCompositionLocalOf<NativeViewFactory> {
error("No view factory provided.")
}
fun MainViewController(
nativeViewFactory: NativeViewFactory,
) = ComposeUIViewController {
CompositionLocalProvider(LocalNativeViewFactory provides nativeViewFactory) {
App()
}
}
Moving back to Xcode, open the ContentView file. We'll need to update it to pass the new parameter—our NativeViewFactory implementation—into MainViewController, as it's now required.
import UIKit
import SwiftUI
import ComposeApp
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
MainViewControllerKt.MainViewController(
nativeViewFactory: IOSNativeViewFactory.shared
)
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
}
struct ContentView: View {
var body: some View {
ComposeView()
.ignoresSafeArea(.keyboard) // Compose has own keyboard handler
}
}
We’re almost at the finish line — just a few meters to go! 😄
As you may have noticed, our NativeViewFactory expects a CommonFlow, but in our NativeTextView composable, we're currently just passing a String.
So now, we’ll create a small helper function that transforms a String into a CommonFlow. This function will use remember to retain the flow and emit updated values whenever the string changes.
@Composable
fun <T> rememberCommonFlowState(
value: T,
vararg keys: Any? = arrayOf(value),
): CommonFlow<T> {
val flow = remember { MutableStateFlow(value) }
LaunchedEffect(*keys) {
flow.emit(value)
}
return flow.toCommonFlow()
}
At this point, we have everything we need to implement NativeTextView on the iOS side. We'll use LocalNativeViewFactory to access our NativeViewFactory and call the createTextView function. We'll also use the previously implemented rememberCommonFlowState helper to convert our simple String value into a CommonFlow.
So let’s now implement the NativeTextView for iOS.
@Composable
actual fun NativeTextView(
modifier: Modifier,
value: String,
) {
val valueFlow = rememberCommonFlowState(value = value)
val factory = LocalNativeViewFactory.current
UIKitViewController(
modifier = modifier,
factory = { factory.createTextView(value = valueFlow) }
)
}
Finally it’s time to test our implementation! Let’s create the HomeScreen, call our NativeTextView, and see it in action.
@Composable
fun HomeScreen() {
val viewModel = koinViewModel<HomeViewModel>()
val uiState = viewModel.uiState.collectAsStateWithLifecycle()
Box(
modifier = Modifier.fillMaxSize(),
) {
NativeTextView(
modifier = Modifier
.height(40.dp)
.fillMaxWidth()
.align(Alignment.Center),
value = uiState.value.randomWord,
)
}
}
view raw
Note: We always need to provide a height; otherwise, SwiftUI won’t render anything.
If all steps were followed correctly, you should see the value updating every 10 seconds.
Throughout this post, we explored how to integrate SwiftUI into a Compose Multiplatform project and manage reactive state in SwiftUI. This approach involves a fair amount of boilerplate code, but I hope JetBrains will simplify the process in the future.
If you’re looking for a more streamlined solution, I recommend checking out the excellent Compose Swift Bridge library by Touchlab. It significantly reduces boilerplate code and offers a more direct and easy integration.
However, I personally prefer using a custom implementation. Since Compose Swift Bridge relies on SKIE, adopting new Kotlin versions can be delayed until SKIE provides compatibility. The projects I work on prioritize staying up-to-date with the latest Kotlin releases; having our own solution avoids this dependency issue and offers greater flexibility.
I hope you enjoyed this post and found it helpful for your Compose Multiplatform development journey.
If you’d like to see the full example code, you can check out the complete project in this repository — everything you need is there.