Skip to content

Swift Observation support #70

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions KMMViewModel.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
1D198B302933C01800EF778D /* KMMVMViewModelScope.h in Headers */ = {isa = PBXBuildFile; fileRef = 1D198B2E2933C01800EF778D /* KMMVMViewModelScope.h */; settings = {ATTRIBUTES = (Public, ); }; };
1D198B312933C01800EF778D /* KMMViewModelCoreObjC.m in Sources */ = {isa = PBXBuildFile; fileRef = 1D198B2F2933C01800EF778D /* KMMViewModelCoreObjC.m */; };
1D198B322933C04400EF778D /* KMMViewModelCoreObjC.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1D198B272933BFD900EF778D /* KMMViewModelCoreObjC.framework */; };
1D2AAC592BB1F528005F1344 /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D2AAC582BB1F528005F1344 /* Observable.swift */; };
1D2AAC5B2BB2053D005F1344 /* ObservationRegistrar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D2AAC5A2BB2053D005F1344 /* ObservationRegistrar.swift */; };
1D2AAC5D2BB205AC005F1344 /* ObservableProperty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D2AAC5C2BB205AC005F1344 /* ObservableProperty.swift */; };
1D43F3EC2ABAFCA600EB3DFE /* ObservableViewModelPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D43F3EB2ABAFCA600EB3DFE /* ObservableViewModelPublisher.swift */; };
1D43F3EE2ABAFD7D00EB3DFE /* ObservableViewModelPublishers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D43F3ED2ABAFD7D00EB3DFE /* ObservableViewModelPublishers.swift */; };
1D6641DD2A5175C3000180D7 /* ChildViewModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6641DC2A5175C3000180D7 /* ChildViewModels.swift */; };
Expand Down Expand Up @@ -52,6 +55,9 @@
1D198B292933BFD900EF778D /* KMMViewModelCoreObjC.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = KMMViewModelCoreObjC.h; sourceTree = "<group>"; };
1D198B2E2933C01800EF778D /* KMMVMViewModelScope.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = KMMVMViewModelScope.h; sourceTree = "<group>"; };
1D198B2F2933C01800EF778D /* KMMViewModelCoreObjC.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = KMMViewModelCoreObjC.m; sourceTree = "<group>"; };
1D2AAC582BB1F528005F1344 /* Observable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observable.swift; sourceTree = "<group>"; };
1D2AAC5A2BB2053D005F1344 /* ObservationRegistrar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservationRegistrar.swift; sourceTree = "<group>"; };
1D2AAC5C2BB205AC005F1344 /* ObservableProperty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableProperty.swift; sourceTree = "<group>"; };
1D43F3EB2ABAFCA600EB3DFE /* ObservableViewModelPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableViewModelPublisher.swift; sourceTree = "<group>"; };
1D43F3ED2ABAFD7D00EB3DFE /* ObservableViewModelPublishers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableViewModelPublishers.swift; sourceTree = "<group>"; };
1D6641DC2A5175C3000180D7 /* ChildViewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChildViewModels.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -111,9 +117,12 @@
children = (
1D6641DC2A5175C3000180D7 /* ChildViewModels.swift */,
1DDAF21D293545DD0049C114 /* KMMViewModel.swift */,
1D2AAC582BB1F528005F1344 /* Observable.swift */,
1D2AAC5C2BB205AC005F1344 /* ObservableProperty.swift */,
1D0DA80129336AD40057DDAD /* ObservableViewModel.swift */,
1D43F3EB2ABAFCA600EB3DFE /* ObservableViewModelPublisher.swift */,
1D43F3ED2ABAFD7D00EB3DFE /* ObservableViewModelPublishers.swift */,
1D2AAC5A2BB2053D005F1344 /* ObservationRegistrar.swift */,
);
path = KMMViewModelCore;
sourceTree = "<group>";
Expand Down Expand Up @@ -303,9 +312,12 @@
buildActionMask = 2147483647;
files = (
1D43F3EE2ABAFD7D00EB3DFE /* ObservableViewModelPublishers.swift in Sources */,
1D2AAC5B2BB2053D005F1344 /* ObservationRegistrar.swift in Sources */,
1D43F3EC2ABAFCA600EB3DFE /* ObservableViewModelPublisher.swift in Sources */,
1DDAF21E293545DD0049C114 /* KMMViewModel.swift in Sources */,
1D6641DD2A5175C3000180D7 /* ChildViewModels.swift in Sources */,
1D2AAC5D2BB205AC005F1344 /* ObservableProperty.swift in Sources */,
1D2AAC592BB1F528005F1344 /* Observable.swift in Sources */,
1D0DA80229336AD40057DDAD /* ObservableViewModel.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
Expand Down
16 changes: 16 additions & 0 deletions KMMViewModelCore/Observable.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// Observable.swift
// KMMViewModelCore
//
// Created by Rick Clephas on 25/03/2024.
//

import Foundation
import Observation
import KMMViewModelCoreObjC

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public protocol Observable: KMMViewModel, Observation.Observable {
@ObservationRegistrarBuilder<Self>
var observationRegistrar: ObservationRegistrar { get }
}
47 changes: 47 additions & 0 deletions KMMViewModelCore/ObservableProperty.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
//
// ObservableProperty.swift
// KMMViewModelCore
//
// Created by Rick Clephas on 25/03/2024.
//

import Observation

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public protocol ObservableProperty {

func initialize(subject: any Observable)

func willSet(registrar: Observation.ObservationRegistrar, subject: any Observable)

func didSet(registrar: Observation.ObservationRegistrar, subject: any Observable)

func access(registrar: Observation.ObservationRegistrar, subject: any Observable)
}

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
extension KeyPath: ObservableProperty where Root: Observable {

private func root(_ subject: any Observable) -> Root {
guard let root = subject as? Root else {
fatalError("subject must be of type Root")
}
return root
}

public func initialize(subject: any Observable) {
_ = root(subject)[keyPath: self]
}

public func willSet(registrar: Observation.ObservationRegistrar, subject: any Observable) {
registrar.willSet(root(subject), keyPath: self)
}

public func didSet(registrar: Observation.ObservationRegistrar, subject: any Observable) {
registrar.didSet(root(subject), keyPath: self)
}

public func access(registrar: Observation.ObservationRegistrar, subject: any Observable) {
registrar.access(root(subject), keyPath: self)
}
}
8 changes: 6 additions & 2 deletions KMMViewModelCore/ObservableViewModelPublisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ public final class ObservableViewModelPublisher: Publisher {

internal init(_ viewModel: any KMMViewModel, _ objectWillChange: ObservableObjectPublisher) {
self.viewModel = viewModel
viewModel.viewModelScope.setSendObjectWillChange { [weak self] in
self?.publisher.send()
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *), let observable = viewModel as? (any Observable) {
observable.observationRegistrar.initialize(observable, publisher)
} else {
viewModel.viewModelScope.setPropertyWillSet { [weak self] _ in
self?.publisher.send()
}
}
objectWillChangeCancellable = objectWillChange.sink { [weak self] _ in
self?.publisher.send()
Expand Down
84 changes: 84 additions & 0 deletions KMMViewModelCore/ObservationRegistrar.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
//
// ObservationRegistrar.swift
// KMMViewModelCore
//
// Created by Rick Clephas on 25/03/2024.
//

import Foundation
import Observation
import Combine

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public class ObservationRegistrar {

private let registrar = Observation.ObservationRegistrar()

private let observableProperties: [ObservableProperty]
private var properties: [NSObject:[ObservableProperty]] = [:]

internal init(_ observableProperties: [ObservableProperty]) {
self.observableProperties = observableProperties
}

private weak var observable: (any Observable)? = nil
private var publisher: ObservableObjectPublisher? = nil

internal func initialize(_ observable: any Observable, _ publisher: ObservableObjectPublisher) {
observable.viewModelScope.setPropertyAccess(access)
observable.viewModelScope.setPropertyWillSet(willSet)
observable.viewModelScope.setPropertyDidSet(didSet)
for observableProperty in observableProperties {
Thread.current.threadDictionary["observableProperty"] = observableProperty
observableProperty.initialize(subject: observable)
}
Thread.current.threadDictionary.removeObject(forKey: "observableProperty")
self.observable = observable
self.publisher = publisher
}

private func access(_ property: NSObject) {
guard let observable else {
if let observableProperty = Thread.current.threadDictionary["observableProperty"] as? ObservableProperty {
var observableProperties = properties[property] ?? []
observableProperties.append(observableProperty)
properties[property] = observableProperties
}
return
}
guard let properties = properties[property] else { return }
for property in properties {
property.access(registrar: registrar, subject: observable)
}
}

private func willSet(_ property: NSObject) {
guard let observable, let properties = properties[property] else {
publisher?.send()
return
}
for property in properties {
property.willSet(registrar: registrar, subject: observable)
}
}

private func didSet(_ property: NSObject) {
guard let observable, let properties = properties[property] else { return }
for property in properties {
property.didSet(registrar: registrar, subject: observable)
}
}
}

@resultBuilder
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public struct ObservationRegistrarBuilder<ViewModel: Observable> {

public static func buildExpression<Member>(_ expression: KeyPath<ViewModel, Member>) -> ObservableProperty {
return expression
}

public static func buildBlock(_ components: ObservableProperty...) -> ObservationRegistrar {
return ObservationRegistrar(components)
}
}
4 changes: 3 additions & 1 deletion KMMViewModelCoreObjC/KMMVMViewModelScope.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ __attribute__((swift_name("ViewModelScope")))
@protocol KMMVMViewModelScope
- (void)increaseSubscriptionCount;
- (void)decreaseSubscriptionCount;
- (void)setSendObjectWillChange:(void (^ _Nonnull)(void))sendObjectWillChange;
- (void)setPropertyAccess:(void (^ _Nonnull)(NSObject * _Nonnull))propertyAccess;
- (void)setPropertyWillSet:(void (^ _Nonnull)(NSObject * _Nonnull))propertyWillSet;
- (void)setPropertyDidSet:(void (^ _Nonnull)(NSObject * _Nonnull))propertyDidSet;
- (void)cancel;
@end

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ public actual fun <T> MutableStateFlow(
): MutableStateFlow<T> = MutableStateFlowImpl(viewModelScope.asImpl(), MutableStateFlow(value))

/**
* A [MutableStateFlow] that triggers [ViewModelScopeImpl.sendObjectWillChange]
* A [MutableStateFlow] that triggers [ViewModelScopeImpl.propertyWillSet],
* [ViewModelScopeImpl.propertyDidSet] and [ViewModelScopeImpl.propertyAccess]
* and accounts for the [ViewModelScopeImpl.subscriptionCount].
*/
private class MutableStateFlowImpl<T>(
Expand All @@ -23,16 +24,21 @@ private class MutableStateFlowImpl<T>(
): MutableStateFlow<T> {

override var value: T
get() = stateFlow.value
get() {
viewModelScope.propertyAccess(this)
return stateFlow.value
}
set(value) {
if (stateFlow.value != value) {
viewModelScope.sendObjectWillChange()
}
val changed = stateFlow.value != value
if (changed) viewModelScope.propertyWillSet(this)
stateFlow.value = value
if (changed) viewModelScope.propertyDidSet(this)
}

override val replayCache: List<T>
get() = stateFlow.replayCache
override val replayCache: List<T> get() {
viewModelScope.propertyAccess(this)
return stateFlow.replayCache
}

override val subscriptionCount: StateFlow<Int> =
SubscriptionCountFlow(viewModelScope.subscriptionCount, stateFlow.subscriptionCount)
Expand All @@ -41,10 +47,11 @@ private class MutableStateFlowImpl<T>(
stateFlow.collect(collector)

override fun compareAndSet(expect: T, update: T): Boolean {
if (stateFlow.value == expect && expect != update) {
viewModelScope.sendObjectWillChange()
}
return stateFlow.compareAndSet(expect, update)
val changed = stateFlow.value == expect && expect != update
if (changed) viewModelScope.propertyWillSet(this)
val result = stateFlow.compareAndSet(expect, update)
if (changed) viewModelScope.propertyDidSet(this)
return result
}

@ExperimentalCoroutinesApi
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,20 +59,52 @@ public class ViewModelScopeImpl internal constructor(
_subscriptionCount.update { it - 1 }
}

private var sendObjectWillChange: (() -> Unit)? = null
private var propertyAccess: ((NSObject) -> Unit)? = null

override fun setSendObjectWillChange(sendObjectWillChange: () -> Unit) {
if (this.sendObjectWillChange != null) {
override fun setPropertyAccess(propertyAccess: (NSObject?) -> Unit) {
if (this.propertyAccess != null) {
throw IllegalStateException("KMMViewModel can't be wrapped more than once")
}
this.sendObjectWillChange = sendObjectWillChange
this.propertyAccess = propertyAccess
}

/**
* Invokes the object will change listener set by [setSendObjectWillChange].
* Invokes the listener set by [setPropertyAccess].
*/
public fun sendObjectWillChange() {
sendObjectWillChange?.invoke()
public fun propertyAccess(property: Any) {
propertyAccess?.invoke(property as NSObject)
}

private var propertyWillSet: ((NSObject) -> Unit)? = null

override fun setPropertyWillSet(propertyWillSet: (NSObject?) -> Unit) {
if (this.propertyWillSet != null) {
throw IllegalStateException("KMMViewModel can't be wrapped more than once")
}
this.propertyWillSet = propertyWillSet
}

/**
* Invokes the listener set by [setPropertyWillSet].
*/
public fun propertyWillSet(property: Any) {
propertyWillSet?.invoke(property as NSObject)
}

private var propertyDidSet: ((NSObject) -> Unit)? = null

override fun setPropertyDidSet(propertyDidSet: (NSObject?) -> Unit) {
if (this.propertyDidSet != null) {
throw IllegalStateException("KMMViewModel can't be wrapped more than once")
}
this.propertyDidSet = propertyDidSet
}

/**
* Invokes the listener set by [setPropertyDidSet].
*/
public fun propertyDidSet(property: Any) {
propertyDidSet?.invoke(property as NSObject)
}

override fun cancel() {
Expand Down
11 changes: 11 additions & 0 deletions sample/iosApp/KMMViewModelSample/TimeTravelViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import KMMViewModelSampleShared
import KMMViewModelCore

class TimeTravelViewModel: KMMViewModelSampleShared.TimeTravelViewModel {

Expand All @@ -17,3 +18,13 @@ class TimeTravelViewModel: KMMViewModelSampleShared.TimeTravelViewModel {
super.resetTime()
}
}

@available(iOS 17.0, *)
extension KMMViewModelSampleShared.TimeTravelViewModel: Observable {
public var observationRegistrar: ObservationRegistrar {
\.actualTime
\.travelEffect
\.currentTime
\.isFixedTime
}
}