A SwiftUI macro to use inside a Apple Kotlin multiplatform project for wrapping a Kotlin ViewModel into a SwiftUI ObservableObject, based on the SKIE library.
The goal of this macro is to increase the iOS development experience by removing the complexity of using a Kotlin ViewModel inside an iOS application written in Swift and also respecting the Lifecycle.
For example, instead of using KotlinInt/KotlinInt?, we're using Int/Int?, it can work with Float/Double/...
Currently supported Kotlin types : dataclass/class, int, double, float, bool, uint, Long (swift int64), please make an issue for more.
You can also bind List but only with type Class
and String
.
Note
The macro can create uniderectional and bidirectionel binding
See the sample for a full and detailed example.
KMP ViewModel
iOS SwiftUI macro
KTViewModelBuilder/Sample/iosApp/iosApp/ExampleScreen.swift
Lines 13 to 29 in 983bedf
public class ExampleViewModel : ViewModel() {
public var bidirectionalString: MutableStateFlow<String> = MutableStateFlow<String>("SOME INPUT")
public var bidirectionalBoolean: MutableStateFlow<Boolean> = MutableStateFlow<Boolean>(false)
public var bidirectionalInt: MutableStateFlow<Int?> = MutableStateFlow<Int?>(42)
public var bidirectionalLong: MutableStateFlow<Long> = MutableStateFlow<Long>(424242L)
private val _stringData = MutableStateFlow("Some Data")
public val stringData: StateFlow<String> = _stringData
private val _intNullableData = MutableStateFlow<Int?>(null)
public val intNullableData: StateFlow<Int?> = _intNullableData
private val _randomValue = MutableStateFlow(0)
public val randomValue: StateFlow<Int> = _randomValue
private val _entityData = MutableStateFlow<MyData?>(MyData())
public val entityData: StateFlow<MyData?> = _entityData
public fun randomizeValue() {
_randomValue.value = (0..100).random()
}
}
The macro generate a SwiftUI ViewModel from the content of the Kotlin ExampleViewModel
class.
@ktViewModelBinding(ofType: ExampleViewModel.self,
publishing:
.init(\.stringData, String.self),
.init(\.intNullableData, Int?.self),
.init(\.randomValue, Double.self),
.init(\.entityData, MyData?.self),
.init(\.bidirectionalString, String.self, true),
.init(\.bidirectionalInt, Int?.self, true),
.init(\.bidirectionalBoolean, Bool.self, true),
.init(\.bidirectionalLong, Int64.self, true)
)
class MyMainScreenViewModel: ObservableObject {}
Generated content
class MyMainScreenViewModel : ObservableObject {
private let viewModelStore = ViewModelStore()
@Published private(set) var stringData: String
@Published private(set) var intNullableData: Int?
@Published private(set) var randomValue: Double
@Published private(set) var entityData: MyData?
@Published var bidirectionalString: String {
didSet {
instance.bidirectionalString.value = bidirectionalString
}
}
@Published var bidirectionalInt: Int? {
didSet {
instance.bidirectionalInt.value = bidirectionalInt != nil ? KotlinInt(integerLiteral: bidirectionalInt!) : nil
}
}
@Published var bidirectionalBoolean: Bool {
didSet {
instance.bidirectionalBoolean.value = KotlinBoolean(bool: bidirectionalBoolean)
}
}
@Published var bidirectionalLong: Int64 {
didSet {
instance.bidirectionalLong.value = KotlinLong(value: bidirectionalLong)
}
}
init(_ viewModel: ExampleViewModel) {
self.viewModelStore.put(key: "ExampleViewModelKey", viewModel: viewModel)
self.stringData = viewModel.stringData.value
print("INIT stringData : " + String(describing: viewModel.stringData.value))
self.intNullableData = viewModel.intNullableData.value?.intValue
print("INIT intNullableData : " + String(describing: viewModel.intNullableData.value))
self.randomValue = viewModel.randomValue.value.doubleValue
print("INIT randomValue : " + String(describing: viewModel.randomValue.value))
self.entityData = viewModel.entityData.value
print("INIT entityData : " + String(describing: viewModel.entityData.value))
self.bidirectionalString = viewModel.bidirectionalString.value
print("INIT bidirectionalString : " + String(describing: viewModel.bidirectionalString.value))
self.bidirectionalInt = viewModel.bidirectionalInt.value?.intValue
print("INIT bidirectionalInt : " + String(describing: viewModel.bidirectionalInt.value))
self.bidirectionalBoolean = viewModel.bidirectionalBoolean.value.boolValue
print("INIT bidirectionalBoolean : " + String(describing: viewModel.bidirectionalBoolean.value))
self.bidirectionalLong = viewModel.bidirectionalLong.value.int64Value
print("INIT bidirectionalLong : " + String(describing: viewModel.bidirectionalLong.value))
}
var instance: ExampleViewModel {
self.viewModelStore.get(key: "ExampleViewModelKey") as! ExampleViewModel
}
func start() async {
await withTaskGroup(of: (Void).self) {
$0.addTask { @MainActor [weak self] in
for await value in self!.instance.stringData where self != nil {
if value != self?.stringData {
#if DEBUG
print("UPDATING TO VIEW stringData : " + String(describing: value))
#endif
self?.stringData = value
}
}
}
$0.addTask { @MainActor [weak self] in
for await value in self!.instance.intNullableData where self != nil {
if value?.intValue != self?.intNullableData {
#if DEBUG
print("UPDATING TO VIEW intNullableData : " + String(describing: value))
#endif
self?.intNullableData = value?.intValue
}
}
}
$0.addTask { @MainActor [weak self] in
for await value in self!.instance.randomValue where self != nil {
if value.doubleValue != self?.randomValue {
#if DEBUG
print("UPDATING TO VIEW randomValue : " + String(describing: value))
#endif
self?.randomValue = value.doubleValue
}
}
}
$0.addTask { @MainActor [weak self] in
for await value in self!.instance.entityData where self != nil {
if value != self?.entityData {
#if DEBUG
print("UPDATING TO VIEW entityData : " + String(describing: value))
#endif
self?.entityData = value
}
}
}
$0.addTask { @MainActor [weak self] in
for await value in self!.instance.bidirectionalString where self != nil {
if value != self?.bidirectionalString {
#if DEBUG
print("UPDATING TO VIEW bidirectionalString : " + String(describing: value))
#endif
self?.bidirectionalString = value
}
}
}
$0.addTask { @MainActor [weak self] in
for await value in self!.instance.bidirectionalInt where self != nil {
if value?.intValue != self?.bidirectionalInt {
#if DEBUG
print("UPDATING TO VIEW bidirectionalInt : " + String(describing: value))
#endif
self?.bidirectionalInt = value?.intValue
}
}
}
$0.addTask { @MainActor [weak self] in
for await value in self!.instance.bidirectionalBoolean where self != nil {
if value.boolValue != self?.bidirectionalBoolean {
#if DEBUG
print("UPDATING TO VIEW bidirectionalBoolean : " + String(describing: value))
#endif
self?.bidirectionalBoolean = value.boolValue
}
}
}
$0.addTask { @MainActor [weak self] in
for await value in self!.instance.bidirectionalLong where self != nil {
if value.int64Value != self?.bidirectionalLong {
#if DEBUG
print("UPDATING TO VIEW bidirectionalLong : " + String(describing: value))
#endif
self?.bidirectionalLong = value.int64Value
}
}
}
}
}
deinit {
self.viewModelStore.clear()
}
}
The properties of the ViewModel can be directly used from the viewModel
property and the method from viewModel.instance
.
The binding must be triggered from a SwiftUI task modifier; it will start the Observability of the declared properties.
struct ExampleScreen: View {
// Initialize the ViewModel, binding and lifecycle
@StateObject var viewModel = MyMainScreenViewModel(ExampleViewModel())
var body: some View {
VStack {
Text("STRING VALUE \(viewModel.stringData)")
Text("NULL VALUE \(String(describing: viewModel.intNullableData))")
Text("RANDOM VALUE \(viewModel.randomValue)")
Text("BIDI STRING VALUE \(viewModel.bidirectionalString)")
Text("BIDI BOOLEAN SHEET VALUE \(viewModel.bidirectionalBoolean)") // see app logs for update
TextField("MY bidirectional String", text: $viewModel.bidirectionalString) // see app logs for update
Button {
viewModel.instance.randomizeValue()
} label: {
Text("randomizeValue")
}
Button("Show Sheet") {
viewModel.bidirectionalBoolean.toggle()
}
.sheet(isPresented: $viewModel.bidirectionalBoolean) {
SheetView()
}
}.task {
// start the ViewModel lifecycle and keep it alive until the view disappear
await viewModel.start()
}
}
}
As this solution is based on Kotlin multiplatform and SKIE, some requirements need to be met.
Please follow the installation step of the library.
- Add in your .toml or .gradle the following dependency
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx_lifecycle_version" }
- Then follow this configuration or equivalent
listOf(
iosX64(),
iosArm64(),
iosSimulatorArm64()
).forEach {
it.binaries.framework {
baseName = "shared"
isStatic = true
export(libs.androidx.lifecycle.viewmodel) // !! export the library for the iOS target, so it can be accessible from swift code !!
}
}
sourceSets {
commonMain.dependencies {
api(libs.androidx.lifecycle.viewmodel) // the library itself
}
}
Finally, add this package to your application swift package dependencies.
https://github.com/frankois944/KTViewModelBuilder
That's all you need.
A Sample is available in this repository, which has a shared library and an iOS/Android app.