How to use Kotlin Multiplatform ViewModel in SwiftUI and Jetpack Compose

We are in IceRock Development we have been using the MVVM approach for many years, and for the last 4 years our ViewModel located in a common code, through the use of our library moko-mvvm. In the last year, we have been actively moving to using Jetpack Compose and SwiftUI to build UI in our projects. And this required MOKO MVVM improvements to make it comfortable for developers on both platforms to work with this approach.

April 30, 2022 released new MOKO MVVM version – 0.13.0. This version has full support for Jetpack Compose and SwiftUI. Let’s take an example of how you can use the ViewModel from the common code with these frameworks.

The example will be simple – an application with an authorization screen. Two input fields – login and password, the Login button and a message about successful login after a second of waiting (while waiting, we turn the progress bar).

Create a project

The first step is simple – take Android Studio, install Kotlin Multiplatform Mobile IDE Pluginif not already installed. Create a project according to the template “Kotlin Multiplatform App” using CocoaPods integration (they are more convenient, plus we still need to connect an additional CocoaPod).

KMM wizard
KMM wizard

git commit

Login screen on Android with Jetpack Compose

The app template uses the standard Android View approach, so we need to include Jetpack Compose before we start layout.

Include in androidApp/build.gradle.kts Compose support:

val composeVersion = "1.1.1"

android {
    // ...

    buildFeatures {
        compose = true
    }
    composeOptions {
        kotlinCompilerExtensionVersion = composeVersion
    }
}

And we connect the dependencies we need, removing the old unnecessary ones (related to the usual approach with View):

dependencies {
    implementation(project(":shared"))

    implementation("androidx.compose.foundation:foundation:$composeVersion")
    implementation("androidx.compose.runtime:runtime:$composeVersion")
    // UI
    implementation("androidx.compose.ui:ui:$composeVersion")
    implementation("androidx.compose.ui:ui-tooling:$composeVersion")
    // Material Design
    implementation("androidx.compose.material:material:$composeVersion")
    implementation("androidx.compose.material:material-icons-core:$composeVersion")
    // Activity
    implementation("androidx.activity:activity-compose:1.4.0")
    implementation("androidx.appcompat:appcompat:1.4.1")
}

When running Gradle Sync, we get a message about the version incompatibility between Jetpack Compose and Kotlin. This is due to the fact that Compose uses a compiler plugin for Kotlin, and their API is not yet stable. Therefore, we need to install the version of Kotlin that the version of Compose we use supports – 1.6.10.

Next, it remains to lay out the authorization screen, I immediately give the finished code:

@Composable
fun LoginScreen() {
    val context: Context = LocalContext.current
    val coroutineScope: CoroutineScope = rememberCoroutineScope()

    var login: String by remember { mutableStateOf("") }
    var password: String by remember { mutableStateOf("") }
    var isLoading: Boolean by remember { mutableStateOf(false) }

    val isLoginButtonEnabled: Boolean = login.isNotBlank() && password.isNotBlank() && !isLoading

    Column(
        modifier = Modifier.padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        TextField(
            modifier = Modifier.fillMaxWidth(),
            value = login,
            enabled = !isLoading,
            label = { Text(text = "Login") },
            onValueChange = { login = it }
        )
        Spacer(modifier = Modifier.height(8.dp))
        TextField(
            modifier = Modifier.fillMaxWidth(),
            value = password,
            enabled = !isLoading,
            label = { Text(text = "Password") },
            visualTransformation = PasswordVisualTransformation(),
            onValueChange = { password = it }
        )
        Spacer(modifier = Modifier.height(8.dp))
        Button(
            modifier = Modifier
                .fillMaxWidth()
                .height(48.dp),
            enabled = isLoginButtonEnabled,
            onClick = {
                coroutineScope.launch {
                    isLoading = true
                    delay(1000)
                    isLoading = false
                    Toast.makeText(context, "login success!", Toast.LENGTH_SHORT).show()
                }
            }
        ) {
            if (isLoading) CircularProgressIndicator(modifier = Modifier.size(24.dp))
            else Text(text = "Login")
        }
    }
}

And here is our Android app with authorization screen ready and functioning as required, but without the common code.

Application on Android
Application on Android

git commit

Authorization screen in iOS with SwiftUI

Let’s make the same screen in SwiftUI. The template has already created a SwiftUI app, so we just need to write the screen code. We get the following code:

struct LoginScreen: View {
    @State private var login: String = ""
    @State private var password: String = ""
    @State private var isLoading: Bool = false
    @State private var isSuccessfulAlertShowed: Bool = false
    
    private var isButtonEnabled: Bool {
        get {
            !isLoading && !login.isEmpty && !password.isEmpty
        }
    }
    
    var body: some View {
        Group {
            VStack(spacing: 8.0) {
                TextField("Login", text: $login)
                    .textFieldStyle(.roundedBorder)
                    .disabled(isLoading)
                
                SecureField("Password", text: $password)
                    .textFieldStyle(.roundedBorder)
                    .disabled(isLoading)
                
                Button(
                    action: {
                        isLoading = true
                        DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
                            isLoading = false
                            isSuccessfulAlertShowed = true
                        }
                    }, label: {
                        if isLoading {
                            ProgressView()
                        } else {
                            Text("Login")
                        }
                    }
                ).disabled(!isButtonEnabled)
            }.padding()
        }.alert(
            "Login successful",
            isPresented: $isSuccessfulAlertShowed
        ) {
            Button("Close", action: { isSuccessfulAlertShowed = false })
        }
    }
}

The operation logic is completely identical to the Android version and also does not use any common logic.

git commit

Implementing a Generic ViewModel

All preparatory steps are completed. It’s time to move the authorization screen logic out of the platforms into a common code.

The first thing we will do for this is to connect the moko-mvvm dependency to the common module and add it to the export list for the iOS framework (so that we can see all the public classes and methods of this library in Swift).

val mokoMvvmVersion = "0.13.0"

kotlin {
    // ...

    cocoapods {
        // ...
        
        framework {
            baseName = "MultiPlatformLibrary"

            export("dev.icerock.moko:mvvm-core:$mokoMvvmVersion")
            export("dev.icerock.moko:mvvm-flow:$mokoMvvmVersion")
        }
    }

    sourceSets {
        val commonMain by getting {
            dependencies {
                api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.1-native-mt")

                api("dev.icerock.moko:mvvm-core:$mokoMvvmVersion")
                api("dev.icerock.moko:mvvm-flow:$mokoMvvmVersion")
            }
        }
        // ...
        val androidMain by getting {
            dependencies {
                api("dev.icerock.moko:mvvm-flow-compose:$mokoMvvmVersion")
            }
        }
        // ...
    }
}

We also changed baseName from the iOS Framework to MultiPlatformLibrary. This is an important change, without which we will not be able to connect CocoaPod with Kotlin and SwiftUI integration functions in the future.

It remains to write LoginViewModel. Here is the code:

class LoginViewModel : ViewModel() {
    val login: MutableStateFlow<String> = MutableStateFlow("")
    val password: MutableStateFlow<String> = MutableStateFlow("")

    private val _isLoading: MutableStateFlow<Boolean> = MutableStateFlow(false)
    val isLoading: StateFlow<Boolean> = _isLoading

    val isButtonEnabled: StateFlow<Boolean> =
        combine(isLoading, login, password) { isLoading, login, password ->
            isLoading.not() && login.isNotBlank() && password.isNotBlank()
        }.stateIn(viewModelScope, SharingStarted.Eagerly, false)

    private val _actions = Channel<Action>()
    val actions: Flow<Action> get() = _actions.receiveAsFlow()

    fun onLoginPressed() {
        _isLoading.value = true
        viewModelScope.launch {
            delay(1000)
            _isLoading.value = false
            _actions.send(Action.LoginSuccess)
        }
    }

    sealed interface Action {
        object LoginSuccess : Action
    }
}

For input fields that can be changed by the user, we used MutableStateFlow from kotlinx-coroutines (but you can also use MutableLiveData from moko-mvvm-livedata). For properties that the UI should keep track of but should not change, use StateFlow. And to notify about the need to do something (show a success message or to go to another screen), we created Channelwhich is issued to the UI in the form Flow. We combine all available actions under a single sealed interface Actionso that it is known exactly what actions can be reported by this ViewModel.

git commit

We connect the general ViewModel to Android

On Android to get out ViewModelStorage our ViewModel (so that when we rotate the screen we get the same ViewModel) we need to include a special dependency in androidApp/build.gradle.kts:

dependencies {
    // ...
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.4.1")
}

Next, add to the arguments of our screen LoginViewModel:

@Composable
fun LoginScreen(
    viewModel: LoginViewModel = viewModel()
)

Let’s replace the local state of the screen with getting the state from LoginViewModel:

val login: String by viewModel.login.collectAsState()
val password: String by viewModel.password.collectAsState()
val isLoading: Boolean by viewModel.isLoading.collectAsState()
val isLoginButtonEnabled: Boolean by viewModel.isButtonEnabled.collectAsState()

Subscribe to receive actions from the ViewModel using observeAsAction from moko-mvvm:

viewModel.actions.observeAsActions { action ->
    when (action) {
        LoginViewModel.Action.LoginSuccess ->
            Toast.makeText(context, "login success!", Toast.LENGTH_SHORT).show()
    }
}

Let’s replace the input handler with TextField‘s from local state to write to ViewModel:

TextField(
    // ...
    onValueChange = { viewModel.login.value = it }
)

And call the button click handler:

Button(
    // ...
    onClick = viewModel::onLoginPressed
) {
    // ...
}

We launch the application and see that everything works exactly the same as it worked before the common code, but now all the logic of the screen is controlled by the common ViewModel.

git commit

We connect the general ViewModel to iOS

To connect LoginViewModel to SwiftUI, we need Swift add-ons from MOKO MVVM. They connect via CocoaPods:

pod 'mokoMvvmFlowSwiftUI', :podspec => 'https://raw.githubusercontent.com/icerockdev/moko-mvvm/release/0.13.0/mokoMvvmFlowSwiftUI.podspec'

And also, in the LoginViewModel need to make changes – from Swift side MutableStateFlow, StateFlow, Flow will lose their generic type since they are interfaces. So that the generic is not lost, you need to use classes. MOKO MVVM provides special classes CMutableStateFlow, CStateFlow and CFlow to save the generic type in iOS. We bring the types with the following change:

class LoginViewModel : ViewModel() {
    val login: CMutableStateFlow<String> = MutableStateFlow("").cMutableStateFlow()
    val password: CMutableStateFlow<String> = MutableStateFlow("").cMutableStateFlow()

    // ...
    val isLoading: CStateFlow<Boolean> = _isLoading.cStateFlow()

    val isButtonEnabled: CStateFlow<Boolean> =
        // ...
        .cStateFlow()
    
    // ...
    val actions: CFlow<Action> get() = _actions.receiveAsFlow().cFlow()

    // ...
}

Now we can move on to the Swift code. To integrate, we make the following change:

import MultiPlatformLibrary
import mokoMvvmFlowSwiftUI
import Combine

struct LoginScreen: View {
    @ObservedObject var viewModel: LoginViewModel = LoginViewModel()
    @State private var isSuccessfulAlertShowed: Bool = false
    
    // ...
}

We are adding viewModel in View as @ObservedObjectjust like we do with Swift versions of the ViewModel, but in this case, by using mokoMvvmFlowSwiftUI we can immediately pass a Kotlin class LoginViewModel.

Next, change the binding of the fields:

TextField("Login", text: viewModel.binding(\.login))
    .textFieldStyle(.roundedBorder)
    .disabled(viewModel.state(\.isLoading))

mokoMvvmFlowSwiftUI provides special extension functions to ViewModel:

  • binding returns Binding structure, for the possibility of changing data from the UI

  • state returns a value that will be automatically updated when StateFlow give new data

Similarly, we replace other places where the local state is used and subscribe to actions:

.onReceive(createPublisher(viewModel.actions)) { action in
    let actionKs = LoginViewModelActionKs(action)
    switch(actionKs) {
    case .loginSuccess:
        isSuccessfulAlertShowed = true
        break
    }
}

Function createPublisher also provided from mokoMvvmFlowSwiftUI and allows you to convert CFlow in AnyPublisher from Combine. For reliable processing of actions, we use moko-kswift. This is a gradle plugin that automatically generates swift code based on Kotlin. In this case, Swift was generated enum LoginViewModelActionKs from sealed interface LoginViewModel.Action. Using the automatically generated enum we get a guarantee of matching cases in enum and in sealed interface, so now we can rely on exhaustive switch logic. You can read more about MOKO KSwift in the article.

As a result, we got a SwiftUI screen that is controlled from a common code using the MVVM approach.

git commit

findings

In developing with Kotlin Multiplatform Mobile, we consider it important to strive to provide a convenient toolkit for both platforms – both Android and iOS developers should be comfortable developing and using any approach in common code should not force developers of one of the platforms to do extra work. By developing our MOKO libraries and tools we aim to simplify the work of developers for both Android and iOS. The integration of SwiftUI and MOKO MVVM required a lot of experimentation, but the final result looks comfortable to use.

You can try the project created in this article yourself, on GitHub.

Also, if you are interested in the Kotlin Multiplatform Mobile topic, we recommend our materials on kmm.icerock.dev.

For novice developers who want to immerse themselves in Android and iOS development with Kotlin Multiplatform, we have a corporate university, the materials of which are available to everyone at kmm.icerock.dev – University. Those who wish to learn more about our development approaches can also read the materials of the university.

We also we can help and development teams who need development assistance or advice on the topic of Kotlin Multiplatform Mobile.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *