Library to share Kotlin ViewModels with SwiftUI
A library that allows you to share ViewModels between Android and iOS.
The latest version of the library uses Kotlin version 1.9.23
.
Compatibility versions for older and/or preview Kotlin versions are also available:
Version | Version suffix | Kotlin | Coroutines | AndroidX Lifecycle |
---|---|---|---|---|
latest | -kotlin-2.0.0-RC1 | 2.0.0-RC1 | 1.8.0 | 2.7.0 |
latest | no suffix | 1.9.23 | 1.8.0 | 2.7.0 |
1.0.0-ALPHA-19 | no suffix | 1.9.22 | 1.8.0 | 2.7.0 |
1.0.0-ALPHA-18 | no suffix | 1.9.22 | 1.7.3 | 2.6.2 |
1.0.0-ALPHA-16 | no suffix | 1.9.21 | 1.7.3 | 2.6.2 |
1.0.0-ALPHA-15 | no suffix | 1.9.20 | 1.7.3 | 2.6.2 |
1.0.0-ALPHA-14 | no suffix | 1.9.10 | 1.7.3 | 2.6.1 |
1.0.0-ALPHA-13 | no suffix | 1.9.0 | 1.7.3 | 2.6.1 |
1.0.0-ALPHA-10 | no suffix | 1.8.22 | 1.7.2 | 2.6.1 |
1.0.0-ALPHA-9 | no suffix | 1.8.21 | 1.7.1 | 2.5.1 |
1.0.0-ALPHA-8 | no suffix | 1.8.21 | 1.7.0 | 2.5.1 |
1.0.0-ALPHA-7 | no suffix | 1.8.21 | 1.6.4 | 2.5.1 |
1.0.0-ALPHA-6 | no suffix | 1.8.20 | 1.6.4 | 2.5.1 |
1.0.0-ALPHA-4 | no suffix | 1.8.10 | 1.6.4 | 2.5.1 |
1.0.0-ALPHA-3 | no suffix | 1.8.0 | 1.6.4 | 2.5.1 |
Add the library to your shared Kotlin module and opt-in to the ExperimentalForeignApi
:
kotlin {
sourceSets {
all {
languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi")
}
commonMain {
dependencies {
api("com.rickclephas.kmm:kmm-viewmodel-core:1.0.0-ALPHA-20")
}
}
}
}
And create your ViewModels:
import com.rickclephas.kmm.viewmodel.KMMViewModel
import com.rickclephas.kmm.viewmodel.MutableStateFlow
import com.rickclephas.kmm.viewmodel.stateIn
open class TimeTravelViewModel: KMMViewModel() {
private val clockTime = Clock.time
/**
* A [StateFlow] that emits the actual time.
*/
val actualTime = clockTime.map { formatTime(it) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "N/A")
private val _travelEffect = MutableStateFlow<TravelEffect?>(viewModelScope, null)
/**
* A [StateFlow] that emits the applied [TravelEffect].
*/
val travelEffect = _travelEffect.asStateFlow()
}
As you might notice it isn't much different from an Android ViewModel.
The most obvious difference is the KMMViewModel
superclass:
- import androidx.lifecycle.ViewModel
+ import com.rickclephas.kmm.viewmodel.KMMViewModel
- open class TimeTravelViewModel: ViewModel() {
+ open class TimeTravelViewModel: KMMViewModel() {
Besides that there are only 2 minor differences.
The first being a different import for stateIn
:
- import kotlinx.coroutines.flow.stateIn
+ import com.rickclephas.kmm.viewmodel.stateIn
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "N/A")
And the second being a different MutableStateFlow
constructor:
- import kotlinx.coroutines.flow.MutableStateFlow
+ import com.rickclephas.kmm.viewmodel.MutableStateFlow
- private val _travelEffect = MutableStateFlow<TravelEffect?>(null)
+ private val _travelEffect = MutableStateFlow<TravelEffect?>(viewModelScope, null)
These minor differences will make sure that any state changes are propagated to iOS.
[!NOTE]
viewModelScope
is a wrapper around the actualCoroutineScope
which can be accessed via theViewModelScope.coroutineScope
property.
I highly recommend you to use the @NativeCoroutinesState
annotation from
KMP-NativeCoroutines
to turn your StateFlow
s into properties in Swift:
@NativeCoroutinesState
val travelEffect = _travelEffect.asStateFlow()
Checkout the KMP-NativeCoroutines README for more information and installation instructions.
Alternatively you can create extension properties in your iOS source-set yourself:
val TimeTravelViewModel.travelEffectValue: TravelEffect?
get() = travelEffect.value
Use the view model like you would any other Android view model:
class TimeTravelFragment: Fragment(R.layout.fragment_time_travel) {
private val viewModel: TimeTravelViewModel by viewModels()
}
[!NOTE] Improved support for Jetpack Compose is coming soon.
Add the Swift package to your Package.swift
file:
dependencies: [
.package(url: "https://github.com/rickclephas/KMM-ViewModel.git", from: "1.0.0-ALPHA-20")
]
Or add it in Xcode by going to File
> Add Packages...
and providing the URL:
https://github.com/rickclephas/KMM-ViewModel.git
.
If you like you can also use CocoaPods instead of SPM:
pod 'KMMViewModelSwiftUI', '1.0.0-ALPHA-20'
Create a KMMViewModel.swift
file with the following contents:
import KMMViewModelCore
import shared // This should be your shared KMM module
extension Kmm_viewmodel_coreKMMViewModel: KMMViewModel { }
After that you can use your view model almost as if it were an ObservableObject
.
Just use the view model specific property wrappers and functions:
ObservableObject |
KMMViewModel |
---|---|
@StateObject |
@StateViewModel |
@ObservedObject |
@ObservedViewModel |
@EnvironmentObject |
@EnvironmentViewModel |
environmentObject(_:) |
environmentViewModel(_:) |
E.g. to use the TimeTravelViewModel
as a StateObject
:
import SwiftUI
import KMMViewModelSwiftUI
import shared // This should be your shared KMM module
struct ContentView: View {
@StateViewModel var viewModel = TimeTravelViewModel()
}
It's also possible to subclass your view model in Swift:
import Combine
import shared // This should be your shared KMM module
class TimeTravelViewModel: shared.TimeTravelViewModel {
@Published var isResetDisabled: Bool = false
}
You'll need some additional logic if your KMMViewModel
s expose child view models.
First make sure to use the NativeCoroutinesRefinedState
annotation instead of the NativeCoroutinesState
annotation:
class MyParentViewModel: KMMViewModel() {
@NativeCoroutinesRefinedState
val myChildViewModel: StateFlow<MyChildViewModel?> = MutableStateFlow(null)
}
After that you should create a Swift extension property using the childViewModel(at:)
function:
extension MyParentViewModel {
var myChildViewModel: MyChildViewModel? {
childViewModel(at: \.__myChildViewModel)
}
}
This will prevent your Swift view models from being deallocated too soon.
[!NOTE] For lists, sets and dictionaries containing view models there is
childViewModels(at:)
.
When subclassing your Kotlin ViewModel in Swift you might experience some issues in the way those ViewModels are cleared.
An example of such an issue is when you are using a Combine publisher to observe a Flow through KMP-NativeCoroutines:
import Combine
import KMPNativeCoroutinesCombine
import shared // This should be your shared KMM module
class TimeTravelViewModel: shared.TimeTravelViewModel {
private var cancellables = Set<AnyCancellable>()
override init() {
super.init()
createPublisher(for: currentTimeFlow)
.assertNoFailure()
.sink { time in print("It's \(time)") }
.store(in: &cancellables)
}
}
Since currentTimeFlow
is a StateFlow we don't ever expect it to fail, which is why we are using the assertNoFailure
.
However, in this case you'll notice that the publisher will fail with a JobCancellationException
.
The problem here is that before the TimeTravelViewModel
is deinited it will already be cleared.
Meaning the viewModelScope
is cancelled and onCleared
is called.
This results in the Combine publisher outliving the underlying StateFlow collection.
To solve such issues you should have your Swift ViewModel conform to Cancellable
and perform the required cleanup in the cancel
function:
class TimeTravelViewModel: shared.TimeTravelViewModel, Cancellable {
func cancel() {
cancellables = []
}
}
KMM-ViewModel will make sure to call the cancel
function before the ViewModel is being cleared.