Skip to content

Commit c7af426

Browse files
committed
Add logic to emit state changes for a specific keyPath
1 parent 8353e0e commit c7af426

File tree

12 files changed

+232
-9
lines changed

12 files changed

+232
-9
lines changed
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//
2+
// ObservableKeyPath.swift
3+
// KMPObservableViewModelCore
4+
//
5+
// Created by Rick Clephas on 11/06/2025.
6+
//
7+
8+
import Observation
9+
import KMPObservableViewModelCoreObjC
10+
11+
/// An observable `KeyPath` which uses an `ObservationRegistrar` to track changes.
12+
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
13+
public final class ObservableKeyPath<Root, Value>: ViewModelKeyPath where Root: AnyObject, Root: Observable {
14+
15+
private weak var subject: Root?
16+
private let keyPath: KeyPath<Root, Value>
17+
18+
/// Creates a new `ObservableKeyPath` for the provided `subject` and `keyPath`.
19+
public init(_ subject: Root, _ keyPath: KeyPath<Root, Value>) {
20+
self.subject = subject
21+
self.keyPath = keyPath
22+
}
23+
24+
public func access(_ publisher: any Publisher) {
25+
guard let subject else { return }
26+
publisher.cast().observationRegistrar.access(subject, keyPath: keyPath)
27+
}
28+
29+
public func willSet(_ publisher: any Publisher) {
30+
guard let subject else { return }
31+
publisher.cast().observationRegistrar.willSet(subject, keyPath: keyPath)
32+
}
33+
34+
public func didSet(_ publisher: any Publisher) {
35+
guard let subject else { return }
36+
publisher.cast().observationRegistrar.didSet(subject, keyPath: keyPath)
37+
}
38+
}

KMPObservableViewModelCore/ObservableViewModelPublisher.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,25 @@
66
//
77

88
import Combine
9+
import Observation
910
import KMPObservableViewModelCoreObjC
1011

1112
/// Publisher for `ObservableViewModel` that connects to the `ViewModelScope`.
1213
public final class ObservableViewModelPublisher: Combine.Publisher, KMPObservableViewModelCoreObjC.Publisher {
1314
public typealias Output = Void
1415
public typealias Failure = Never
1516

17+
private var _observationRegistrar: Any? = nil
18+
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
19+
public var observationRegistrar: ObservationRegistrar {
20+
if let observationRegistrar = _observationRegistrar {
21+
return observationRegistrar as! ObservationRegistrar
22+
}
23+
let observationRegistrar = ObservationRegistrar()
24+
_observationRegistrar = observationRegistrar
25+
return observationRegistrar
26+
}
27+
1628
internal let cancellable = ViewModelCancellable()
1729

1830
private let publisher: ObservableObjectPublisher
@@ -33,6 +45,16 @@ public final class ObservableViewModelPublisher: Combine.Publisher, KMPObservabl
3345
}
3446
}
3547

48+
internal extension KMPObservableViewModelCoreObjC.Publisher {
49+
/// Casts this `Publisher` to an `ObservableViewModelPublisher`.
50+
func cast() -> ObservableViewModelPublisher {
51+
guard let publisher = self as? ObservableViewModelPublisher else {
52+
fatalError("Publisher must be an ObservableViewModelPublisher")
53+
}
54+
return publisher
55+
}
56+
}
57+
3658
/// Subscriber for `ObservableViewModelPublisher` that creates `ObservableViewModelSubscription`s.
3759
private class ObservableViewModelSubscriber<S>: Subscriber where S : Subscriber, Never == S.Failure, Void == S.Input {
3860
typealias Input = Void
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//
2+
// PublishedKeyPath.swift
3+
// KMPObservableViewModelCore
4+
//
5+
// Created by Rick Clephas on 09/06/2025.
6+
//
7+
8+
import KMPObservableViewModelCoreObjC
9+
10+
/// A published `KeyPath` which uses an `ObservableObject` to emit change events.
11+
public final class PublishedKeyPath: ViewModelKeyPath {
12+
13+
public static let shared = PublishedKeyPath()
14+
15+
private init() { }
16+
17+
public func access(_ publisher: any Publisher) {
18+
// Published keyPaths only emit on willSet
19+
}
20+
21+
public func willSet(_ publisher: any Publisher) {
22+
publisher.cast().send()
23+
}
24+
25+
public func didSet(_ publisher: any Publisher) {
26+
// Published keyPaths only emit on willSet
27+
}
28+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
//
2+
// KMPOVMViewModelKeyPath.h
3+
// KMPObservableViewModelCoreObjC
4+
//
5+
// Created by Rick Clephas on 11/06/2025.
6+
//
7+
8+
#ifndef KMPOVMViewModelKeyPath_h
9+
#define KMPOVMViewModelKeyPath_h
10+
11+
#import <Foundation/Foundation.h>
12+
#import "KMPOVMPublisher.h"
13+
14+
NS_ASSUME_NONNULL_BEGIN
15+
16+
__attribute__((swift_name("ViewModelKeyPath")))
17+
@protocol KMPOVMViewModelKeyPath
18+
- (void)access:(id<KMPOVMPublisher>)publisher;
19+
- (void)willSet:(id<KMPOVMPublisher>)publisher;
20+
- (void)didSet:(id<KMPOVMPublisher>)publisher;
21+
@end
22+
23+
NS_ASSUME_NONNULL_END
24+
25+
#endif /* KMPOVMViewModelKeyPath_h */

KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77

88
#import "KMPOVMPublisher.h"
99
#import "KMPOVMSubscriptionCount.h"
10+
#import "KMPOVMViewModelKeyPath.h"
1011
#import "KMPOVMViewModelScope.h"

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.rickclephas.kmp.observableviewmodel
22

33
import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMPublisherProtocol
4+
import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMViewModelKeyPathProtocol
45
import kotlinx.coroutines.CoroutineScope
56
import platform.darwin.NSObject
67

@@ -21,9 +22,15 @@ internal class NativeViewModelScope internal constructor(
2122
if (_publisher != null) throw IllegalStateException("ViewModel can't be initialized more than once")
2223
_publisher = publisher
2324
}
25+
26+
/**
27+
* Indicates if [ViewModelKeyPath][KMPOVMViewModelKeyPathProtocol]s musts be used with the [publisher].
28+
*/
29+
var requireKeyPaths: Boolean = false
2430
}
2531

2632
/**
2733
* Casts `this` [ViewModelScope] to a [NativeViewModelScope].
2834
*/
35+
@Suppress("NOTHING_TO_INLINE")
2936
internal inline fun ViewModelScope.asNative(): NativeViewModelScope = this as NativeViewModelScope

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

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.rickclephas.kmp.observableviewmodel
22

3+
import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMViewModelKeyPathProtocol
34
import kotlinx.coroutines.ExperimentalCoroutinesApi
45
import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi
56
import kotlinx.coroutines.Job
@@ -17,17 +18,28 @@ internal class ObservableMutableStateFlow<T>(
1718
private val stateFlow: MutableStateFlow<T>
1819
): MutableStateFlow<T> {
1920

21+
/**
22+
* The [KeyPath][KMPOVMViewModelKeyPathProtocol] associated with the [StateFlow] property.
23+
*/
24+
var keyPath: KMPOVMViewModelKeyPathProtocol? = null
25+
set(value) {
26+
if (value != null) viewModelScope.requireKeyPaths = true
27+
field = value
28+
}
29+
2030
override var value: T
21-
get() = stateFlow.value
31+
get() = viewModelScope.access(keyPath) { stateFlow.value }
2232
set(value) {
23-
if (stateFlow.value != value) {
24-
viewModelScope.publisher?.send()
33+
val changed = stateFlow.value != value
34+
viewModelScope.set(keyPath, changed) {
35+
stateFlow.value = value
2536
}
26-
stateFlow.value = value
2737
}
2838

39+
// Same implementation as in StateFlowImpl, but we need to go through our own value property.
40+
// https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/StateFlow.kt#L367
2941
override val replayCache: List<T>
30-
get() = stateFlow.replayCache
42+
get() = listOf(value)
3143

3244
/**
3345
* The combined subscription count from the [NativeViewModelScope] and the actual [StateFlow].
@@ -38,10 +50,10 @@ internal class ObservableMutableStateFlow<T>(
3850
stateFlow.collect(collector)
3951

4052
override fun compareAndSet(expect: T, update: T): Boolean {
41-
if (stateFlow.value == expect && expect != update) {
42-
viewModelScope.publisher?.send()
53+
val changed = stateFlow.value == expect && expect != update
54+
return viewModelScope.set(keyPath, changed) {
55+
stateFlow.compareAndSet(expect, update)
4356
}
44-
return stateFlow.compareAndSet(expect, update)
4557
}
4658

4759
@ExperimentalCoroutinesApi
@@ -70,3 +82,24 @@ internal class ObservableStateFlow<T>(
7082
@Suppress("unused")
7183
private val job: Job? = null
7284
): StateFlow<T> by flow
85+
86+
/**
87+
* Returns the `ObservableStateFlow` for the provided [stateFlow].
88+
* @throws IllegalArgumentException if the [stateFlow] isn't an `ObservableStateFlow`.
89+
*/
90+
internal fun <T> requireObservableStateFlow(
91+
stateFlow: StateFlow<T>,
92+
): ObservableMutableStateFlow<T> = when (stateFlow) {
93+
is ObservableMutableStateFlow -> stateFlow
94+
is ObservableStateFlow -> stateFlow.flow
95+
else -> throw IllegalArgumentException("$stateFlow is not an ObservableStateFlow")
96+
}
97+
98+
/**
99+
* Asserts that the provided [stateFlow] is an `ObservableStateFlow`.
100+
* @throws IllegalArgumentException if the [stateFlow] isn't an `ObservableStateFlow`.
101+
*/
102+
@InternalKMPObservableViewModelApi
103+
public fun <T> assertObservableStateFlow(stateFlow: StateFlow<T>) {
104+
requireObservableStateFlow(stateFlow)
105+
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,9 @@ private fun <T> CoroutineScope.launchSharing(
6262
}
6363
}
6464
}
65+
66+
/**
67+
* @see kotlinx.coroutines.flow.asStateFlow
68+
*/
69+
public actual fun <T> MutableStateFlow<T>.asObservableStateFlow(): StateFlow<T> =
70+
ObservableStateFlow(requireObservableStateFlow(this), null)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package com.rickclephas.kmp.observableviewmodel
2+
3+
import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMViewModelKeyPathProtocol
4+
import kotlinx.coroutines.flow.StateFlow
5+
6+
/**
7+
* Sets the [KeyPath][KMPOVMViewModelKeyPathProtocol] of an `ObservableStateFlow`.
8+
* @throws IllegalArgumentException if `this` [StateFlow] isn't an `ObservableStateFlow`.
9+
*/
10+
@InternalKMPObservableViewModelApi
11+
public fun <T> StateFlow<T>.setKeyPath(keyPath: KMPOVMViewModelKeyPathProtocol?) {
12+
requireObservableStateFlow(this).keyPath = keyPath
13+
}
14+
15+
/**
16+
* Helper to emit [keyPath] access events through the [NativeViewModelScope].
17+
* @param keyPath The keyPath being accessed.
18+
* @param action The action accessing the keyPath value.
19+
*/
20+
internal inline fun <R> NativeViewModelScope.access(
21+
keyPath: KMPOVMViewModelKeyPathProtocol?,
22+
action: () -> R
23+
): R {
24+
val publisher = publisher
25+
if (keyPath != null && publisher != null) {
26+
keyPath.access(publisher)
27+
}
28+
return action()
29+
}
30+
31+
/**
32+
* Helper to emit [keyPath] willSet and didSet events through the [NativeViewModelScope].
33+
* @param keyPath The keyPath being set.
34+
* @param changed Indicates if it's likely the property will be changed.
35+
* False-positives are allowed, false-negatives aren't.
36+
* @param action The action setting the new keyPath value.
37+
*/
38+
internal inline fun <R> NativeViewModelScope.set(
39+
keyPath: KMPOVMViewModelKeyPathProtocol?,
40+
changed: Boolean,
41+
action: () -> R
42+
): R {
43+
val publisher = publisher
44+
if (!changed || publisher == null) return action()
45+
if (keyPath != null) {
46+
keyPath.willSet(publisher)
47+
} else if (!requireKeyPaths) {
48+
publisher.send()
49+
}
50+
val result = action()
51+
keyPath?.didSet(publisher)
52+
return result
53+
}

kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,3 +21,8 @@ public expect fun <T> Flow<T>.stateIn(
2121
started: SharingStarted,
2222
initialValue: T
2323
): StateFlow<T>
24+
25+
/**
26+
* @see kotlinx.coroutines.flow.asStateFlow
27+
*/
28+
public expect fun <T> MutableStateFlow<T>.asObservableStateFlow(): StateFlow<T>
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
language = Objective-C
22
package = com.rickclephas.kmp.observableviewmodel.objc
3-
headers = KMPOVMPublisher.h KMPOVMSubscriptionCount.h KMPOVMViewModelScope.h
3+
headers = KMPOVMPublisher.h KMPOVMSubscriptionCount.h KMPOVMViewModelKeyPath.h KMPOVMViewModelScope.h

kmp-observableviewmodel-core/src/nonAppleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,8 @@ public actual inline fun <T> Flow<T>.stateIn(
1818
started: SharingStarted,
1919
initialValue: T
2020
): StateFlow<T> = stateIn(viewModelScope.coroutineScope, started, initialValue)
21+
22+
/**
23+
* @see kotlinx.coroutines.flow.asStateFlow
24+
*/
25+
public actual fun <T> MutableStateFlow<T>.asObservableStateFlow(): StateFlow<T> = asStateFlow()

0 commit comments

Comments
 (0)