diff --git a/KMPObservableViewModelCore/ObservableProperties.swift b/KMPObservableViewModelCore/ObservableProperties.swift new file mode 100644 index 0000000..5255696 --- /dev/null +++ b/KMPObservableViewModelCore/ObservableProperties.swift @@ -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) + } +} diff --git a/KMPObservableViewModelCore/ObservableViewModelPublisher.swift b/KMPObservableViewModelCore/ObservableViewModelPublisher.swift index 898aac2..92b49fb 100644 --- a/KMPObservableViewModelCore/ObservableViewModelPublisher.swift +++ b/KMPObservableViewModelCore/ObservableViewModelPublisher.swift @@ -18,9 +18,18 @@ 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(subscriber: S) where S : Subscriber, Never == S.Failure, Void == S.Input { @@ -28,8 +37,34 @@ public final class ObservableViewModelPublisher: Combine.Publisher, KMPObservabl 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 } } diff --git a/KMPObservableViewModelCore/ViewModel.swift b/KMPObservableViewModelCore/ViewModel.swift index 25adaa4..4c9aab4 100644 --- a/KMPObservableViewModelCore/ViewModel.swift +++ b/KMPObservableViewModelCore/ViewModel.swift @@ -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 diff --git a/KMPObservableViewModelCoreObjC/KMPOVMProperty.h b/KMPObservableViewModelCoreObjC/KMPOVMProperty.h new file mode 100644 index 0000000..cffccbc --- /dev/null +++ b/KMPObservableViewModelCoreObjC/KMPOVMProperty.h @@ -0,0 +1,22 @@ +// +// KMPOVMProperty.h +// KMPObservableViewModelCoreObjC +// +// Created by Rick Clephas on 25/10/2025. +// + +#ifndef KMPOVMProperty_h +#define KMPOVMProperty_h + +#import + +NS_ASSUME_NONNULL_BEGIN + +__attribute__((swift_name("Property"))) +@protocol KMPOVMProperty +@property (readonly) id _Nullable value; +@end + +NS_ASSUME_NONNULL_END + +#endif /* KMPOVMProperty_h */ diff --git a/KMPObservableViewModelCoreObjC/KMPOVMPublisher.h b/KMPObservableViewModelCoreObjC/KMPOVMPublisher.h index 8669fa7..aae935f 100644 --- a/KMPObservableViewModelCoreObjC/KMPOVMPublisher.h +++ b/KMPObservableViewModelCoreObjC/KMPOVMPublisher.h @@ -9,12 +9,15 @@ #define KMPOVMPublisher_h #import +#import "KMPOVMProperty.h" NS_ASSUME_NONNULL_BEGIN __attribute__((swift_name("Publisher"))) @protocol KMPOVMPublisher -- (void)send; +- (void)access:(id)property; +- (void)willSet:(id)property; +- (void)didSet:(id)property; @end NS_ASSUME_NONNULL_END diff --git a/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h b/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h index f7c0985..744b81c 100644 --- a/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h +++ b/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h @@ -5,6 +5,7 @@ // Created by Rick Clephas on 27/11/2022. // +#import "KMPOVMProperty.h" #import "KMPOVMPublisher.h" #import "KMPOVMSubscriptionCount.h" #import "KMPOVMViewModelScope.h" diff --git a/README.md b/README.md index 3a50ff6..af7c5ca 100644 --- a/README.md +++ b/README.md @@ -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`. @@ -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. diff --git a/kmp-observableviewmodel-core/src/androidxMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt b/kmp-observableviewmodel-core/src/androidxMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt index bf30174..9d20a9f 100644 --- a/kmp-observableviewmodel-core/src/androidxMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt +++ b/kmp-observableviewmodel-core/src/androidxMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt @@ -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 { 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 index 23470c6..3b864b7 100644 --- 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 @@ -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 { 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 index 6d7857a..08aa067 100644 --- 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 @@ -9,7 +9,7 @@ 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( @@ -17,17 +17,24 @@ internal class ObservableMutableStateFlow( private val stateFlow: MutableStateFlow ): MutableStateFlow { + 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 - get() = stateFlow.replayCache + get() = listOf(value) /** * The combined subscription count from the [NativeViewModelScope] and the actual [StateFlow]. @@ -38,10 +45,11 @@ internal class ObservableMutableStateFlow( 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 diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlowProperty.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlowProperty.kt new file mode 100644 index 0000000..a5136af --- /dev/null +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlowProperty.kt @@ -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( + private val stateFlow: StateFlow +): NSObject(), KMPOVMPropertyProtocol { + override fun value(): T = stateFlow.value +} diff --git a/kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt b/kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt index c274ad7..a0c5a48 100644 --- a/kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt +++ b/kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt @@ -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 { diff --git a/kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def b/kmp-observableviewmodel-core/src/nativeInterop/cinterop/KMPObservableViewModelCoreObjC.def index b830088..7685506 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 = KMPOVMPublisher.h KMPOVMSubscriptionCount.h KMPOVMViewModelScope.h +headers = KMPOVMProperty.h KMPOVMPublisher.h KMPOVMSubscriptionCount.h KMPOVMViewModelScope.h diff --git a/kmp-observableviewmodel-core/src/nonAndroidxMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt b/kmp-observableviewmodel-core/src/nonAndroidxMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt index 60c298c..3c7fd94 100644 --- a/kmp-observableviewmodel-core/src/nonAndroidxMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt +++ b/kmp-observableviewmodel-core/src/nonAndroidxMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModel.kt @@ -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 { diff --git a/sample/iosApp/KMPObservableViewModelSample.xcodeproj/project.pbxproj b/sample/iosApp/KMPObservableViewModelSample.xcodeproj/project.pbxproj index e9761ef..a94448b 100644 --- a/sample/iosApp/KMPObservableViewModelSample.xcodeproj/project.pbxproj +++ b/sample/iosApp/KMPObservableViewModelSample.xcodeproj/project.pbxproj @@ -8,16 +8,18 @@ /* Begin PBXBuildFile section */ 1D44EF78292C066B00465C43 /* KMPObservableViewModelSampleApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D44EF77292C066B00465C43 /* KMPObservableViewModelSampleApp.swift */; }; - 1D44EF7A292C066B00465C43 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D44EF79292C066B00465C43 /* ContentView.swift */; }; + 1D44EF7A292C066B00465C43 /* ContentViewSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D44EF79292C066B00465C43 /* ContentViewSwiftUI.swift */; }; 1D44EF7C292C066C00465C43 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1D44EF7B292C066C00465C43 /* Assets.xcassets */; }; 1D44EF7F292C066C00465C43 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1D44EF7E292C066C00465C43 /* Preview Assets.xcassets */; }; 1D44EF89292C066C00465C43 /* KMPObservableViewModelSampleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D44EF88292C066C00465C43 /* KMPObservableViewModelSampleTests.swift */; }; 1D44EF93292C066C00465C43 /* KMPObservableViewModelSampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D44EF92292C066C00465C43 /* KMPObservableViewModelSampleUITests.swift */; }; 1D44EF95292C066C00465C43 /* KMPObservableViewModelSampleUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D44EF94292C066C00465C43 /* KMPObservableViewModelSampleUITestsLaunchTests.swift */; }; + 1DC34F6A2EAE33580063597A /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DC34F692EAE33550063597A /* RootView.swift */; }; 1DCF89AB2933929400A4C54A /* KMPObservableViewModelSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 1DCF89AA2933929400A4C54A /* KMPObservableViewModelSwiftUI */; }; 1DDAF2202935470A0049C114 /* KMPObservableViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DDAF21F2935470A0049C114 /* KMPObservableViewModel.swift */; }; 1DDAF222293548A60049C114 /* TimeTravelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DDAF221293548A60049C114 /* TimeTravelViewModel.swift */; }; - 1DF227B02C2856BC00D8B3A7 /* ContentViewMP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF227AF2C2856BC00D8B3A7 /* ContentViewMP.swift */; }; + 1DDE18852E114DE800227AE6 /* ChangeCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DDE18842E114DE500227AE6 /* ChangeCounter.swift */; }; + 1DF227B02C2856BC00D8B3A7 /* ContentViewCompose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF227AF2C2856BC00D8B3A7 /* ContentViewCompose.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -41,7 +43,7 @@ 1D198B1A2933AFDE00EF778D /* KMPObservableViewModelCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = KMPObservableViewModelCore.framework; path = "../../.build-core-xcf/KMPObservableViewModelCore.xcframework/ios-arm64_x86_64-simulator/KMPObservableViewModelCore.framework"; sourceTree = ""; }; 1D44EF74292C066B00465C43 /* KMPObservableViewModelSample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = KMPObservableViewModelSample.app; sourceTree = BUILT_PRODUCTS_DIR; }; 1D44EF77292C066B00465C43 /* KMPObservableViewModelSampleApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPObservableViewModelSampleApp.swift; sourceTree = ""; }; - 1D44EF79292C066B00465C43 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 1D44EF79292C066B00465C43 /* ContentViewSwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewSwiftUI.swift; sourceTree = ""; }; 1D44EF7B292C066C00465C43 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 1D44EF7E292C066C00465C43 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 1D44EF84292C066C00465C43 /* KMPObservableViewModelSampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KMPObservableViewModelSampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -49,10 +51,12 @@ 1D44EF8E292C066C00465C43 /* KMPObservableViewModelSampleUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = KMPObservableViewModelSampleUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 1D44EF92292C066C00465C43 /* KMPObservableViewModelSampleUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPObservableViewModelSampleUITests.swift; sourceTree = ""; }; 1D44EF94292C066C00465C43 /* KMPObservableViewModelSampleUITestsLaunchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPObservableViewModelSampleUITestsLaunchTests.swift; sourceTree = ""; }; + 1DC34F692EAE33550063597A /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 1DCF89A82933928600A4C54A /* KMP-ObservableViewModel */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = "KMP-ObservableViewModel"; path = ../..; sourceTree = ""; }; 1DDAF21F2935470A0049C114 /* KMPObservableViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMPObservableViewModel.swift; sourceTree = ""; }; 1DDAF221293548A60049C114 /* TimeTravelViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeTravelViewModel.swift; sourceTree = ""; }; - 1DF227AF2C2856BC00D8B3A7 /* ContentViewMP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewMP.swift; sourceTree = ""; }; + 1DDE18842E114DE500227AE6 /* ChangeCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeCounter.swift; sourceTree = ""; }; + 1DF227AF2C2856BC00D8B3A7 /* ContentViewCompose.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewCompose.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -106,13 +110,15 @@ 1D44EF76292C066B00465C43 /* KMPObservableViewModelSample */ = { isa = PBXGroup; children = ( - 1D44EF77292C066B00465C43 /* KMPObservableViewModelSampleApp.swift */, - 1D44EF79292C066B00465C43 /* ContentView.swift */, 1D44EF7B292C066C00465C43 /* Assets.xcassets */, - 1D44EF7D292C066C00465C43 /* Preview Content */, + 1DDE18842E114DE500227AE6 /* ChangeCounter.swift */, + 1DF227AF2C2856BC00D8B3A7 /* ContentViewCompose.swift */, + 1D44EF79292C066B00465C43 /* ContentViewSwiftUI.swift */, 1DDAF21F2935470A0049C114 /* KMPObservableViewModel.swift */, + 1D44EF77292C066B00465C43 /* KMPObservableViewModelSampleApp.swift */, + 1D44EF7D292C066C00465C43 /* Preview Content */, + 1DC34F692EAE33550063597A /* RootView.swift */, 1DDAF221293548A60049C114 /* TimeTravelViewModel.swift */, - 1DF227AF2C2856BC00D8B3A7 /* ContentViewMP.swift */, ); path = KMPObservableViewModelSample; sourceTree = ""; @@ -316,8 +322,10 @@ files = ( 1DDAF2202935470A0049C114 /* KMPObservableViewModel.swift in Sources */, 1DDAF222293548A60049C114 /* TimeTravelViewModel.swift in Sources */, - 1D44EF7A292C066B00465C43 /* ContentView.swift in Sources */, - 1DF227B02C2856BC00D8B3A7 /* ContentViewMP.swift in Sources */, + 1DDE18852E114DE800227AE6 /* ChangeCounter.swift in Sources */, + 1D44EF7A292C066B00465C43 /* ContentViewSwiftUI.swift in Sources */, + 1DC34F6A2EAE33580063597A /* RootView.swift in Sources */, + 1DF227B02C2856BC00D8B3A7 /* ContentViewCompose.swift in Sources */, 1D44EF78292C066B00465C43 /* KMPObservableViewModelSampleApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/sample/iosApp/KMPObservableViewModelSample/ChangeCounter.swift b/sample/iosApp/KMPObservableViewModelSample/ChangeCounter.swift new file mode 100644 index 0000000..2aa9e13 --- /dev/null +++ b/sample/iosApp/KMPObservableViewModelSample/ChangeCounter.swift @@ -0,0 +1,46 @@ +// +// ChangeCounter.swift +// KMPObservableViewModelSample +// +// Created by Rick Clephas on 29/06/2025. +// + +import SwiftUI + +@MainActor +class Counter: ObservableObject { + + private var count = 0 + + func incrementAndGet() -> Int { + count += 1 + return count + } +} + +struct ChangeCounter: View { + + var count: Int + var content: Content + + init(_ count: Int, @ViewBuilder _ content: () -> Content) { + self.count = count + self.content = content() + } + + var body: some View { + ZStack(alignment: .bottom) { + content.padding(.bottom, 32) + Text("Changes: \(count)") + .foregroundStyle(.foreground.opacity(0.6)) + .dynamicTypeSize(.small) + .padding(.horizontal, 16) + .background(.background) + } + .frame(minWidth: 0, maxWidth: .infinity) + .padding(.vertical, 16) + .padding(.horizontal, 8) + .background { RoundedRectangle(cornerRadius: 16).stroke(.foreground.opacity(0.3), lineWidth: 2).padding(.bottom, 24) } + .padding(.horizontal, 8) + } +} diff --git a/sample/iosApp/KMPObservableViewModelSample/ContentView.swift b/sample/iosApp/KMPObservableViewModelSample/ContentView.swift deleted file mode 100644 index 9a18d24..0000000 --- a/sample/iosApp/KMPObservableViewModelSample/ContentView.swift +++ /dev/null @@ -1,73 +0,0 @@ -// -// ContentView.swift -// KMPObservableViewModelSample -// -// Created by Rick Clephas on 21/11/2022. -// - -import SwiftUI -import KMPObservableViewModelSwiftUI - -struct ContentView: View { - - @StateViewModel var viewModel = TimeTravelViewModel() - - private var isFixedTimeBinding: Binding { - Binding { viewModel.isFixedTime } set: { isFixedTime in - if isFixedTime { - viewModel.stopTime() - } else { - viewModel.startTime() - } - } - } - - var body: some View { - VStack{ - Spacer() - Group { - Text("Actual time:") - Text(viewModel.actualTime) - .font(.system(size: 20)) - } - Group { - Spacer().frame(height: 24) - Text("Travel effect:") - Text(viewModel.travelEffect?.description ?? "nil") - .font(.system(size: 20)) - } - Group { - Spacer().frame(height: 24) - Text("Current time:") - Text(viewModel.currentTime) - .font(.system(size: 20)) - } - Group { - Spacer().frame(height: 24) - HStack { - Toggle("", isOn: isFixedTimeBinding).labelsHidden() - Text("Fixed time") - } - } - Group { - Spacer().frame(height: 24) - Button("Time travel") { - viewModel.timeTravel() - } - } - Group { - Spacer().frame(height: 24) - Button("Reset") { - viewModel.resetTime() - }.foregroundColor(viewModel.isResetDisabled ? Color.red : Color.green) - } - Spacer() - } - } -} - -struct ContentView_Previews: PreviewProvider { - static var previews: some View { - ContentView() - } -} diff --git a/sample/iosApp/KMPObservableViewModelSample/ContentViewMP.swift b/sample/iosApp/KMPObservableViewModelSample/ContentViewCompose.swift similarity index 60% rename from sample/iosApp/KMPObservableViewModelSample/ContentViewMP.swift rename to sample/iosApp/KMPObservableViewModelSample/ContentViewCompose.swift index e9113d4..7d38ff9 100644 --- a/sample/iosApp/KMPObservableViewModelSample/ContentViewMP.swift +++ b/sample/iosApp/KMPObservableViewModelSample/ContentViewCompose.swift @@ -1,5 +1,5 @@ // -// ContentViewMP.swift +// ContentViewCompose.swift // KMPObservableViewModelSample // // Created by Rick Clephas on 23/06/2024. @@ -8,7 +8,16 @@ import SwiftUI import KMPObservableViewModelSampleShared -struct ContentViewMP: UIViewControllerRepresentable { +struct ContentViewCompose: View { + + var body: some View { + ComposeViewController() + .navigationTitle(Text("Compose Multiplatform")) + .navigationBarTitleDisplayMode(.inline) + } +} + +private struct ComposeViewController: UIViewControllerRepresentable { @StateObject var viewModel = TimeTravelViewModel() diff --git a/sample/iosApp/KMPObservableViewModelSample/ContentViewSwiftUI.swift b/sample/iosApp/KMPObservableViewModelSample/ContentViewSwiftUI.swift new file mode 100644 index 0000000..dc528ed --- /dev/null +++ b/sample/iosApp/KMPObservableViewModelSample/ContentViewSwiftUI.swift @@ -0,0 +1,168 @@ +// +// ContentViewSwiftUI.swift +// KMPObservableViewModelSample +// +// Created by Rick Clephas on 21/11/2022. +// + +import SwiftUI +import KMPObservableViewModelSwiftUI + +struct ContentViewSwiftUIPublished: View { + + @StateViewModel var viewModel = TimeTravelViewModelPublished() + + var body: some View { + ContentViewSwiftUI(viewModel: viewModel) + .navigationTitle(Text("Published")) + .navigationBarTitleDisplayMode(.inline) + } +} + +struct ContentViewSwiftUIPublishedObservable: View { + + @StateViewModel var viewModel = TimeTravelViewModelPublishedObservable() + + var body: some View { + ContentViewSwiftUI(viewModel: viewModel) + .navigationTitle(Text("Published + Observable")) + .navigationBarTitleDisplayMode(.inline) + } +} + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +struct ContentViewSwiftUIObservable: View { + + @StateViewModel var viewModel = TimeTravelViewModelObservable() + + var body: some View { + ContentViewSwiftUI(viewModel: viewModel) + .navigationTitle(Text("Observable")) + .navigationBarTitleDisplayMode(.inline) + } +} + +private struct ContentViewSwiftUI: View { + + @ObservedViewModel var viewModel: TimeTravelViewModel + @StateObject var counter = Counter() + + var body: some View { + Spacer() + ChangeCounter(counter.incrementAndGet()) { + VStack(spacing: 24) { + ActualTimeView(viewModel: viewModel) + TravelEffectView(viewModel: viewModel) + CurrentTimeView(viewModel: viewModel) + IsFixedTimeView(viewModel: viewModel) + ButtonsView(viewModel: viewModel) + }.frame(minWidth: 0, maxWidth: .infinity) + }.padding(.horizontal, 8) + Spacer() + } +} + +private struct ActualTimeView: View { + + @ObservedViewModel var viewModel: TimeTravelViewModel + @StateObject var counter = Counter() + + var body: some View { + ChangeCounter(counter.incrementAndGet()) { + VStack { + Text("Actual time:") + .fontWeight(.thin) + Text(viewModel.actualTime) + .font(.system(size: 20, design: .monospaced)) + } + } + } +} + +private struct TravelEffectView: View { + + @ObservedViewModel var viewModel: TimeTravelViewModel + @StateObject var counter = Counter() + + var body: some View { + ChangeCounter(counter.incrementAndGet()) { + VStack { + Text("Travel effect:") + .fontWeight(.thin) + Text(viewModel.travelEffect?.description ?? "nil") + .font(.system(size: 20, design: .monospaced)) + } + } + } +} + +private struct CurrentTimeView: View { + + @ObservedViewModel var viewModel: TimeTravelViewModel + @StateObject var counter = Counter() + + var body: some View { + ChangeCounter(counter.incrementAndGet()) { + VStack { + Text("Current time:") + .fontWeight(.thin) + Text(viewModel.currentTime) + .font(.system(size: 20, design: .monospaced)) + } + } + } +} + +private struct IsFixedTimeView: View { + + @ObservedViewModel var viewModel: TimeTravelViewModel + @StateObject var counter = Counter() + + private var isFixedTimeBinding: Binding { + Binding { viewModel.isFixedTime } set: { isFixedTime in + if isFixedTime { + viewModel.stopTime() + } else { + viewModel.startTime() + } + } + } + + var body: some View { + ChangeCounter(counter.incrementAndGet()) { + HStack { + Text("Fixed time:") + .fontWeight(.thin) + Toggle("", isOn: isFixedTimeBinding).labelsHidden() + } + } + } +} + +private struct ButtonsView: View { + + @ObservedViewModel var viewModel: TimeTravelViewModel + @StateObject var counter = Counter() + + var body: some View { + ChangeCounter(counter.incrementAndGet()) { + HStack { + Spacer() + Button("Time travel") { + viewModel.timeTravel() + } + Spacer() + Button("Reset") { + viewModel.resetTime() + }.foregroundColor(viewModel.isResetDisabled ? Color.red : Color.green) + Spacer() + } + } + } +} + +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentViewSwiftUIPublished() + } +} diff --git a/sample/iosApp/KMPObservableViewModelSample/KMPObservableViewModel.swift b/sample/iosApp/KMPObservableViewModelSample/KMPObservableViewModel.swift index aada39d..a888713 100644 --- a/sample/iosApp/KMPObservableViewModelSample/KMPObservableViewModel.swift +++ b/sample/iosApp/KMPObservableViewModelSample/KMPObservableViewModel.swift @@ -8,4 +8,7 @@ import KMPObservableViewModelCore import KMPObservableViewModelSampleShared -extension Kmp_observableviewmodel_coreViewModel: ViewModel { } +extension Kmp_observableviewmodel_coreViewModel: @retroactive ViewModel { } + +// Uncomment to make all KMP-ObservableViewModels compatible with the Observation framework +// extension Kmp_observableviewmodel_coreViewModel: @retroactive Observable { } diff --git a/sample/iosApp/KMPObservableViewModelSample/KMPObservableViewModelSampleApp.swift b/sample/iosApp/KMPObservableViewModelSample/KMPObservableViewModelSampleApp.swift index deaa703..f78ad58 100644 --- a/sample/iosApp/KMPObservableViewModelSample/KMPObservableViewModelSampleApp.swift +++ b/sample/iosApp/KMPObservableViewModelSample/KMPObservableViewModelSampleApp.swift @@ -13,9 +13,7 @@ struct KMPObservableViewModelSampleApp: App { var body: some Scene { WindowGroup { NavigationStack { - NavigationLink("SwiftUI", destination: ContentView()) - Spacer().frame(height: 24) - NavigationLink("Compose MP", destination: ContentViewMP()) + RootView() } } } diff --git a/sample/iosApp/KMPObservableViewModelSample/RootView.swift b/sample/iosApp/KMPObservableViewModelSample/RootView.swift new file mode 100644 index 0000000..597d57e --- /dev/null +++ b/sample/iosApp/KMPObservableViewModelSample/RootView.swift @@ -0,0 +1,42 @@ +// +// RootView.swift +// KMPObservableViewModelSample +// +// Created by Rick Clephas on 26/10/2025. +// + +import SwiftUI + +struct RootView: View { + + var body: some View { + List { + Section(header: Text("SwiftUI")) { + NavigationLink { + ContentViewSwiftUIPublished() + } label: { + Text("Published") + } + NavigationLink { + ContentViewSwiftUIPublishedObservable() + } label: { + Text("Published + Observable") + } + if #available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) { + NavigationLink { + ContentViewSwiftUIObservable() + } label: { + Text("Observable") + } + } + } + NavigationLink { + ContentViewCompose() + } label: { + Text("Compose Multiplatform") + } + } + .navigationTitle(Text("KMP-ObservableViewModel")) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/sample/iosApp/KMPObservableViewModelSample/TimeTravelViewModel.swift b/sample/iosApp/KMPObservableViewModelSample/TimeTravelViewModel.swift index 8f52074..1271b76 100644 --- a/sample/iosApp/KMPObservableViewModelSample/TimeTravelViewModel.swift +++ b/sample/iosApp/KMPObservableViewModelSample/TimeTravelViewModel.swift @@ -9,7 +9,7 @@ import KMPObservableViewModelSampleShared class TimeTravelViewModel: KMPObservableViewModelSampleShared.TimeTravelViewModel { - @Published var isResetDisabled: Bool = false + var isResetDisabled: Bool = false override func resetTime() { isResetDisabled = !isResetDisabled @@ -17,3 +17,35 @@ class TimeTravelViewModel: KMPObservableViewModelSampleShared.TimeTravelViewMode super.resetTime() } } + +class TimeTravelViewModelPublished: TimeTravelViewModel { + + @Published private var _isResetDisabled: Bool = false + + override var isResetDisabled: Bool { + get { _isResetDisabled } + set { _isResetDisabled = newValue } + } +} + +class TimeTravelViewModelPublishedObservable: TimeTravelViewModel, Observable { + + @Published private var _isResetDisabled: Bool = false + + override var isResetDisabled: Bool { + get { _isResetDisabled } + set { _isResetDisabled = newValue } + } +} + +@Observable +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, *) +class TimeTravelViewModelObservable: TimeTravelViewModel { + + private var _isResetDisabled: Bool = false + + override var isResetDisabled: Bool { + get { _isResetDisabled } + set { _isResetDisabled = newValue } + } +} diff --git a/sample/shared/src/iosMain/kotlin/com/rickclephas/kmp/observableviewmodel/sample/shared/TimeTravelViewController.kt b/sample/shared/src/iosMain/kotlin/com/rickclephas/kmp/observableviewmodel/sample/shared/TimeTravelViewController.kt index f38780a..3b25b8c 100644 --- a/sample/shared/src/iosMain/kotlin/com/rickclephas/kmp/observableviewmodel/sample/shared/TimeTravelViewController.kt +++ b/sample/shared/src/iosMain/kotlin/com/rickclephas/kmp/observableviewmodel/sample/shared/TimeTravelViewController.kt @@ -1,10 +1,16 @@ package com.rickclephas.kmp.observableviewmodel.sample.shared +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.MaterialTheme +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier import androidx.compose.ui.window.ComposeUIViewController fun TimeTravelViewController(viewModel: TimeTravelViewModel) = ComposeUIViewController { MaterialTheme { - TimeTravelScreen(viewModel) + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + TimeTravelScreen(viewModel) + } } }