Skip to content

Commit a6a9c82

Browse files
committed
Introduce ViewModel.Properties
1 parent 3481655 commit a6a9c82

File tree

12 files changed

+321
-5
lines changed

12 files changed

+321
-5
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
//
2+
// Properties.swift
3+
// KMPObservableViewModelProperties
4+
//
5+
// Created by Rick Clephas on 10/06/2025.
6+
//
7+
8+
import Foundation
9+
import Observation
10+
import KMPObservableViewModelCore
11+
12+
/// A class that stores all `ObservableKeyPath` properties of a ViewModel.
13+
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
14+
public protocol ObservableProperties: Properties, Observable { }
15+
16+
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
17+
public extension ObservableProperties {
18+
19+
/// Returns the value of a property ensuring the `keyPath` is being registered.
20+
subscript<Value>(keyPath: KeyPath<Self, Value>) -> Value {
21+
let value = self[keyPath: keyPath]
22+
guard shouldRegisterKeyPath(), Thread.isMainThread else { return value }
23+
registerKeyPath(ObservableKeyPath(self, keyPath))
24+
return self[keyPath: keyPath]
25+
}
26+
27+
/// Gets or sets the value of a property ensuring the `keyPath` is being registered.
28+
subscript<Value>(keyPath: ReferenceWritableKeyPath<Self, Value>) -> Value {
29+
get { self[keyPath as KeyPath<Self, Value>] }
30+
set { self[keyPath: keyPath] = newValue }
31+
}
32+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//
2+
// Properties.swift
3+
// KMPObservableViewModelProperties
4+
//
5+
// Created by Rick Clephas on 16/06/2025.
6+
//
7+
8+
import KMPObservableViewModelCoreObjC
9+
10+
/// A class that stores all observable properties of a ViewModel.
11+
public protocol Properties: AnyObject {
12+
func shouldRegisterKeyPath() -> Bool
13+
func registerKeyPath(_ keyPath: any ViewModelKeyPath)
14+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
//
2+
// Properties.swift
3+
// KMPObservableViewModelProperties
4+
//
5+
// Created by Rick Clephas on 10/06/2025.
6+
//
7+
8+
import Foundation
9+
import Combine
10+
import KMPObservableViewModelCore
11+
12+
/// A class that stores all `PublishedKeyPath` properties of a ViewModel.
13+
public protocol PublishedProperties: Properties { }
14+
15+
public extension PublishedProperties {
16+
17+
/// Returns the value of a property ensuring the `keyPath` is being registered.
18+
subscript<Value>(keyPath: KeyPath<Self, Value>) -> Value {
19+
let value = self[keyPath: keyPath]
20+
guard shouldRegisterKeyPath(), Thread.isMainThread else { return value }
21+
registerKeyPath(PublishedKeyPath.shared)
22+
return self[keyPath: keyPath]
23+
}
24+
25+
/// Gets or sets the value of a property ensuring the `keyPath` is being registered.
26+
subscript<Value>(keyPath: ReferenceWritableKeyPath<Self, Value>) -> Value {
27+
get { self[keyPath as KeyPath<Self, Value>] }
28+
set { self[keyPath: keyPath] = newValue }
29+
}
30+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
//
2+
// ViewModel.swift
3+
// KMPObservableViewModelProperties
4+
//
5+
// Created by Rick Clephas on 11/06/2025.
6+
//
7+
8+
import KMPObservableViewModelCore
9+
10+
/// A Kotlin Multiplatform ViewModel.
11+
@dynamicMemberLookup
12+
public protocol ViewModel: KMPObservableViewModelCore.ViewModel {
13+
14+
associatedtype Properties: KMPObservableViewModelProperties.Properties
15+
16+
/// The observable properties of `this` ViewModel.
17+
var __properties: Properties { get }
18+
19+
/// Returns the value of an observable property.
20+
subscript<T>(dynamicMember keyPath: KeyPath<Properties, T>) -> T { get }
21+
22+
/// Gets or sets the value of an observable property.
23+
subscript<T>(dynamicMember keyPath: ReferenceWritableKeyPath<Properties, T>) -> T { get set }
24+
}

Package.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ let package = Package(
99
name: "KMPObservableViewModelCore",
1010
targets: ["KMPObservableViewModelCore"]
1111
),
12+
.library(
13+
name: "KMPObservableViewModelProperties",
14+
targets: ["KMPObservableViewModelProperties"]
15+
),
1216
.library(
1317
name: "KMPObservableViewModelSwiftUI",
1418
targets: ["KMPObservableViewModelSwiftUI"]
@@ -25,6 +29,11 @@ let package = Package(
2529
dependencies: [.target(name: "KMPObservableViewModelCoreObjC")],
2630
path: "KMPObservableViewModelCore"
2731
),
32+
.target(
33+
name: "KMPObservableViewModelProperties",
34+
dependencies: [.target(name: "KMPObservableViewModelCore")],
35+
path: "KMPObservableViewModelProperties"
36+
),
2837
.target(
2938
name: "KMPObservableViewModelSwiftUI",
3039
dependencies: [.target(name: "KMPObservableViewModelCore")],

gradle/libs.versions.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@ kotlinx-coroutines = "1.10.1"
44
android = "8.2.0"
55
androidx-lifecycle = "2.8.7"
66
atomicfu = "0.26.1"
7+
nativecoroutines = "1.0.0-ALPHA-43"
78

89
# Sample versions
910
androidx-compose = "2023.10.01"
1011
androidx-fragment = "1.6.2"
1112
ksp = "2.1.21-2.0.1"
12-
nativecoroutines = "1.0.0-ALPHA-43"
1313

1414
[libraries]
1515
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
@@ -18,6 +18,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c
1818
android-library-gradle-plugin = { module = "com.android.library:com.android.library.gradle.plugin", version.ref = "android" }
1919
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
2020
vanniktech-mavenPublish = { module = "com.vanniktech:gradle-maven-publish-plugin", version = "0.32.0" }
21+
nativecoroutines-core = { module = "com.rickclephas.kmp:kmp-nativecoroutines-core", version.ref = "nativecoroutines" }
2122

2223
# Sample libraries
2324
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose" }

kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelKeyPath.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMViewModelKeyPathProtoc
44
import kotlinx.coroutines.flow.StateFlow
55

66
/**
7-
* Sets the [KeyPath][KMPOVMViewModelKeyPathProtocol] of an `ObservableStateFlow`.
7+
* The [KeyPath][KMPOVMViewModelKeyPathProtocol] of an `ObservableStateFlow`.
88
* @throws IllegalArgumentException if `this` [StateFlow] isn't an `ObservableStateFlow`.
99
*/
1010
@InternalKMPObservableViewModelApi
11-
public fun <T> StateFlow<T>.setKeyPath(keyPath: KMPOVMViewModelKeyPathProtocol?) {
12-
requireObservableStateFlow(this).keyPath = keyPath
13-
}
11+
public var <T> StateFlow<T>.keyPath: KMPOVMViewModelKeyPathProtocol?
12+
get() = requireObservableStateFlow(this).keyPath
13+
set(value) { requireObservableStateFlow(this).keyPath = value }
1414

1515
/**
1616
* Helper to emit [keyPath] access events through the [NativeViewModelScope].
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi
2+
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
3+
4+
plugins {
5+
id("kmp-observableviewmodel-android-library")
6+
id("kmp-observableviewmodel-kotlin-multiplatform")
7+
id("kmp-observableviewmodel-publish")
8+
}
9+
10+
kotlin {
11+
explicitApi()
12+
jvmToolchain(11)
13+
14+
@OptIn(ExperimentalKotlinGradlePluginApi::class)
15+
applyDefaultHierarchyTemplate {
16+
common {
17+
group("nonApple") {
18+
withAndroidTarget()
19+
withJvm()
20+
withJs()
21+
group("linux")
22+
group("mingw")
23+
withWasmJs()
24+
}
25+
}
26+
}
27+
28+
macosX64()
29+
macosArm64()
30+
iosArm64()
31+
iosX64()
32+
iosSimulatorArm64()
33+
watchosArm32()
34+
watchosArm64()
35+
watchosX64()
36+
watchosSimulatorArm64()
37+
watchosDeviceArm64()
38+
tvosArm64()
39+
tvosX64()
40+
tvosSimulatorArm64()
41+
androidTarget()
42+
jvm()
43+
js {
44+
browser()
45+
nodejs()
46+
}
47+
linuxArm64()
48+
linuxX64()
49+
mingwX64()
50+
@OptIn(ExperimentalWasmDsl::class)
51+
wasmJs {
52+
browser()
53+
nodejs()
54+
d8()
55+
}
56+
57+
targets.all {
58+
compilations.all {
59+
compileTaskProvider.configure {
60+
compilerOptions {
61+
freeCompilerArgs.add("-Xexpect-actual-classes")
62+
}
63+
}
64+
}
65+
}
66+
67+
sourceSets {
68+
all {
69+
languageSettings {
70+
optIn("com.rickclephas.kmp.observableviewmodel.InternalKMPObservableViewModelApi")
71+
optIn("kotlin.experimental.ExperimentalObjCRefinement")
72+
optIn("kotlinx.cinterop.ExperimentalForeignApi")
73+
}
74+
}
75+
76+
commonMain {
77+
dependencies {
78+
api(project(":kmp-observableviewmodel-core"))
79+
}
80+
}
81+
appleMain {
82+
dependencies {
83+
api(libs.nativecoroutines.core)
84+
}
85+
}
86+
}
87+
}
88+
89+
android {
90+
namespace = "com.rickclephas.kmp.observableviewmodel.properties"
91+
compileSdk = 33
92+
defaultConfig {
93+
minSdk = 19
94+
}
95+
}
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package com.rickclephas.kmp.observableviewmodel.properties
2+
3+
import com.rickclephas.kmp.nativecoroutines.NativeFlow
4+
import com.rickclephas.kmp.nativecoroutines.asNativeFlow
5+
import com.rickclephas.kmp.observableviewmodel.InternalKMPObservableViewModelApi
6+
import com.rickclephas.kmp.observableviewmodel.ViewModel
7+
import com.rickclephas.kmp.observableviewmodel.ViewModelScope
8+
import com.rickclephas.kmp.observableviewmodel.coroutineScope
9+
import com.rickclephas.kmp.observableviewmodel.keyPath
10+
import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMViewModelKeyPathProtocol
11+
import kotlinx.coroutines.CoroutineScope
12+
import kotlinx.coroutines.flow.MutableStateFlow
13+
import kotlinx.coroutines.flow.StateFlow
14+
import platform.Foundation.NSThread
15+
import kotlin.experimental.ExperimentalObjCName
16+
17+
/**
18+
* A Kotlin Multiplatform ViewModel.
19+
*/
20+
public actual abstract class ViewModel: ViewModel() {
21+
22+
/**
23+
* Class that stores all observable properties of a ViewModel.
24+
*/
25+
@InternalKMPObservableViewModelApi
26+
public abstract class Properties {
27+
28+
private var registeringStateFlow: StateFlow<*>? = null
29+
30+
/**
31+
* Indicates if the `KeyPath` of the accessed property should be registered.
32+
* @see registerKeyPath
33+
*/
34+
public fun shouldRegisterKeyPath(): Boolean = registeringStateFlow != null
35+
36+
/**
37+
* Registers the [keyPath] for the previously accessed property.
38+
* @see shouldRegisterKeyPath
39+
*/
40+
@OptIn(ExperimentalObjCName::class)
41+
public fun registerKeyPath(@ObjCName(swiftName = "_") keyPath: KMPOVMViewModelKeyPathProtocol) {
42+
registeringStateFlow?.keyPath = keyPath
43+
registeringStateFlow = null
44+
}
45+
46+
/**
47+
* Returns the value of the provided [stateFlow] and ensures `KeyPath`s are being registered.
48+
* @see shouldRegisterKeyPath
49+
*/
50+
@HiddenFromObjC
51+
protected fun <T> getProperty(stateFlow: StateFlow<T>): T {
52+
if (stateFlow.keyPath == null && NSThread.isMainThread) {
53+
registeringStateFlow = stateFlow
54+
}
55+
return stateFlow.value
56+
}
57+
58+
/**
59+
* Sets the value of the [stateFlow] to the provided [value].
60+
*/
61+
@HiddenFromObjC
62+
protected fun <T> setProperty(stateFlow: MutableStateFlow<T>, value: T) {
63+
stateFlow.value = value
64+
}
65+
}
66+
67+
/**
68+
* The observable properties of `this` ViewModel.
69+
*/
70+
@ShouldRefineInSwift
71+
@InternalKMPObservableViewModelApi
72+
public abstract val properties: Properties
73+
74+
/**
75+
* Class that provides [NativeFlow]s for all observable properties of a ViewModel.
76+
*/
77+
public abstract class NativeFlows(
78+
viewModelScope: ViewModelScope
79+
) {
80+
private val coroutineScope: CoroutineScope = viewModelScope.coroutineScope
81+
82+
/**
83+
* Returns a [NativeFlow] for the provided [stateFlow] using the `viewModelScope`.
84+
*/
85+
@HiddenFromObjC
86+
protected fun <T> getNativeFlow(stateFlow: StateFlow<T>): NativeFlow<T> =
87+
stateFlow.asNativeFlow(coroutineScope)
88+
}
89+
90+
/**
91+
* The [NativeFlow]s for the observable [properties] of `this` ViewModel.
92+
*/
93+
public abstract val nativeFlows: NativeFlows
94+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package com.rickclephas.kmp.observableviewmodel.properties
2+
3+
import com.rickclephas.kmp.observableviewmodel.ViewModel
4+
5+
/**
6+
* A Kotlin Multiplatform ViewModel.
7+
*/
8+
public expect abstract class ViewModel: ViewModel

0 commit comments

Comments
 (0)