Skip to content
Merged
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
62 changes: 62 additions & 0 deletions KMPObservableViewModelCore/ObservableProperties.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// ObservableProperties.swift
// KMPObservableViewModelCore
//
// Created by Rick Clephas on 25/10/2025.
//

import Observation
import KMPObservableViewModelCoreObjC

/// Helper object that maps any external `Property` to an `ObservableProperty`.
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
internal final class ObservableProperties {

private var properties: [ObjectIdentifier:ObservableProperty] = [:]

private func observableProperty(_ property: any Property) -> ObservableProperty {
let identifier = ObjectIdentifier(property)
if let observableProperty = properties[identifier] { return observableProperty }
let observableProperty = ObservableProperty(property)
properties[identifier] = observableProperty
return observableProperty
}

func access(_ property: any Property) {
observableProperty(property).access()
}

func willSet(_ property: any Property) {
observableProperty(property).willSet()
}

func didSet(_ property: any Property) {
observableProperty(property).didSet()
}
}

/// Helper object that turns an external `Property` into a Swift observable property.
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
private final class ObservableProperty: Observable {

private let registrar = ObservationRegistrar()
private let property: any Property

init(_ property: any Property) {
self.property = property
}

var value: Any? { property.value }

func access() {
registrar.access(self, keyPath: \.value)
}

func willSet() {
registrar.willSet(self, keyPath: \.value)
}

func didSet() {
registrar.didSet(self, keyPath: \.value)
}
}
39 changes: 37 additions & 2 deletions KMPObservableViewModelCore/ObservableViewModelPublisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,53 @@ public final class ObservableViewModelPublisher: Combine.Publisher, KMPObservabl
private let publisher: ObservableObjectPublisher
private let subscriptionCount: any SubscriptionCount

private var _observableProperties: Any? = nil
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
private var observableProperties: ObservableProperties? {
_observableProperties as! ObservableProperties?
}

internal init(_ viewModel: any ViewModel) {
self.publisher = viewModel.objectWillChange
self.subscriptionCount = viewModel.viewModelScope.subscriptionCount
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *), viewModel is Observable {
_observableProperties = ObservableProperties()
}
}

public func receive<S>(subscriber: S) where S : Subscriber, Never == S.Failure, Void == S.Input {
subscriptionCount.increase()
publisher.receive(subscriber: ObservableViewModelSubscriber(subscriptionCount, subscriber))
}

public func send() {
publisher.send()
public func access(_ property: any Property) {
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *), let observableProperties {
observableProperties.access(property)
}
}

public func willSet(_ property: any Property) {
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *), let observableProperties {
observableProperties.willSet(property)
} else {
publisher.send()
}
}

public func didSet(_ property: any Property) {
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *), let observableProperties {
observableProperties.didSet(property)
}
}
}

internal extension KMPObservableViewModelCoreObjC.Publisher {
/// Casts this `Publisher` to an `ObservableViewModelPublisher`.
func cast() -> ObservableViewModelPublisher {
guard let publisher = self as? ObservableViewModelPublisher else {
fatalError("Publisher must be an ObservableViewModelPublisher")
}
return publisher
}
}

Expand Down
2 changes: 1 addition & 1 deletion KMPObservableViewModelCore/ViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public protocol ViewModel: ObservableObject where ObjectWillChangePublisher == O
public extension ViewModel {
var viewModelWillChange: ObservableViewModelPublisher {
if let publisher = viewModelScope.publisher {
return publisher as! ObservableViewModelPublisher
return publisher.cast()
}
let publisher = ObservableViewModelPublisher(self)
viewModelScope.publisher = publisher
Expand Down
22 changes: 22 additions & 0 deletions KMPObservableViewModelCoreObjC/KMPOVMProperty.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
//
// KMPOVMProperty.h
// KMPObservableViewModelCoreObjC
//
// Created by Rick Clephas on 25/10/2025.
//

#ifndef KMPOVMProperty_h
#define KMPOVMProperty_h

#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

__attribute__((swift_name("Property")))
@protocol KMPOVMProperty
@property (readonly) id _Nullable value;
@end

NS_ASSUME_NONNULL_END

#endif /* KMPOVMProperty_h */
5 changes: 4 additions & 1 deletion KMPObservableViewModelCoreObjC/KMPOVMPublisher.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,15 @@
#define KMPOVMPublisher_h

#import <Foundation/Foundation.h>
#import "KMPOVMProperty.h"

NS_ASSUME_NONNULL_BEGIN

__attribute__((swift_name("Publisher")))
@protocol KMPOVMPublisher
- (void)send;
- (void)access:(id<KMPOVMProperty>)property;
- (void)willSet:(id<KMPOVMProperty>)property;
- (void)didSet:(id<KMPOVMProperty>)property;
@end

NS_ASSUME_NONNULL_END
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// Created by Rick Clephas on 27/11/2022.
//

#import "KMPOVMProperty.h"
#import "KMPOVMPublisher.h"
#import "KMPOVMSubscriptionCount.h"
#import "KMPOVMViewModelScope.h"
27 changes: 26 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,7 @@ Create a `KMPObservableViewModel.swift` file with the following contents:
import KMPObservableViewModelCore
import shared // This should be your shared KMP module

extension Kmp_observableviewmodel_coreViewModel: ViewModel { }
extension Kmp_observableviewmodel_coreViewModel: @retroactive ViewModel { }
```

After that you can use your view model almost as if it were an `ObservableObject`.
Expand Down Expand Up @@ -211,6 +211,31 @@ class TimeTravelViewModel: shared.TimeTravelViewModel {
}
```

### Observation support

When using `ObservableObject`s your SwiftUI views will likely rerender a lot.
Luckily the [Observation](https://developer.apple.com/documentation/Observation) framework brings significant
improvements in terms of change tracking, allowing for more efficient rerendering.

To add support for the Observation framework to all your Kotlin view models,
simply add the following to your `KMPObservableViewModel.swift` file:
```swift
import Observation
import shared // This should be your shared KMP module

extension Kmp_observableviewmodel_coreViewModel: @retroactive Observable { }
```

Alternatively you can add the `Observable` conformance to specific Kotlin view models instead.

> [!NOTE]
> The `Observable` conformance is automatically added if you subclass your view model and use the `@Observable` macro.

> [!NOTE]
> As an added bonus your Kotlin view models will benefit from the Observable framework on supported OS versions
> regardless of your app's deployment target. E.g. you can benefit from Observation support on iOS 17 and above
> while still supporting iOS 16 and below using `ObservableObject`s.

### Child view models

You'll need some additional logic if your `ViewModel`s expose child view models.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlin.reflect.KClass

/**
* A Kotlin Multiplatform Mobile ViewModel.
* A Kotlin Multiplatform ViewModel.
*/
public actual abstract class ViewModel: AndroidXViewModel {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import platform.darwin.NSObject
* Implementation of [ViewModelScope] for Apple platforms.
* @property coroutineScope The [CoroutineScope] associated with the [ViewModel].
*/
internal class NativeViewModelScope internal constructor(
internal class NativeViewModelScope(
val coroutineScope: CoroutineScope
): NSObject(), ViewModelScope {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,32 @@ import kotlinx.coroutines.flow.StateFlow

/**
* A [MutableStateFlow] wrapper that emits state change events through the [NativeViewModelScope]
* and it accounts for the [NativeViewModelScope.subscriptionCount].
* and accounts for the [NativeViewModelScope.subscriptionCount].
*/
@OptIn(ExperimentalForInheritanceCoroutinesApi::class)
internal class ObservableMutableStateFlow<T>(
private val viewModelScope: NativeViewModelScope,
private val stateFlow: MutableStateFlow<T>
): MutableStateFlow<T> {

private val property = StateFlowProperty(stateFlow)

override var value: T
get() = stateFlow.value
get() {
viewModelScope.publisher?.access(property)
return stateFlow.value
}
set(value) {
if (stateFlow.value != value) {
viewModelScope.publisher?.send()
}
val publisher = viewModelScope.publisher?.takeIf { stateFlow.value != value }
publisher?.willSet(property)
stateFlow.value = value
publisher?.didSet(property)
}

// 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#L367
override val replayCache: List<T>
get() = stateFlow.replayCache
get() = listOf(value)

/**
* The combined subscription count from the [NativeViewModelScope] and the actual [StateFlow].
Expand All @@ -38,10 +45,11 @@ internal class ObservableMutableStateFlow<T>(
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)
val publisher = viewModelScope.publisher?.takeIf { stateFlow.value == expect && expect != update }
publisher?.willSet(property)
val result = stateFlow.compareAndSet(expect, update)
publisher?.didSet(property)
return result
}

@ExperimentalCoroutinesApi
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.rickclephas.kmp.observableviewmodel

import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMPropertyProtocol
import kotlinx.coroutines.flow.StateFlow
import platform.darwin.NSObject

/**
* A [Property][KMPOVMPropertyProtocol] that retrieves its value from a [StateFlow].
*/
internal class StateFlowProperty<out T>(
private val stateFlow: StateFlow<T>
): NSObject(), KMPOVMPropertyProtocol {
override fun value(): T = stateFlow.value
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package com.rickclephas.kmp.observableviewmodel
import kotlinx.coroutines.CoroutineScope

/**
* A Kotlin Multiplatform Mobile ViewModel.
* A Kotlin Multiplatform ViewModel.
*/
public expect abstract class ViewModel {

Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
language = Objective-C
package = com.rickclephas.kmp.observableviewmodel.objc
headers = KMPOVMPublisher.h KMPOVMSubscriptionCount.h KMPOVMViewModelScope.h
headers = KMPOVMProperty.h KMPOVMPublisher.h KMPOVMSubscriptionCount.h KMPOVMViewModelScope.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.cancel

/**
* A Kotlin Multiplatform Mobile ViewModel.
* A Kotlin Multiplatform ViewModel.
*/
public actual abstract class ViewModel {

Expand Down
Loading