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 6 commits into
base: feature/v1-api
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
38 changes: 38 additions & 0 deletions KMPObservableViewModelCore/ObservableKeyPath.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
//
// ObservableKeyPath.swift
// KMPObservableViewModelCore
//
// Created by Rick Clephas on 11/06/2025.
//

import Observation
import KMPObservableViewModelCoreObjC

/// An observable `KeyPath` which uses an `ObservationRegistrar` to track changes.
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public final class ObservableKeyPath<Root: AnyObject & Observable, Value>: ViewModelKeyPath {

private weak var subject: Root?
private let keyPath: KeyPath<Root, Value>

/// Creates a new `ObservableKeyPath` for the provided `subject` and `keyPath`.
public init(_ subject: Root, _ keyPath: KeyPath<Root, Value>) {
self.subject = subject
self.keyPath = keyPath
}

public func access(_ publisher: any Publisher) {
guard let subject else { return }
publisher.cast().observationRegistrar.access(subject, keyPath: keyPath)
}

public func willSet(_ publisher: any Publisher) {
guard let subject else { return }
publisher.cast().observationRegistrar.willSet(subject, keyPath: keyPath)
}

public func didSet(_ publisher: any Publisher) {
guard let subject else { return }
publisher.cast().observationRegistrar.didSet(subject, keyPath: keyPath)
}
}
22 changes: 22 additions & 0 deletions KMPObservableViewModelCore/ObservableViewModelPublisher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,25 @@
//

import Combine
import Observation
import KMPObservableViewModelCoreObjC

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

private var _observationRegistrar: Any? = nil
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public var observationRegistrar: ObservationRegistrar {
if let observationRegistrar = _observationRegistrar {
return observationRegistrar as! ObservationRegistrar
}
let observationRegistrar = ObservationRegistrar()
_observationRegistrar = observationRegistrar
return observationRegistrar
}

internal let cancellable = ViewModelCancellable()

private let publisher: ObservableObjectPublisher
Expand All @@ -33,6 +45,16 @@ public final class ObservableViewModelPublisher: Combine.Publisher, KMPObservabl
}
}

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
}
}

/// Subscriber for `ObservableViewModelPublisher` that creates `ObservableViewModelSubscription`s.
private class ObservableViewModelSubscriber<S>: Subscriber where S : Subscriber, Never == S.Failure, Void == S.Input {
typealias Input = Void
Expand Down
28 changes: 28 additions & 0 deletions KMPObservableViewModelCore/PublishedKeyPath.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// PublishedKeyPath.swift
// KMPObservableViewModelCore
//
// Created by Rick Clephas on 09/06/2025.
//

import KMPObservableViewModelCoreObjC

/// A published `KeyPath` which uses an `ObservableObject` to emit change events.
public final class PublishedKeyPath: ViewModelKeyPath {

public static let shared = PublishedKeyPath()

private init() { }

public func access(_ publisher: any Publisher) {
// Published keyPaths only emit on willSet
}

public func willSet(_ publisher: any Publisher) {
publisher.cast().send()
}

public func didSet(_ publisher: any Publisher) {
// Published keyPaths only emit on willSet
}
}
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
25 changes: 25 additions & 0 deletions KMPObservableViewModelCoreObjC/KMPOVMViewModelKeyPath.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
//
// KMPOVMViewModelKeyPath.h
// KMPObservableViewModelCoreObjC
//
// Created by Rick Clephas on 11/06/2025.
//

#ifndef KMPOVMViewModelKeyPath_h
#define KMPOVMViewModelKeyPath_h

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

NS_ASSUME_NONNULL_BEGIN

__attribute__((swift_name("ViewModelKeyPath")))
@protocol KMPOVMViewModelKeyPath
- (void)access:(id<KMPOVMPublisher>)publisher;
- (void)willSet:(id<KMPOVMPublisher>)publisher;
- (void)didSet:(id<KMPOVMPublisher>)publisher;
@end

NS_ASSUME_NONNULL_END

#endif /* KMPOVMViewModelKeyPath_h */
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@

#import "KMPOVMPublisher.h"
#import "KMPOVMSubscriptionCount.h"
#import "KMPOVMViewModelKeyPath.h"
#import "KMPOVMViewModelScope.h"
16 changes: 16 additions & 0 deletions KMPObservableViewModelPlugin/KMPObservableViewModelPlugin.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
//
// KMPObservableViewModelPlugin.swift
// KMPObservableViewModelPlugin
//
// Created by Rick Clephas on 21/06/2025.
//

import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct KMPObservableViewModelPlugin: CompilerPlugin {
var providingMacros: [any Macro.Type] = [
ObservableViewModel.self
]
}
56 changes: 56 additions & 0 deletions KMPObservableViewModelPlugin/ObservableViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
//
// ObservableViewModel.swift
// KMPObservableViewModelPlugin
//
// Created by Rick Clephas on 21/06/2025.
//

import SwiftSyntax
import SwiftSyntaxMacros
import SwiftSyntaxMacroExpansion

public struct ObservableViewModel: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let declaration = declaration.as(ExtensionDeclSyntax.self) else {
throw MacroExpansionErrorMessage("@ObservableViewModel can only be applied to an extension")
}
guard protocols.isEmpty else {
throw MacroExpansionErrorMessage("@ObservableViewModel type must be of type ViewModel")
}
let type = declaration.extendedType
return [
"""
public subscript<Value>(dynamicMember keyPath: KeyPath<\(type.trimmed).Properties, Value>) -> Value {
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
__properties[observable: keyPath]
} else {
__properties[published: keyPath]
}
}
""",
"""
public subscript<Value>(dynamicMember keyPath: ReferenceWritableKeyPath<\(type.trimmed).Properties, Value>) -> Value {
get {
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
__properties[observable: keyPath]
} else {
__properties[published: keyPath]
}
}
set {
if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) {
__properties[observable: keyPath] = newValue
} else {
__properties[published: keyPath] = newValue
}
}
}
"""
]
}
}
45 changes: 45 additions & 0 deletions KMPObservableViewModelProperties/ObservableProperties.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//
// Properties.swift
// KMPObservableViewModelProperties
//
// Created by Rick Clephas on 10/06/2025.
//

import Foundation
import Observation
import KMPObservableViewModelCore

/// A class that stores all `ObservableKeyPath` properties of a ViewModel.
@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public protocol ObservableProperties: Properties, Observable { }

@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *)
public extension ObservableProperties {

/// Returns the value of a property ensuring the `keyPath` is being registered.
subscript<Value>(observable keyPath: KeyPath<Self, Value>) -> Value {
let value = self[keyPath: keyPath]
guard shouldRegisterKeyPath(), Thread.isMainThread else { return value }
registerKeyPath(ObservableKeyPath(self, keyPath))
return self[keyPath: keyPath]
}

/// Gets or sets the value of a property ensuring the `keyPath` is being registered.
subscript<Value>(observable keyPath: ReferenceWritableKeyPath<Self, Value>) -> Value {
get { self[observable: keyPath as KeyPath<Self, Value>] }
set { self[keyPath: keyPath] = newValue }
}

/// `PublishedProperties` implementation required for compilation if only `ObservableProperties` is used.
@_disfavoredOverload
subscript<Value>(published keyPath: KeyPath<Self, Value>) -> Value {
self[observable: keyPath]
}

/// `PublishedProperties` implementation required for compilation if only `ObservableProperties` is used.
@_disfavoredOverload
subscript<Value>(published keyPath: ReferenceWritableKeyPath<Self, Value>) -> Value {
get { self[observable: keyPath] }
set { self[observable: keyPath] = newValue }
}
}
9 changes: 9 additions & 0 deletions KMPObservableViewModelProperties/ObservableViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
//
// ObservableViewModel.swift
// KMPObservableViewModelProperties
//
// Created by Rick Clephas on 21/06/2025.
//

@attached(member, conformances: ViewModel, names: named(subscript(dynamicMember:)))
public macro ObservableViewModel() = #externalMacro(module: "KMPObservableViewModelPlugin", type: "ObservableViewModel")
14 changes: 14 additions & 0 deletions KMPObservableViewModelProperties/Properties.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
//
// Properties.swift
// KMPObservableViewModelProperties
//
// Created by Rick Clephas on 16/06/2025.
//

import KMPObservableViewModelCoreObjC

/// A class that stores all observable properties of a ViewModel.
public protocol Properties: AnyObject {
func shouldRegisterKeyPath() -> Bool
func registerKeyPath(_ keyPath: any ViewModelKeyPath)
}
43 changes: 43 additions & 0 deletions KMPObservableViewModelProperties/PublishedProperties.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// Properties.swift
// KMPObservableViewModelProperties
//
// Created by Rick Clephas on 10/06/2025.
//

import Foundation
import Combine
import KMPObservableViewModelCore

/// A class that stores all `PublishedKeyPath` properties of a ViewModel.
public protocol PublishedProperties: Properties { }

public extension PublishedProperties {

/// Returns the value of a property ensuring the `keyPath` is being registered.
subscript<Value>(published keyPath: KeyPath<Self, Value>) -> Value {
let value = self[keyPath: keyPath]
guard shouldRegisterKeyPath(), Thread.isMainThread else { return value }
registerKeyPath(PublishedKeyPath.shared)
return self[keyPath: keyPath]
}

/// Gets or sets the value of a property ensuring the `keyPath` is being registered.
subscript<Value>(published keyPath: ReferenceWritableKeyPath<Self, Value>) -> Value {
get { self[published: keyPath as KeyPath<Self, Value>] }
set { self[keyPath: keyPath] = newValue }
}

/// `ObservableProperties` implementation required for compilation if only `PublishedProperties` is used.
@_disfavoredOverload
subscript<Value>(observable keyPath: KeyPath<Self, Value>) -> Value {
self[published: keyPath]
}

/// `ObservableProperties` implementation required for compilation if only `PublishedProperties` is used.
@_disfavoredOverload
subscript<Value>(observable keyPath: ReferenceWritableKeyPath<Self, Value>) -> Value {
get { self[published: keyPath] }
set { self[published: keyPath] = newValue }
}
}
18 changes: 18 additions & 0 deletions KMPObservableViewModelProperties/ViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
//
// ViewModel.swift
// KMPObservableViewModelProperties
//
// Created by Rick Clephas on 11/06/2025.
//

import KMPObservableViewModelCore

/// A Kotlin Multiplatform ViewModel.
@dynamicMemberLookup
public protocol ViewModel: KMPObservableViewModelCore.ViewModel { }

private extension ViewModel {
subscript<T>(dynamicMember keyPath: KeyPath<Properties, T>) -> T {
fatalError("ViewModel subscripts will be added by @ObservableViewModel")
}
}
Loading