diff --git a/KMPObservableViewModelCore/ChildViewModels.swift b/KMPObservableViewModelCore/ChildViewModels.swift index 64aeaf2..336bc3c 100644 --- a/KMPObservableViewModelCore/ChildViewModels.swift +++ b/KMPObservableViewModelCore/ChildViewModels.swift @@ -9,19 +9,11 @@ import KMPObservableViewModelCoreObjC public extension ViewModel { - private func setChildViewModelPublishers(_ keyPath: AnyKeyPath, _ publishers: AnyHashable?) { - if let publishers = publishers { - observableViewModelPublishers(for: self).childPublishers[keyPath] = publishers - } else { - observableViewModelPublishers(for: self).childPublishers.removeValue(forKey: keyPath) - } - } - private func setChildViewModel( _ viewModel: VM?, at keyPath: AnyKeyPath ) { - setChildViewModelPublishers(keyPath, observableViewModelPublishers(for: viewModel)) + viewModelWillChange.cancellable.setChildCancellables(keyPath, ViewModelCancellable.get(for: viewModel)) } /// Stores a reference to the `ObservableObject` for the specified child `ViewModel`. @@ -48,8 +40,8 @@ public extension ViewModel { _ viewModels: [VM?]?, at keyPath: AnyKeyPath ) { - setChildViewModelPublishers(keyPath, viewModels?.map { viewModel in - observableViewModelPublishers(for: viewModel) + viewModelWillChange.cancellable.setChildCancellables(keyPath, viewModels?.map { viewModel in + ViewModelCancellable.get(for: viewModel) }) } @@ -95,8 +87,8 @@ public extension ViewModel { _ viewModels: Set?, at keyPath: AnyKeyPath ) { - setChildViewModelPublishers(keyPath, viewModels?.map { viewModel in - observableViewModelPublishers(for: viewModel) + viewModelWillChange.cancellable.setChildCancellables(keyPath, viewModels?.map { viewModel in + ViewModelCancellable.get(for: viewModel) }) } @@ -142,8 +134,8 @@ public extension ViewModel { _ viewModels: [Key : VM?]?, at keyPath: AnyKeyPath ) { - setChildViewModelPublishers(keyPath, viewModels?.mapValues { viewModel in - observableViewModelPublishers(for: viewModel) + viewModelWillChange.cancellable.setChildCancellables(keyPath, viewModels?.mapValues { viewModel in + ViewModelCancellable.get(for: viewModel) }) } diff --git a/KMPObservableViewModelCore/ObservableViewModel.swift b/KMPObservableViewModelCore/ObservableViewModel.swift index 6990b9e..c855c77 100644 --- a/KMPObservableViewModelCore/ObservableViewModel.swift +++ b/KMPObservableViewModelCore/ObservableViewModel.swift @@ -13,8 +13,7 @@ import KMPObservableViewModelCoreObjC public func observableViewModel( for viewModel: VM ) -> ObservableViewModel { - let publishers = observableViewModelPublishers(for: viewModel) - return ObservableViewModel(publishers, viewModel) + return ObservableViewModel(viewModel) } /// Gets an `ObservableObject` for the specified `ViewModel`. @@ -35,13 +34,13 @@ public final class ObservableViewModel: ObservableObject, Hashabl /// The observed `ViewModel`. public let viewModel: VM - /// Holds a strong reference to the publishers - private let publishers: ObservableViewModelPublishers + /// Holds a strong reference to the cancellable + private let cancellable: AnyCancellable - internal init(_ publishers: ObservableViewModelPublishers, _ viewModel: VM) { - objectWillChange = publishers.publisher + internal init(_ viewModel: VM) { + objectWillChange = viewModel.viewModelWillChange self.viewModel = viewModel - self.publishers = publishers + cancellable = ViewModelCancellable.get(for: viewModel) } public static func == (lhs: ObservableViewModel, rhs: ObservableViewModel) -> Bool { diff --git a/KMPObservableViewModelCore/ObservableViewModelPublisher.swift b/KMPObservableViewModelCore/ObservableViewModelPublisher.swift index 4e194a5..898aac2 100644 --- a/KMPObservableViewModelCore/ObservableViewModelPublisher.swift +++ b/KMPObservableViewModelCore/ObservableViewModelPublisher.swift @@ -9,36 +9,27 @@ import Combine import KMPObservableViewModelCoreObjC /// Publisher for `ObservableViewModel` that connects to the `ViewModelScope`. -public final class ObservableViewModelPublisher: Publisher { +public final class ObservableViewModelPublisher: Combine.Publisher, KMPObservableViewModelCoreObjC.Publisher { public typealias Output = Void public typealias Failure = Never - internal weak var viewModel: (any ViewModel)? + internal let cancellable = ViewModelCancellable() - private let publisher = ObservableObjectPublisher() - private var objectWillChangeCancellable: AnyCancellable? = nil + private let publisher: ObservableObjectPublisher + private let subscriptionCount: any SubscriptionCount - internal init(_ viewModel: any ViewModel, _ objectWillChange: ObservableObjectPublisher) { - self.viewModel = viewModel - viewModel.viewModelScope.setSendObjectWillChange { [weak self] in - self?.publisher.send() - } - objectWillChangeCancellable = objectWillChange.sink { [weak self] _ in - self?.publisher.send() - } + internal init(_ viewModel: any ViewModel) { + self.publisher = viewModel.objectWillChange + self.subscriptionCount = viewModel.viewModelScope.subscriptionCount } public func receive(subscriber: S) where S : Subscriber, Never == S.Failure, Void == S.Input { - viewModel?.viewModelScope.increaseSubscriptionCount() - publisher.receive(subscriber: ObservableViewModelSubscriber(self, subscriber)) + subscriptionCount.increase() + publisher.receive(subscriber: ObservableViewModelSubscriber(subscriptionCount, subscriber)) } - deinit { - guard let viewModel else { return } - if let cancellable = viewModel as? Cancellable { - cancellable.cancel() - } - viewModel.clear() + public func send() { + publisher.send() } } @@ -47,16 +38,16 @@ private class ObservableViewModelSubscriber: Subscriber where S : Subscriber, typealias Input = Void typealias Failure = Never - private let publisher: ObservableViewModelPublisher + private let subscriptionCount: any SubscriptionCount private let subscriber: S - init(_ publisher: ObservableViewModelPublisher, _ subscriber: S) { - self.publisher = publisher + init(_ subscriptionCount: any SubscriptionCount, _ subscriber: S) { + self.subscriptionCount = subscriptionCount self.subscriber = subscriber } func receive(subscription: Subscription) { - subscriber.receive(subscription: ObservableViewModelSubscription(publisher, subscription)) + subscriber.receive(subscription: ObservableViewModelSubscription(subscriptionCount, subscription)) } func receive(_ input: Void) -> Subscribers.Demand { @@ -71,11 +62,11 @@ private class ObservableViewModelSubscriber: Subscriber where S : Subscriber, /// Subscription for `ObservableViewModelPublisher` that decreases the subscription count upon cancellation. private class ObservableViewModelSubscription: Subscription { - private let publisher: ObservableViewModelPublisher + private var subscriptionCount: (any SubscriptionCount)? private let subscription: Subscription - init(_ publisher: ObservableViewModelPublisher, _ subscription: Subscription) { - self.publisher = publisher + init(_ subscriptionCount: any SubscriptionCount, _ subscription: Subscription) { + self.subscriptionCount = subscriptionCount self.subscription = subscription } @@ -83,12 +74,9 @@ private class ObservableViewModelSubscription: Subscription { subscription.request(demand) } - private var cancelled = false - func cancel() { subscription.cancel() - guard !cancelled else { return } - cancelled = true - publisher.viewModel?.viewModelScope.decreaseSubscriptionCount() + subscriptionCount?.decrease() + subscriptionCount = nil } } diff --git a/KMPObservableViewModelCore/ObservableViewModelPublishers.swift b/KMPObservableViewModelCore/ObservableViewModelPublishers.swift deleted file mode 100644 index 4142612..0000000 --- a/KMPObservableViewModelCore/ObservableViewModelPublishers.swift +++ /dev/null @@ -1,62 +0,0 @@ -// -// ObservableViewModelPublishers.swift -// KMPObservableViewModelCore -// -// Created by Rick Clephas on 20/09/2023. -// - -import Foundation - -private var observableViewModelPublishersKey: Void? - -private class WeakObservableViewModelPublishers { - weak var publishers: ObservableViewModelPublishers? - init(_ publishers: ObservableViewModelPublishers) { - self.publishers = publishers - } -} - -/// Gets the `ObservableViewModelPublishers` for the specified `viewModel`. -internal func observableViewModelPublishers( - for viewModel: VM -) -> ObservableViewModelPublishers { - let publishers: ObservableViewModelPublishers - if let object = objc_getAssociatedObject(viewModel, &observableViewModelPublishersKey) { - publishers = (object as! WeakObservableViewModelPublishers).publishers ?? { - fatalError("ObservableViewModel has been deallocated") - }() - } else { - let publisher = ObservableViewModelPublisher(viewModel, viewModel.objectWillChange) - publishers = ObservableViewModelPublishers(publisher) - let object = WeakObservableViewModelPublishers(publishers) - objc_setAssociatedObject(viewModel, &observableViewModelPublishersKey, object, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) - } - return publishers -} - -/// Gets the `ObservableViewModelPublishers` for the specified `viewModel`. -internal func observableViewModelPublishers( - for viewModel: VM? -) -> ObservableViewModelPublishers? { - guard let viewModel = viewModel else { return nil } - let observableViewModelPublishers = observableViewModelPublishers(for: viewModel) - return observableViewModelPublishers -} - -/// Helper object that keeps strong references to the `ObservableViewModelPublisher`s. -internal final class ObservableViewModelPublishers: Hashable { - let publisher: ObservableViewModelPublisher - var childPublishers: Dictionary = [:] - - init(_ publisher: ObservableViewModelPublisher) { - self.publisher = publisher - } - - static func == (lhs: ObservableViewModelPublishers, rhs: ObservableViewModelPublishers) -> Bool { - return ObjectIdentifier(lhs) == ObjectIdentifier(rhs) - } - - func hash(into hasher: inout Hasher) { - hasher.combine(ObjectIdentifier(self)) - } -} diff --git a/KMPObservableViewModelCore/ViewModel.swift b/KMPObservableViewModelCore/ViewModel.swift index 4245db9..25adaa4 100644 --- a/KMPObservableViewModelCore/ViewModel.swift +++ b/KMPObservableViewModelCore/ViewModel.swift @@ -8,11 +8,24 @@ import Combine import KMPObservableViewModelCoreObjC -/// A Kotlin Multiplatform Mobile ViewModel. +/// A Kotlin Multiplatform ViewModel. public protocol ViewModel: ObservableObject where ObjectWillChangePublisher == ObservableObjectPublisher { /// The `ViewModelScope` of this `ViewModel`. var viewModelScope: ViewModelScope { get } + /// An `ObservableViewModelPublisher` that emits before this `ViewModel` has changed. + var viewModelWillChange: ObservableViewModelPublisher { get } /// Internal KMP-ObservableViewModel function used to clear the ViewModel. /// - Warning: You should NOT call this yourself! func clear() } + +public extension ViewModel { + var viewModelWillChange: ObservableViewModelPublisher { + if let publisher = viewModelScope.publisher { + return publisher as! ObservableViewModelPublisher + } + let publisher = ObservableViewModelPublisher(self) + viewModelScope.publisher = publisher + return publisher + } +} diff --git a/KMPObservableViewModelCore/ViewModelCancellable.swift b/KMPObservableViewModelCore/ViewModelCancellable.swift new file mode 100644 index 0000000..b833b9a --- /dev/null +++ b/KMPObservableViewModelCore/ViewModelCancellable.swift @@ -0,0 +1,53 @@ +// +// ViewModelCancellable.swift +// KMPObservableViewModelCore +// +// Created by Rick Clephas on 09/06/2025. +// + +import Combine + +/// Helper object that provides a weakly cached `Cancellable` for a `ViewModel`. +internal class ViewModelCancellable { + + private var didInit = false + private weak var cancellable: AnyCancellable? + private var childCancellables: Dictionary? = [:] + + private func get(_ viewModel: any ViewModel) -> AnyCancellable { + guard didInit else { + let cancellable = AnyCancellable { + if let cancellable = viewModel as? Cancellable { + cancellable.cancel() + } + self.childCancellables = nil + viewModel.clear() + } + self.cancellable = cancellable + didInit = true + return cancellable + } + guard let cancellable = self.cancellable else { + fatalError("ObservableViewModel for \(viewModel) has been deallocated") + } + return cancellable + } + + func setChildCancellables(_ keyPath: AnyKeyPath, _ cancellables: AnyHashable?) { + if let cancellables = cancellables { + childCancellables?[keyPath] = cancellables + } else { + childCancellables?.removeValue(forKey: keyPath) + } + } + + static func get(for viewModel: any ViewModel) -> AnyCancellable { + viewModel.viewModelWillChange.cancellable.get(viewModel) + } + + static func get(for viewModel: (any ViewModel)?) -> AnyCancellable? { + guard let viewModel else { return nil } + let cancellable = get(for: viewModel) + return cancellable + } +} diff --git a/KMPObservableViewModelCoreObjC/KMPOVMPublisher.h b/KMPObservableViewModelCoreObjC/KMPOVMPublisher.h new file mode 100644 index 0000000..8669fa7 --- /dev/null +++ b/KMPObservableViewModelCoreObjC/KMPOVMPublisher.h @@ -0,0 +1,22 @@ +// +// KMPOVMPublisher.h +// KMPObservableViewModelCoreObjC +// +// Created by Rick Clephas on 09/06/2025. +// + +#ifndef KMPOVMPublisher_h +#define KMPOVMPublisher_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +__attribute__((swift_name("Publisher"))) +@protocol KMPOVMPublisher +- (void)send; +@end + +NS_ASSUME_NONNULL_END + +#endif /* KMPOVMPublisher_h */ diff --git a/KMPObservableViewModelCoreObjC/KMPOVMSubscriptionCount.h b/KMPObservableViewModelCoreObjC/KMPOVMSubscriptionCount.h new file mode 100644 index 0000000..a3da31c --- /dev/null +++ b/KMPObservableViewModelCoreObjC/KMPOVMSubscriptionCount.h @@ -0,0 +1,23 @@ +// +// KMPOVMSubscriptionCount.h +// KMPObservableViewModelCoreObjC +// +// Created by Rick Clephas on 09/06/2025. +// + +#ifndef KMPOVMSubscriptionCount_h +#define KMPOVMSubscriptionCount_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +__attribute__((swift_name("SubscriptionCount"))) +@protocol KMPOVMSubscriptionCount +- (void)increase; +- (void)decrease; +@end + +NS_ASSUME_NONNULL_END + +#endif /* KMPOVMSubscriptionCount_h */ diff --git a/KMPObservableViewModelCoreObjC/KMPOVMViewModelScope.h b/KMPObservableViewModelCoreObjC/KMPOVMViewModelScope.h index baab6cc..57dc93b 100644 --- a/KMPObservableViewModelCoreObjC/KMPOVMViewModelScope.h +++ b/KMPObservableViewModelCoreObjC/KMPOVMViewModelScope.h @@ -9,12 +9,17 @@ #define KMPOVMViewModelScope_h #import +#import "KMPOVMPublisher.h" +#import "KMPOVMSubscriptionCount.h" + +NS_ASSUME_NONNULL_BEGIN __attribute__((swift_name("ViewModelScope"))) @protocol KMPOVMViewModelScope -- (void)increaseSubscriptionCount; -- (void)decreaseSubscriptionCount; -- (void)setSendObjectWillChange:(void (^ _Nonnull)(void))sendObjectWillChange; +@property (readonly) id subscriptionCount; +@property id _Nullable publisher; @end +NS_ASSUME_NONNULL_END + #endif /* KMPOVMViewModelScope_h */ diff --git a/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h b/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h index 2b2e329..f7c0985 100644 --- a/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h +++ b/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h @@ -5,4 +5,6 @@ // Created by Rick Clephas on 27/11/2022. // +#import "KMPOVMPublisher.h" +#import "KMPOVMSubscriptionCount.h" #import "KMPOVMViewModelScope.h" diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/CombinedSubscriptionCount.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/CombinedSubscriptionCount.kt new file mode 100644 index 0000000..2939dfd --- /dev/null +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/CombinedSubscriptionCount.kt @@ -0,0 +1,35 @@ +package com.rickclephas.kmp.observableviewmodel + +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +/** + * A [StateFlow] that combines the subscription counts of a [NativeViewModelScope] and a [StateFlow]. + */ +@OptIn(ExperimentalForInheritanceCoroutinesApi::class) +internal class CombinedSubscriptionCount private constructor( + private val viewModelScopeCount: StateFlow, + private val stateFlowCount: StateFlow +): StateFlow { + + constructor(viewModelScope: NativeViewModelScope, stateFlow: MutableStateFlow<*>) : this( + viewModelScopeCount = viewModelScope.subscriptionCount().stateFlow, + stateFlowCount = stateFlow.subscriptionCount + ) + + override val value: Int + get() = viewModelScopeCount.value + stateFlowCount.value + + override val replayCache: List + get() = listOf(value) + + override suspend fun collect(collector: FlowCollector): Nothing { + viewModelScopeCount.combine(stateFlowCount) { viewModelScopeCount, stateFlowCount -> + viewModelScopeCount + stateFlowCount + }.collect(collector) + throw IllegalStateException("CombinedSubscriptionCount.collect should never complete") + } +} diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt new file mode 100644 index 0000000..23470c6 --- /dev/null +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/NativeViewModelScope.kt @@ -0,0 +1,29 @@ +package com.rickclephas.kmp.observableviewmodel + +import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMPublisherProtocol +import kotlinx.coroutines.CoroutineScope +import platform.darwin.NSObject + +/** + * Implementation of [ViewModelScope] for Apple platforms. + * @property coroutineScope The [CoroutineScope] associated with the [ViewModel]. + */ +internal class NativeViewModelScope internal constructor( + val coroutineScope: CoroutineScope +): NSObject(), ViewModelScope { + + private val _subscriptionCount = SubscriptionCount() + override fun subscriptionCount(): SubscriptionCount = _subscriptionCount + + private var _publisher: KMPOVMPublisherProtocol? = null + override fun publisher(): KMPOVMPublisherProtocol? = _publisher + override fun setPublisher(publisher: KMPOVMPublisherProtocol?) { + if (_publisher != null) throw IllegalStateException("ViewModel can't be initialized more than once") + _publisher = publisher + } +} + +/** + * Casts `this` [ViewModelScope] to a [NativeViewModelScope]. + */ +internal inline fun ViewModelScope.asNative(): NativeViewModelScope = this as NativeViewModelScope diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt new file mode 100644 index 0000000..6d7857a --- /dev/null +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ObservableStateFlow.kt @@ -0,0 +1,72 @@ +package com.rickclephas.kmp.observableviewmodel + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * A [MutableStateFlow] wrapper that emits state change events through the [NativeViewModelScope] + * and it accounts for the [NativeViewModelScope.subscriptionCount]. + */ +@OptIn(ExperimentalForInheritanceCoroutinesApi::class) +internal class ObservableMutableStateFlow( + private val viewModelScope: NativeViewModelScope, + private val stateFlow: MutableStateFlow +): MutableStateFlow { + + override var value: T + get() = stateFlow.value + set(value) { + if (stateFlow.value != value) { + viewModelScope.publisher?.send() + } + stateFlow.value = value + } + + override val replayCache: List + get() = stateFlow.replayCache + + /** + * The combined subscription count from the [NativeViewModelScope] and the actual [StateFlow]. + */ + override val subscriptionCount: StateFlow = CombinedSubscriptionCount(viewModelScope, stateFlow) + + override suspend fun collect(collector: FlowCollector): Nothing = + stateFlow.collect(collector) + + override fun compareAndSet(expect: T, update: T): Boolean { + if (stateFlow.value == expect && expect != update) { + viewModelScope.publisher?.send() + } + return stateFlow.compareAndSet(expect, update) + } + + @ExperimentalCoroutinesApi + override fun resetReplayCache() = stateFlow.resetReplayCache() + + // Same implementation as in StateFlowImpl, but we need to go through our own value property. + // https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/StateFlow.kt#L369 + override fun tryEmit(value: T): Boolean { + this.value = value + return true + } + + // Same implementation as in StateFlowImpl, but we need to go through our own value property. + // https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/StateFlow.kt#L374 + override suspend fun emit(value: T) { + this.value = value + } +} + +/** + * A [StateFlow] backed by an [ObservableMutableStateFlow] optionally holding a reference to a [Job]. + */ +@OptIn(ExperimentalForInheritanceCoroutinesApi::class) +internal class ObservableStateFlow( + internal val flow: ObservableMutableStateFlow, + @Suppress("unused") + private val job: Job? = null +): StateFlow by flow diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt index dfd9f30..b66c996 100644 --- a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt @@ -11,81 +11,7 @@ import kotlin.coroutines.EmptyCoroutineContext public actual fun MutableStateFlow( viewModelScope: ViewModelScope, value: T -): MutableStateFlow = MutableStateFlowImpl(viewModelScope.asImpl(), MutableStateFlow(value)) - -/** - * A [MutableStateFlow] that triggers [ViewModelScopeImpl.sendObjectWillChange] - * and accounts for the [ViewModelScopeImpl.subscriptionCount]. - */ -@OptIn(ExperimentalForInheritanceCoroutinesApi::class) -private class MutableStateFlowImpl( - private val viewModelScope: ViewModelScopeImpl, - private val stateFlow: MutableStateFlow -): MutableStateFlow { - - override var value: T - get() = stateFlow.value - set(value) { - if (stateFlow.value != value) { - viewModelScope.sendObjectWillChange() - } - stateFlow.value = value - } - - override val replayCache: List - get() = stateFlow.replayCache - - override val subscriptionCount: StateFlow = - SubscriptionCountFlow(viewModelScope.subscriptionCount, stateFlow.subscriptionCount) - - override suspend fun collect(collector: FlowCollector): Nothing = - stateFlow.collect(collector) - - override fun compareAndSet(expect: T, update: T): Boolean { - if (stateFlow.value == expect && expect != update) { - viewModelScope.sendObjectWillChange() - } - return stateFlow.compareAndSet(expect, update) - } - - @ExperimentalCoroutinesApi - override fun resetReplayCache() = stateFlow.resetReplayCache() - - // Same implementation as in StateFlowImpl, but we need to go through our own value property. - // https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/StateFlow.kt#L369 - override fun tryEmit(value: T): Boolean { - this.value = value - return true - } - - // Same implementation as in StateFlowImpl, but we need to go through our own value property. - // https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/StateFlow.kt#L374 - override suspend fun emit(value: T) { - this.value = value - } -} - -/** - * A [StateFlow] that combines the subscription counts of a [ViewModelScopeImpl] and [StateFlow]. - */ -@OptIn(ExperimentalForInheritanceCoroutinesApi::class) -private class SubscriptionCountFlow( - private val viewModelScopeSubscriptionCount: StateFlow, - private val stateFlowSubscriptionCount: StateFlow -): StateFlow { - override val value: Int - get() = viewModelScopeSubscriptionCount.value + stateFlowSubscriptionCount.value - - override val replayCache: List - get() = listOf(value) - - override suspend fun collect(collector: FlowCollector): Nothing { - viewModelScopeSubscriptionCount.combine(stateFlowSubscriptionCount) { count1, count2 -> - count1 + count2 - }.collect(collector) - throw IllegalStateException("SubscriptionCountFlow collect completed") - } -} +): MutableStateFlow = ObservableMutableStateFlow(viewModelScope.asNative(), MutableStateFlow(value)) /** * @see kotlinx.coroutines.flow.stateIn @@ -97,10 +23,10 @@ public actual fun Flow.stateIn( ): StateFlow { // Similar to kotlinx.coroutines, but using our custom MutableStateFlowImpl and CoroutineContext logic. // https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/operators/Share.kt#L135 - val scope = viewModelScope.asImpl() - val state = MutableStateFlowImpl(scope, MutableStateFlow(initialValue)) + val scope = viewModelScope.asNative() + val state = ObservableMutableStateFlow(scope, MutableStateFlow(initialValue)) val job = scope.coroutineScope.launchSharing(EmptyCoroutineContext, this, state, started, initialValue) - return ReadonlyStateFlow(state, job) + return ObservableStateFlow(state, job) } /** @@ -136,14 +62,3 @@ private fun CoroutineScope.launchSharing( } } } - -/** - * Similar to the kotlinx.coroutines implementation, used to return a read-only StateFlow with an optional Job. - * https://github.com/Kotlin/kotlinx.coroutines/blob/6dfabf763fe9fc91fbb73eb0f2d5b488f53043f1/kotlinx-coroutines-core/common/src/flow/operators/Share.kt#L379 - */ -@OptIn(ExperimentalForInheritanceCoroutinesApi::class) -private class ReadonlyStateFlow( - flow: StateFlow, - @Suppress("unused") - private val job: Job? -): StateFlow by flow diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/SubscriptionCount.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/SubscriptionCount.kt new file mode 100644 index 0000000..01ac402 --- /dev/null +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/SubscriptionCount.kt @@ -0,0 +1,20 @@ +package com.rickclephas.kmp.observableviewmodel + +import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMSubscriptionCountProtocol +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import platform.darwin.NSObject + +/** + * [KMPOVMSubscriptionCountProtocol] implementation that uses a [StateFlow] to store the subscription count. + */ +internal class SubscriptionCount: NSObject(), KMPOVMSubscriptionCountProtocol { + + private val _stateFlow = MutableStateFlow(0) + val stateFlow: StateFlow = _stateFlow.asStateFlow() + + override fun increase(): Unit = _stateFlow.update { it + 1 } + override fun decrease(): Unit = _stateFlow.update { it - 1 } +} diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelScope.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelScope.kt index ef143b0..2136156 100644 --- a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelScope.kt +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelScope.kt @@ -2,11 +2,6 @@ package com.rickclephas.kmp.observableviewmodel import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMViewModelScopeProtocol import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import platform.darwin.NSObject /** * Holds the [CoroutineScope] of a [ViewModel]. @@ -18,56 +13,10 @@ public actual typealias ViewModelScope = KMPOVMViewModelScopeProtocol * Creates a new [ViewModelScope] for the provided [coroutineScope]. */ internal actual fun ViewModelScope(coroutineScope: CoroutineScope): ViewModelScope = - ViewModelScopeImpl(coroutineScope) + NativeViewModelScope(coroutineScope) /** * Gets the [CoroutineScope] associated with the [ViewModel] of `this` [ViewModelScope]. */ public actual val ViewModelScope.coroutineScope: CoroutineScope - get() = asImpl().coroutineScope - -/** - * Casts `this` [ViewModelScope] to a [ViewModelScopeImpl]. - */ -@InternalKMPObservableViewModelApi -public inline fun ViewModelScope.asImpl(): ViewModelScopeImpl = this as ViewModelScopeImpl - -/** - * Implementation of [ViewModelScope]. - * @property coroutineScope The [CoroutineScope] associated with the [ViewModel]. - */ -@InternalKMPObservableViewModelApi -public class ViewModelScopeImpl internal constructor( - public val coroutineScope: CoroutineScope -): NSObject(), ViewModelScope { - - private val _subscriptionCount = MutableStateFlow(0) - /** - * A [StateFlow] that emits the number of subscribers to the [ViewModel]. - */ - public val subscriptionCount: StateFlow = _subscriptionCount.asStateFlow() - - override fun increaseSubscriptionCount() { - _subscriptionCount.update { it + 1 } - } - - override fun decreaseSubscriptionCount() { - _subscriptionCount.update { it - 1 } - } - - private var sendObjectWillChange: (() -> Unit)? = null - - override fun setSendObjectWillChange(sendObjectWillChange: () -> Unit) { - if (this.sendObjectWillChange != null) { - throw IllegalStateException("ViewModel can't be wrapped more than once") - } - this.sendObjectWillChange = sendObjectWillChange - } - - /** - * Invokes the object will change listener set by [setSendObjectWillChange]. - */ - public fun sendObjectWillChange() { - sendObjectWillChange?.invoke() - } -} + get() = asNative().coroutineScope diff --git a/kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def b/kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def index 96925fb..b830088 100644 --- a/kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def +++ b/kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def @@ -1,3 +1,3 @@ language = Objective-C package = com.rickclephas.kmp.observableviewmodel.objc -headers = KMPOVMViewModelScope.h +headers = KMPOVMPublisher.h KMPOVMSubscriptionCount.h KMPOVMViewModelScope.h