DEV Community

Cover image for KMP Native UI view model injection for iOS and Android
Saad Alkentar
Saad Alkentar

Posted on

KMP Native UI view model injection for iOS and Android

Cross-platform development significantly reduces overhead for both developers and organizations. While React Native leveraged web expertise and Flutter introduced a unified UI for multi-platform delivery, Kotlin Multiplatform (KMP) represents the next evolution. KMP offers the flexibility of Compose for shared UI, while uniquely allowing developers to implement native interfaces where maximum performance and stability are required—all while maintaining a single shared logic base in Kotlin.

Finding comprehensive resources for Kotlin Multiplatform (KMP) using Native UI can be challenging, so this series aims to bridge that gap.

We will build a production-ready foundation using a modular Clean Architecture—the industry standard for scalable apps. Our roadmap covers the essential pillars of modern development:

  • Dependency Injection,
  • Networking,
  • and Architecture.

In this article, we will test the dependency injection setup by injecting a view model for iOS and Android. Be sure to follow the previous article for setting up Koin di.

View model library

  • let's start by updating the shared module shared/build.gradle.kts
...
kotlin {
    ...
    sourceSets {
        commonMain.dependencies {
            ...
            implementation(libs.androidx.lifecycle.viewmodelCompose)

        }
        ...
    }
}
Enter fullscreen mode Exit fullscreen mode

It should already be in the toml file, if not, you can add it

[versions]
androidx-lifecycle = "2.9.6"

[libraries]
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
Enter fullscreen mode Exit fullscreen mode

Let's create a simple view model to make sure DI works as expected

A simple shared ViewModel

In the shared module, we create a view model.

// shared/kotlin/home/HomeViewModel.kt

import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow

class HomeViewModel : ViewModel() {
    private val _state = MutableStateFlow("initial state")

    val state: StateFlow<String> = _state.asStateFlow()

    fun updateString(newString: String) {
        _state.value = newString
    }
}
Enter fullscreen mode Exit fullscreen mode

View models usage in Android

We will update the App.kt to test our new view model.

// composeApp/kotlin/App.kt

...
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import com.irsc.kotlinnativedemo.home.presentation.HomeViewModel
kotlinnativedemo.composeapp.generated.resources.compose_multiplatform
import org.koin.java.KoinJavaComponent.inject

@Composable
@Preview
fun App() {
    val viewModel: HomeViewModel by inject(HomeViewModel::class.java)
    val state by viewModel.state.collectAsStateWithLifecycle()

    MaterialTheme {
        var showContent by remember { mutableStateOf(false) }
        Column(
            modifier = Modifier
                .background(MaterialTheme.colorScheme.primaryContainer)
                .safeContentPadding()
                .fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
        ) {
            Button(onClick = {
                showContent = !showContent
                viewModel.updateString("new string")
            }) {
                Text("Click me!")
            }
            Text(state)
            ...
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Clicking the button should update the state, and the state flow should update the text under the button. Now we are sure the DI works in Android, the easy one, next is the iOS...

View models usage in iOS

Using view models in iOS is more challenging; the Observable class is actually the iOS ViewModel, but it is kinda tricky mapping them, according to this we have two options

  • KMP-NativeCoroutines: the recommended one for now
  • SKIE: I think it is better, but it does not support Kotlin 2.3.20; this may have changed by the time you read this article. I should create another article for it by the time.

KMP-NativeCoroutines libraries setup

  • We start by adding the plugins to the toml file
[versions]
ksp = "2.3.6"
nativecoroutines = "1.0.2"

[plugins]
koin-compiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" }
devtools-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
kmp-nativecoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "nativecoroutines" }

Enter fullscreen mode Exit fullscreen mode

make sure to sync after every toml file update to help the IDE

  • Next, we update the root project build.gradle.kts
// build.gradle.kts
plugins {
    // this is necessary to avoid the plugins to be loaded multiple times
    // in each subproject's classloader
    ...

    alias(libs.plugins.devtools.ksp) apply false
    alias(libs.plugins.kmp.nativecoroutines) apply false

}
Enter fullscreen mode Exit fullscreen mode
  • Then, we update the shared module build.gradle.kts
// shared/build.gradle.kts

import org.jetbrains.kotlin.gradle.dsl.JvmTarget

plugins {
    alias(libs.plugins.kotlinMultiplatform)
    alias(libs.plugins.androidLibrary)

    alias(libs.plugins.koin.compiler)
    alias(libs.plugins.devtools.ksp) // new
    alias(libs.plugins.kmp.nativecoroutines) // new
}
...
Enter fullscreen mode Exit fullscreen mode
  • and finally, the shared module sourceSets
// shared/build.gradle.kts
kotlin {
    // ...
    sourceSets{
        all {
            languageSettings {
                optIn("kotlin.experimental.ExperimentalObjCName")
                optIn("kotlin.time.ExperimentalTime")
            }
        }
        // ...
    }
}
Enter fullscreen mode Exit fullscreen mode

Nice, this should work, let's build the project ...

> Task :shared:compileKotlinIosSimulatorArm64 FAILED
error: org.jetbrains.kotlin.cli.jvm.plugins.PluginCliParser$PluginProcessingError: Plugin com.rickclephas.kmp.nativecoroutines.compiler.KmpNativeCoroutinesCompilerPluginRegistrar is incompatible with the current version of the compiler.
error: org.jetbrains.kotlin.cli.jvm.plugins.PluginCliParser$PluginProcessingError: Plugin com.rickclephas.kmp.nativecoroutines.compiler.KmpNativeCoroutinesCompilerPluginRegistrar is incompatible with the current version of the compiler.

org.jetbrains.kotlin.cli.jvm.plugins.PluginCliParser$PluginProcessingError: Plugin com.rickclephas.kmp.nativecoroutines.compiler.KmpNativeCoroutinesCompilerPluginRegistrar is incompatible with the current version of the compiler.

Caused by: java.lang.AbstractMethodError: Missing implementation of resolved method 'abstract java.lang.String getPluginId()' of abstract class org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar.
Caused by: java.lang.AbstractMethodError: Missing implementation of resolved method 'abstract java.lang.String getPluginId()' of abstract class org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar.
Enter fullscreen mode Exit fullscreen mode

Those errors confused me a lot. Finding error causes in 3 platforms is kinda ugly. In short, the problem is in our start, the Kotlin version. let's update it.

[versions]
...
kotlin = "2.3.20"
...
Enter fullscreen mode Exit fullscreen mode

Syncing and building again should resolve the error.

KMP-NativeCoroutines shared code update

We should inform the NativeCoroutines of our public state flow, there are multiple options like @NativeCoroutines and @NativeCoroutinesState. Personally, I suggest to always use NativeCoroutinesState. Let's update our view model class

// shared/kotlin/home/HomeViewController.kt

import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState

class HomeViewModel : ViewModel() {
    private val _state = MutableStateFlow("initial state")

    @NativeCoroutinesState // new
    val state: StateFlow<String> = _state.asStateFlow()

    fun updateString(newString: String) {
        _state.value = newString
    }
}
Enter fullscreen mode Exit fullscreen mode

Great, we need to update the iOS native project next ...

KMP-NativeCoroutines iOS project update

We need to open the iOS project in Xcode, choose to add a package dependency

Add package dependencies

look for

https://github.com/rickclephas/KMP-NativeCoroutines.git
Enter fullscreen mode Exit fullscreen mode

and install KMPNativeCoroutinesAsync, KMPNativeCoroutinesCombine, and KMPNativeCoroutinesCore and finally click add packages

Add packages

Nice, now we can use KMP-NativeCoroutines in iOS, let's go back to Android Studio.
PS. I know you tried to build it in Xcode and faced No such module 'Shared', we will solve this in another article

The ViewModel in Swift UI

I wanted to map ViewModel directly to @Observable Swift class, but it is still work in progress, for now we will wrap it with ObservableObject. Before that, we need to create a DI helper class

// shared/src/iosMain/kotlin/di/KoinHelper.kt

import com.irsc.kotlinnativedemo.home.presentation.HomeViewModel
import org.koin.core.component.KoinComponent
import org.koin.core.component.get


class KoinHelper : KoinComponent {
    fun homeViewModel(): HomeViewModel = get()
}
Enter fullscreen mode Exit fullscreen mode

Time to create our own ObservableObject

import Foundation
import Shared
import KMPNativeCoroutinesCombine
import Combine

class HomeViewModelWrapper : ObservableObject {
    @Published var lastUpdated = Date()

    let viewModel: HomeViewModel = KoinHelper().homeViewModel()
    private var cancellables = Set<AnyCancellable>()

    init() {
        // Observe the flow, but we don't need to manually copy every field to Swift anymore
        createPublisher(for: viewModel.stateFlow)
        .receive(on: DispatchQueue.main)
        .sink { _ in } receiveValue: { [weak self] _ in
            // Just trigger a refresh; the View will read from viewModel.state directly
            self?.lastUpdated = Date()
        }
        .store(in: &cancellables)
    }

}

Enter fullscreen mode Exit fullscreen mode

Let's point out a few points here

  • The lastUpdated field is used to trigger UI reload only.
  • We read data and called methods directly from the viewModel.
  • The state field in the view-model is reflected as stateFlow in Swift, because we are using @NativeCoroutinesState; it would be different if we used @NativeCoroutines.
  • This ObservableObject class blueprint should work for any type of view model
  • I still think an @observable class would be better, but I couldn't find the best way to deploy it yet.

Finally, let's test it in the ContentView

import SwiftUI
import Shared

struct ContentView: View {
    @State private var showContent = false
    @StateObject private var wrapper = HomeViewModelWrapper()

    var body: some View {
        VStack {
            Button("Click me!") {
                withAnimation {
                    showContent = !showContent
                    wrapper.viewModel.updateString(newString:"new string")
                }
            }
            Text(wrapper.viewModel.state)

            if showContent {
                VStack(spacing: 16) {
                    Image(systemName: "swift")
                        .font(.system(size: 200))
                        .foregroundColor(.accentColor)
                    Text("SwiftUI: \(Greeting().greet())")
                }
                .transition(.move(edge: .top).combined(with: .opacity))
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
        .padding()
    }
}
Enter fullscreen mode Exit fullscreen mode

That is it, it wasn't too simple for iOS, I know, yet fun and reusable, I hope

We are fixing No such module 'Shared' for Xcode project next time, so
Stay tuned

Top comments (0)