diff --git a/KMPObservableViewModelCore/ObservableKeyPath.swift b/KMPObservableViewModelCore/ObservableKeyPath.swift new file mode 100644 index 0000000..c6c0f8b --- /dev/null +++ b/KMPObservableViewModelCore/ObservableKeyPath.swift @@ -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: ViewModelKeyPath { + + private weak var subject: Root? + private let keyPath: KeyPath + + /// Creates a new `ObservableKeyPath` for the provided `subject` and `keyPath`. + public init(_ subject: Root, _ keyPath: KeyPath) { + 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) + } +} diff --git a/KMPObservableViewModelCore/ObservableViewModelPublisher.swift b/KMPObservableViewModelCore/ObservableViewModelPublisher.swift index 898aac2..a779463 100644 --- a/KMPObservableViewModelCore/ObservableViewModelPublisher.swift +++ b/KMPObservableViewModelCore/ObservableViewModelPublisher.swift @@ -6,6 +6,7 @@ // import Combine +import Observation import KMPObservableViewModelCoreObjC /// Publisher for `ObservableViewModel` that connects to the `ViewModelScope`. @@ -13,6 +14,17 @@ public final class ObservableViewModelPublisher: Combine.Publisher, KMPObservabl 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 @@ -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: Subscriber where S : Subscriber, Never == S.Failure, Void == S.Input { typealias Input = Void diff --git a/KMPObservableViewModelCore/PublishedKeyPath.swift b/KMPObservableViewModelCore/PublishedKeyPath.swift new file mode 100644 index 0000000..cf52e57 --- /dev/null +++ b/KMPObservableViewModelCore/PublishedKeyPath.swift @@ -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 + } +} 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/KMPOVMViewModelKeyPath.h b/KMPObservableViewModelCoreObjC/KMPOVMViewModelKeyPath.h new file mode 100644 index 0000000..4cfab19 --- /dev/null +++ b/KMPObservableViewModelCoreObjC/KMPOVMViewModelKeyPath.h @@ -0,0 +1,25 @@ +// +// KMPOVMViewModelKeyPath.h +// KMPObservableViewModelCoreObjC +// +// Created by Rick Clephas on 11/06/2025. +// + +#ifndef KMPOVMViewModelKeyPath_h +#define KMPOVMViewModelKeyPath_h + +#import +#import "KMPOVMPublisher.h" + +NS_ASSUME_NONNULL_BEGIN + +__attribute__((swift_name("ViewModelKeyPath"))) +@protocol KMPOVMViewModelKeyPath +- (void)access:(id)publisher; +- (void)willSet:(id)publisher; +- (void)didSet:(id)publisher; +@end + +NS_ASSUME_NONNULL_END + +#endif /* KMPOVMViewModelKeyPath_h */ diff --git a/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h b/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h index f7c0985..444bb5e 100644 --- a/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h +++ b/KMPObservableViewModelCoreObjC/KMPObservableViewModelCoreObjC.h @@ -7,4 +7,5 @@ #import "KMPOVMPublisher.h" #import "KMPOVMSubscriptionCount.h" +#import "KMPOVMViewModelKeyPath.h" #import "KMPOVMViewModelScope.h" diff --git a/KMPObservableViewModelPlugin/KMPObservableViewModelPlugin.swift b/KMPObservableViewModelPlugin/KMPObservableViewModelPlugin.swift new file mode 100644 index 0000000..e2b7a47 --- /dev/null +++ b/KMPObservableViewModelPlugin/KMPObservableViewModelPlugin.swift @@ -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 + ] +} diff --git a/KMPObservableViewModelPlugin/ObservableViewModel.swift b/KMPObservableViewModelPlugin/ObservableViewModel.swift new file mode 100644 index 0000000..1176063 --- /dev/null +++ b/KMPObservableViewModelPlugin/ObservableViewModel.swift @@ -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(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(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 + } + } + } + """ + ] + } +} diff --git a/KMPObservableViewModelProperties/ObservableProperties.swift b/KMPObservableViewModelProperties/ObservableProperties.swift new file mode 100644 index 0000000..b042b1a --- /dev/null +++ b/KMPObservableViewModelProperties/ObservableProperties.swift @@ -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(observable keyPath: KeyPath) -> 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(observable keyPath: ReferenceWritableKeyPath) -> Value { + get { self[observable: keyPath as KeyPath] } + set { self[keyPath: keyPath] = newValue } + } + + /// `PublishedProperties` implementation required for compilation if only `ObservableProperties` is used. + @_disfavoredOverload + subscript(published keyPath: KeyPath) -> Value { + self[observable: keyPath] + } + + /// `PublishedProperties` implementation required for compilation if only `ObservableProperties` is used. + @_disfavoredOverload + subscript(published keyPath: ReferenceWritableKeyPath) -> Value { + get { self[observable: keyPath] } + set { self[observable: keyPath] = newValue } + } +} diff --git a/KMPObservableViewModelProperties/ObservableViewModel.swift b/KMPObservableViewModelProperties/ObservableViewModel.swift new file mode 100644 index 0000000..c18a966 --- /dev/null +++ b/KMPObservableViewModelProperties/ObservableViewModel.swift @@ -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") diff --git a/KMPObservableViewModelProperties/Properties.swift b/KMPObservableViewModelProperties/Properties.swift new file mode 100644 index 0000000..d0d1df8 --- /dev/null +++ b/KMPObservableViewModelProperties/Properties.swift @@ -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) +} diff --git a/KMPObservableViewModelProperties/PublishedProperties.swift b/KMPObservableViewModelProperties/PublishedProperties.swift new file mode 100644 index 0000000..cf830f2 --- /dev/null +++ b/KMPObservableViewModelProperties/PublishedProperties.swift @@ -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(published keyPath: KeyPath) -> 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(published keyPath: ReferenceWritableKeyPath) -> Value { + get { self[published: keyPath as KeyPath] } + set { self[keyPath: keyPath] = newValue } + } + + /// `ObservableProperties` implementation required for compilation if only `PublishedProperties` is used. + @_disfavoredOverload + subscript(observable keyPath: KeyPath) -> Value { + self[published: keyPath] + } + + /// `ObservableProperties` implementation required for compilation if only `PublishedProperties` is used. + @_disfavoredOverload + subscript(observable keyPath: ReferenceWritableKeyPath) -> Value { + get { self[published: keyPath] } + set { self[published: keyPath] = newValue } + } +} diff --git a/KMPObservableViewModelProperties/ViewModel.swift b/KMPObservableViewModelProperties/ViewModel.swift new file mode 100644 index 0000000..540d63e --- /dev/null +++ b/KMPObservableViewModelProperties/ViewModel.swift @@ -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(dynamicMember keyPath: KeyPath) -> T { + fatalError("ViewModel subscripts will be added by @ObservableViewModel") + } +} diff --git a/Package.swift b/Package.swift index 9349a8c..9b1d4f8 100644 --- a/Package.swift +++ b/Package.swift @@ -1,5 +1,7 @@ -// swift-tools-version:5.3 +// swift-tools-version:5.9 + import PackageDescription +import CompilerPluginSupport let package = Package( name: "KMPObservableViewModel", @@ -9,11 +11,18 @@ let package = Package( name: "KMPObservableViewModelCore", targets: ["KMPObservableViewModelCore"] ), + .library( + name: "KMPObservableViewModelProperties", + targets: ["KMPObservableViewModelProperties"] + ), .library( name: "KMPObservableViewModelSwiftUI", targets: ["KMPObservableViewModelSwiftUI"] ) ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-syntax", from: "509.0.0") + ], targets: [ .target( name: "KMPObservableViewModelCoreObjC", @@ -25,11 +34,27 @@ let package = Package( dependencies: [.target(name: "KMPObservableViewModelCoreObjC")], path: "KMPObservableViewModelCore" ), + .target( + name: "KMPObservableViewModelProperties", + dependencies: [ + .target(name: "KMPObservableViewModelCore"), + .target(name: "KMPObservableViewModelPlugin"), + ], + path: "KMPObservableViewModelProperties" + ), .target( name: "KMPObservableViewModelSwiftUI", dependencies: [.target(name: "KMPObservableViewModelCore")], path: "KMPObservableViewModelSwiftUI" - ) + ), + .macro( + name: "KMPObservableViewModelPlugin", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax") + ], + path: "KMPObservableViewModelPlugin" + ), ], swiftLanguageVersions: [.v5] ) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8e76ff2..469fd80 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,12 +4,12 @@ kotlinx-coroutines = "1.10.1" android = "8.2.0" androidx-lifecycle = "2.8.7" atomicfu = "0.26.1" +nativecoroutines = "1.0.0-ALPHA-43" # Sample versions androidx-compose = "2023.10.01" androidx-fragment = "1.6.2" ksp = "2.1.21-2.0.1" -nativecoroutines = "1.0.0-ALPHA-43" [libraries] kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } @@ -18,6 +18,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c android-library-gradle-plugin = { module = "com.android.library:com.android.library.gradle.plugin", version.ref = "android" } androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" } vanniktech-mavenPublish = { module = "com.vanniktech:gradle-maven-publish-plugin", version = "0.32.0" } +nativecoroutines-core = { module = "com.rickclephas.kmp:kmp-nativecoroutines-core", version.ref = "nativecoroutines" } # Sample libraries androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose" } 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..0ba8a57 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 @@ -1,6 +1,7 @@ package com.rickclephas.kmp.observableviewmodel import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMPublisherProtocol +import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMViewModelKeyPathProtocol import kotlinx.coroutines.CoroutineScope import platform.darwin.NSObject @@ -8,7 +9,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 { @@ -21,9 +22,15 @@ internal class NativeViewModelScope internal constructor( if (_publisher != null) throw IllegalStateException("ViewModel can't be initialized more than once") _publisher = publisher } + + /** + * Indicates if [ViewModelKeyPath][KMPOVMViewModelKeyPathProtocol]s musts be used with the [publisher]. + */ + var requireKeyPaths: Boolean = false } /** * Casts `this` [ViewModelScope] to a [NativeViewModelScope]. */ +@Suppress("NOTHING_TO_INLINE") 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 index 6d7857a..5ae2b17 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 @@ -1,5 +1,6 @@ package com.rickclephas.kmp.observableviewmodel +import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMViewModelKeyPathProtocol import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalForInheritanceCoroutinesApi import kotlinx.coroutines.Job @@ -17,17 +18,28 @@ internal class ObservableMutableStateFlow( private val stateFlow: MutableStateFlow ): MutableStateFlow { + /** + * The [KeyPath][KMPOVMViewModelKeyPathProtocol] associated with the [StateFlow] property. + */ + var keyPath: KMPOVMViewModelKeyPathProtocol? = null + set(value) { + if (value != null) viewModelScope.requireKeyPaths = true + field = value + } + override var value: T - get() = stateFlow.value + get() = viewModelScope.access(keyPath) { stateFlow.value } set(value) { - if (stateFlow.value != value) { - viewModelScope.publisher?.send() + val changed = stateFlow.value != value + viewModelScope.set(keyPath, changed) { + stateFlow.value = value } - stateFlow.value = value } + // 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 +50,10 @@ internal class ObservableMutableStateFlow( stateFlow.collect(collector) override fun compareAndSet(expect: T, update: T): Boolean { - if (stateFlow.value == expect && expect != update) { - viewModelScope.publisher?.send() + val changed = stateFlow.value == expect && expect != update + return viewModelScope.set(keyPath, changed) { + stateFlow.compareAndSet(expect, update) } - return stateFlow.compareAndSet(expect, update) } @ExperimentalCoroutinesApi @@ -70,3 +82,24 @@ internal class ObservableStateFlow( @Suppress("unused") private val job: Job? = null ): StateFlow by flow + +/** + * Returns the `ObservableStateFlow` for the provided [stateFlow]. + * @throws IllegalArgumentException if the [stateFlow] isn't an `ObservableStateFlow`. + */ +internal fun requireObservableStateFlow( + stateFlow: StateFlow, +): ObservableMutableStateFlow = when (stateFlow) { + is ObservableMutableStateFlow -> stateFlow + is ObservableStateFlow -> stateFlow.flow + else -> throw IllegalArgumentException("$stateFlow is not an ObservableStateFlow") +} + +/** + * Asserts that the provided [stateFlow] is an `ObservableStateFlow`. + * @throws IllegalArgumentException if the [stateFlow] isn't an `ObservableStateFlow`. + */ +@InternalKMPObservableViewModelApi +public fun assertObservableStateFlow(stateFlow: StateFlow) { + requireObservableStateFlow(stateFlow) +} 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 b66c996..8447917 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 @@ -62,3 +62,9 @@ private fun CoroutineScope.launchSharing( } } } + +/** + * @see kotlinx.coroutines.flow.asStateFlow + */ +public actual fun MutableStateFlow.asObservableStateFlow(): StateFlow = + ObservableStateFlow(requireObservableStateFlow(this), null) diff --git a/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelKeyPath.kt b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelKeyPath.kt new file mode 100644 index 0000000..ed1c232 --- /dev/null +++ b/kmp-observableviewmodel-core/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/ViewModelKeyPath.kt @@ -0,0 +1,53 @@ +package com.rickclephas.kmp.observableviewmodel + +import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMViewModelKeyPathProtocol +import kotlinx.coroutines.flow.StateFlow + +/** + * The [KeyPath][KMPOVMViewModelKeyPathProtocol] of an `ObservableStateFlow`. + * @throws IllegalArgumentException if `this` [StateFlow] isn't an `ObservableStateFlow`. + */ +@InternalKMPObservableViewModelApi +public var StateFlow.keyPath: KMPOVMViewModelKeyPathProtocol? + get() = requireObservableStateFlow(this).keyPath + set(value) { requireObservableStateFlow(this).keyPath = value } + +/** + * Helper to emit [keyPath] access events through the [NativeViewModelScope]. + * @param keyPath The keyPath being accessed. + * @param action The action accessing the keyPath value. + */ +internal inline fun NativeViewModelScope.access( + keyPath: KMPOVMViewModelKeyPathProtocol?, + action: () -> R +): R { + val publisher = publisher + if (keyPath != null && publisher != null) { + keyPath.access(publisher) + } + return action() +} + +/** + * Helper to emit [keyPath] willSet and didSet events through the [NativeViewModelScope]. + * @param keyPath The keyPath being set. + * @param changed Indicates if it's likely the property will be changed. + * False-positives are allowed, false-negatives aren't. + * @param action The action setting the new keyPath value. + */ +internal inline fun NativeViewModelScope.set( + keyPath: KMPOVMViewModelKeyPathProtocol?, + changed: Boolean, + action: () -> R +): R { + val publisher = publisher + if (!changed || publisher == null) return action() + if (keyPath != null) { + keyPath.willSet(publisher) + } else if (!requireKeyPaths) { + publisher.send() + } + val result = action() + keyPath?.didSet(publisher) + return result +} diff --git a/kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt b/kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt index 1eeb99d..ced46cb 100644 --- a/kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt +++ b/kmp-observableviewmodel-core/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt @@ -21,3 +21,8 @@ public expect fun Flow.stateIn( started: SharingStarted, initialValue: T ): StateFlow + +/** + * @see kotlinx.coroutines.flow.asStateFlow + */ +public expect fun MutableStateFlow.asObservableStateFlow(): StateFlow 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..5b04956 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 = KMPOVMPublisher.h KMPOVMSubscriptionCount.h KMPOVMViewModelKeyPath.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/kmp-observableviewmodel-core/src/nonAppleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt b/kmp-observableviewmodel-core/src/nonAppleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt index 0e3b07f..9092680 100644 --- a/kmp-observableviewmodel-core/src/nonAppleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt +++ b/kmp-observableviewmodel-core/src/nonAppleMain/kotlin/com/rickclephas/kmp/observableviewmodel/StateFlow.kt @@ -18,3 +18,8 @@ public actual inline fun Flow.stateIn( started: SharingStarted, initialValue: T ): StateFlow = stateIn(viewModelScope.coroutineScope, started, initialValue) + +/** + * @see kotlinx.coroutines.flow.asStateFlow + */ +public actual fun MutableStateFlow.asObservableStateFlow(): StateFlow = asStateFlow() diff --git a/kmp-observableviewmodel-properties/build.gradle.kts b/kmp-observableviewmodel-properties/build.gradle.kts new file mode 100644 index 0000000..0e6d8c7 --- /dev/null +++ b/kmp-observableviewmodel-properties/build.gradle.kts @@ -0,0 +1,95 @@ +import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi +import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl + +plugins { + id("kmp-observableviewmodel-android-library") + id("kmp-observableviewmodel-kotlin-multiplatform") + id("kmp-observableviewmodel-publish") +} + +kotlin { + explicitApi() + jvmToolchain(11) + + @OptIn(ExperimentalKotlinGradlePluginApi::class) + applyDefaultHierarchyTemplate { + common { + group("nonApple") { + withAndroidTarget() + withJvm() + withJs() + group("linux") + group("mingw") + withWasmJs() + } + } + } + + macosX64() + macosArm64() + iosArm64() + iosX64() + iosSimulatorArm64() + watchosArm32() + watchosArm64() + watchosX64() + watchosSimulatorArm64() + watchosDeviceArm64() + tvosArm64() + tvosX64() + tvosSimulatorArm64() + androidTarget() + jvm() + js { + browser() + nodejs() + } + linuxArm64() + linuxX64() + mingwX64() + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser() + nodejs() + d8() + } + + targets.all { + compilations.all { + compileTaskProvider.configure { + compilerOptions { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + } + } + } + + sourceSets { + all { + languageSettings { + optIn("com.rickclephas.kmp.observableviewmodel.InternalKMPObservableViewModelApi") + optIn("kotlin.experimental.ExperimentalObjCRefinement") + optIn("kotlinx.cinterop.ExperimentalForeignApi") + } + } + + commonMain { + dependencies { + api(project(":kmp-observableviewmodel-core")) + } + } + appleMain { + dependencies { + api(libs.nativecoroutines.core) + } + } + } +} + +android { + namespace = "com.rickclephas.kmp.observableviewmodel.properties" + compileSdk = 33 + defaultConfig { + minSdk = 19 + } +} diff --git a/kmp-observableviewmodel-properties/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/properties/ViewModel.kt b/kmp-observableviewmodel-properties/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/properties/ViewModel.kt new file mode 100644 index 0000000..23ab724 --- /dev/null +++ b/kmp-observableviewmodel-properties/src/appleMain/kotlin/com/rickclephas/kmp/observableviewmodel/properties/ViewModel.kt @@ -0,0 +1,94 @@ +package com.rickclephas.kmp.observableviewmodel.properties + +import com.rickclephas.kmp.nativecoroutines.NativeFlow +import com.rickclephas.kmp.nativecoroutines.asNativeFlow +import com.rickclephas.kmp.observableviewmodel.InternalKMPObservableViewModelApi +import com.rickclephas.kmp.observableviewmodel.ViewModel +import com.rickclephas.kmp.observableviewmodel.ViewModelScope +import com.rickclephas.kmp.observableviewmodel.coroutineScope +import com.rickclephas.kmp.observableviewmodel.keyPath +import com.rickclephas.kmp.observableviewmodel.objc.KMPOVMViewModelKeyPathProtocol +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import platform.Foundation.NSThread +import kotlin.experimental.ExperimentalObjCName + +/** + * A Kotlin Multiplatform ViewModel. + */ +public actual abstract class ViewModel: ViewModel() { + + /** + * Class that stores all observable properties of a ViewModel. + */ + @InternalKMPObservableViewModelApi + public abstract class Properties { + + private var registeringStateFlow: StateFlow<*>? = null + + /** + * Indicates if the `KeyPath` of the accessed property should be registered. + * @see registerKeyPath + */ + public fun shouldRegisterKeyPath(): Boolean = registeringStateFlow != null + + /** + * Registers the [keyPath] for the previously accessed property. + * @see shouldRegisterKeyPath + */ + @OptIn(ExperimentalObjCName::class) + public fun registerKeyPath(@ObjCName(swiftName = "_") keyPath: KMPOVMViewModelKeyPathProtocol) { + registeringStateFlow?.keyPath = keyPath + registeringStateFlow = null + } + + /** + * Returns the value of the provided [stateFlow] and ensures `KeyPath`s are being registered. + * @see shouldRegisterKeyPath + */ + @HiddenFromObjC + protected fun getProperty(stateFlow: StateFlow): T { + if (stateFlow.keyPath == null && NSThread.isMainThread) { + registeringStateFlow = stateFlow + } + return stateFlow.value + } + + /** + * Sets the value of the [stateFlow] to the provided [value]. + */ + @HiddenFromObjC + protected fun setProperty(stateFlow: MutableStateFlow, value: T) { + stateFlow.value = value + } + } + + /** + * The observable properties of `this` ViewModel. + */ + @ShouldRefineInSwift + @InternalKMPObservableViewModelApi + public abstract val properties: Properties + + /** + * Class that provides [NativeFlow]s for all observable properties of a ViewModel. + */ + public abstract class NativeFlows( + viewModelScope: ViewModelScope + ) { + private val coroutineScope: CoroutineScope = viewModelScope.coroutineScope + + /** + * Returns a [NativeFlow] for the provided [stateFlow] using the `viewModelScope`. + */ + @HiddenFromObjC + protected fun getNativeFlow(stateFlow: StateFlow): NativeFlow = + stateFlow.asNativeFlow(coroutineScope) + } + + /** + * The [NativeFlow]s for the observable [properties] of `this` ViewModel. + */ + public abstract val nativeFlows: NativeFlows +} diff --git a/kmp-observableviewmodel-properties/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/properties/ViewModel.kt b/kmp-observableviewmodel-properties/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/properties/ViewModel.kt new file mode 100644 index 0000000..11e0be7 --- /dev/null +++ b/kmp-observableviewmodel-properties/src/commonMain/kotlin/com/rickclephas/kmp/observableviewmodel/properties/ViewModel.kt @@ -0,0 +1,8 @@ +package com.rickclephas.kmp.observableviewmodel.properties + +import com.rickclephas.kmp.observableviewmodel.ViewModel + +/** + * A Kotlin Multiplatform ViewModel. + */ +public expect abstract class ViewModel: ViewModel diff --git a/kmp-observableviewmodel-properties/src/nonAppleMain/kotlin/com/rickclephas/kmp/observableviewmodel/properties/ViewModel.kt b/kmp-observableviewmodel-properties/src/nonAppleMain/kotlin/com/rickclephas/kmp/observableviewmodel/properties/ViewModel.kt new file mode 100644 index 0000000..7146571 --- /dev/null +++ b/kmp-observableviewmodel-properties/src/nonAppleMain/kotlin/com/rickclephas/kmp/observableviewmodel/properties/ViewModel.kt @@ -0,0 +1,8 @@ +package com.rickclephas.kmp.observableviewmodel.properties + +import com.rickclephas.kmp.observableviewmodel.ViewModel + +/** + * A Kotlin Multiplatform ViewModel. + */ +public actual abstract class ViewModel: ViewModel() diff --git a/sample/iosApp/KMPObservableViewModelSample.xcodeproj/project.pbxproj b/sample/iosApp/KMPObservableViewModelSample.xcodeproj/project.pbxproj index e9761ef..8ce041d 100644 --- a/sample/iosApp/KMPObservableViewModelSample.xcodeproj/project.pbxproj +++ b/sample/iosApp/KMPObservableViewModelSample.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 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 */; }; + 1DDE18852E114DE800227AE6 /* ChangeCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DDE18842E114DE500227AE6 /* ChangeCounter.swift */; }; 1DF227B02C2856BC00D8B3A7 /* ContentViewMP.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF227AF2C2856BC00D8B3A7 /* ContentViewMP.swift */; }; /* End PBXBuildFile section */ @@ -52,6 +53,7 @@ 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 = ""; }; + 1DDE18842E114DE500227AE6 /* ChangeCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChangeCounter.swift; sourceTree = ""; }; 1DF227AF2C2856BC00D8B3A7 /* ContentViewMP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentViewMP.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -106,6 +108,7 @@ 1D44EF76292C066B00465C43 /* KMPObservableViewModelSample */ = { isa = PBXGroup; children = ( + 1DDE18842E114DE500227AE6 /* ChangeCounter.swift */, 1D44EF77292C066B00465C43 /* KMPObservableViewModelSampleApp.swift */, 1D44EF79292C066B00465C43 /* ContentView.swift */, 1D44EF7B292C066C00465C43 /* Assets.xcassets */, @@ -316,6 +319,7 @@ files = ( 1DDAF2202935470A0049C114 /* KMPObservableViewModel.swift in Sources */, 1DDAF222293548A60049C114 /* TimeTravelViewModel.swift in Sources */, + 1DDE18852E114DE800227AE6 /* ChangeCounter.swift in Sources */, 1D44EF7A292C066B00465C43 /* ContentView.swift in Sources */, 1DF227B02C2856BC00D8B3A7 /* ContentViewMP.swift in Sources */, 1D44EF78292C066B00465C43 /* KMPObservableViewModelSampleApp.swift in Sources */, diff --git a/sample/iosApp/KMPObservableViewModelSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/sample/iosApp/KMPObservableViewModelSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..bd17872 --- /dev/null +++ b/sample/iosApp/KMPObservableViewModelSample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,14 @@ +{ + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "64889f0c732f210a935a0ad7cda38f77f876262d", + "version" : "509.1.1" + } + } + ], + "version" : 2 +} 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 index 9a18d24..f0990ca 100644 --- a/sample/iosApp/KMPObservableViewModelSample/ContentView.swift +++ b/sample/iosApp/KMPObservableViewModelSample/ContentView.swift @@ -11,57 +11,102 @@ 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() - } - } + @StateObject var counter = Counter() + + var body: some View { + Spacer() + ChangeCounter(counter.incrementAndGet()) { + VStack { + ActualTimeView(viewModel: viewModel) + Spacer().frame(height: 24) + TravelEffectView(viewModel: viewModel) + Spacer().frame(height: 24) + CurrentTimeView(viewModel: viewModel) + Spacer().frame(height: 24) + IsFixedTimeView(viewModel: viewModel) + Spacer().frame(height: 24) + Button("Time travel") { + viewModel.timeTravel() + } + Spacer().frame(height: 24) + Button("Reset") { + viewModel.resetTime() + }.foregroundColor(viewModel.isResetDisabled ? Color.red : Color.green) + }.frame(minWidth: 0, maxWidth: .infinity) + }.padding(.horizontal, 8) + Spacer() } +} +struct ActualTimeView: View { + + @ObservedViewModel var viewModel: TimeTravelViewModel + @StateObject var counter = Counter() + var body: some View { - VStack{ - Spacer() - Group { + ChangeCounter(counter.incrementAndGet()) { + VStack { Text("Actual time:") Text(viewModel.actualTime) .font(.system(size: 20)) } - Group { - Spacer().frame(height: 24) + } + } +} + +struct TravelEffectView: View { + + @ObservedViewModel var viewModel: TimeTravelViewModel + @StateObject var counter = Counter() + + var body: some View { + ChangeCounter(counter.incrementAndGet()) { + VStack { Text("Travel effect:") Text(viewModel.travelEffect?.description ?? "nil") .font(.system(size: 20)) } - Group { - Spacer().frame(height: 24) + } + } +} + +struct CurrentTimeView: View { + + @ObservedViewModel var viewModel: TimeTravelViewModel + @StateObject var counter = Counter() + + var body: some View { + ChangeCounter(counter.incrementAndGet()) { + VStack { 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() - } + } + } +} + +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() } - Group { - Spacer().frame(height: 24) - Button("Reset") { - viewModel.resetTime() - }.foregroundColor(viewModel.isResetDisabled ? Color.red : Color.green) + } + } + + var body: some View { + ChangeCounter(counter.incrementAndGet()) { + HStack { + Toggle("", isOn: isFixedTimeBinding).labelsHidden() + Text("Fixed time") } - Spacer() } } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 1a9a2e4..01ba830 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,3 +18,4 @@ dependencyResolutionManagement { rootProject.name = "kmp-observableviewmodel" include(":kmp-observableviewmodel-core") +include(":kmp-observableviewmodel-properties")