diff --git a/KMMViewModel.xcodeproj/project.pbxproj b/KMMViewModel.xcodeproj/project.pbxproj index f3226cd..2aa2886 100644 --- a/KMMViewModel.xcodeproj/project.pbxproj +++ b/KMMViewModel.xcodeproj/project.pbxproj @@ -20,6 +20,9 @@ 1D43F3EC2ABAFCA600EB3DFE /* ObservableViewModelPublisher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D43F3EB2ABAFCA600EB3DFE /* ObservableViewModelPublisher.swift */; }; 1D43F3EE2ABAFD7D00EB3DFE /* ObservableViewModelPublishers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D43F3ED2ABAFD7D00EB3DFE /* ObservableViewModelPublishers.swift */; }; 1D6641DD2A5175C3000180D7 /* ChildViewModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D6641DC2A5175C3000180D7 /* ChildViewModels.swift */; }; + 1D9474232B5084690064A727 /* SavedStateHandle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D9474222B5084690064A727 /* SavedStateHandle.swift */; }; + 1D9474272B5084BC0064A727 /* SavedStateObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D9474262B5084BC0064A727 /* SavedStateObserver.swift */; }; + 1D9E9A8A2B51DB7C009F4D43 /* SavedStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D9E9A892B51DB7C009F4D43 /* SavedStateManager.swift */; }; 1DDAF21E293545DD0049C114 /* KMMViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DDAF21D293545DD0049C114 /* KMMViewModel.swift */; }; /* End PBXBuildFile section */ @@ -55,6 +58,10 @@ 1D43F3EB2ABAFCA600EB3DFE /* ObservableViewModelPublisher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableViewModelPublisher.swift; sourceTree = ""; }; 1D43F3ED2ABAFD7D00EB3DFE /* ObservableViewModelPublishers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObservableViewModelPublishers.swift; sourceTree = ""; }; 1D6641DC2A5175C3000180D7 /* ChildViewModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChildViewModels.swift; sourceTree = ""; }; + 1D9474202B5083310064A727 /* KMMViewModelSavedState.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = KMMViewModelSavedState.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 1D9474222B5084690064A727 /* SavedStateHandle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedStateHandle.swift; sourceTree = ""; }; + 1D9474262B5084BC0064A727 /* SavedStateObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedStateObserver.swift; sourceTree = ""; }; + 1D9E9A892B51DB7C009F4D43 /* SavedStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedStateManager.swift; sourceTree = ""; }; 1DDAF21D293545DD0049C114 /* KMMViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KMMViewModel.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -82,6 +89,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 1D94741A2B5083310064A727 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -91,6 +105,7 @@ 1D0DA7F9293369A80057DDAD /* KMMViewModelCore */, 1D198B282933BFD900EF778D /* KMMViewModelCoreObjC */, 1D0DA809293370E60057DDAD /* KMMViewModelSwiftUI */, + 1D9474212B5083D10064A727 /* KMMViewModelSavedState */, 1D0DA7DE293367DE0057DDAD /* Products */, 1D0DA8112933715A0057DDAD /* Frameworks */, ); @@ -102,6 +117,7 @@ 1D0DA7F8293369A80057DDAD /* KMMViewModelCore.framework */, 1D0DA808293370E60057DDAD /* KMMViewModelSwiftUI.framework */, 1D198B272933BFD900EF778D /* KMMViewModelCoreObjC.framework */, + 1D9474202B5083310064A727 /* KMMViewModelSavedState.framework */, ); name = Products; sourceTree = ""; @@ -146,6 +162,16 @@ path = KMMViewModelCoreObjC; sourceTree = ""; }; + 1D9474212B5083D10064A727 /* KMMViewModelSavedState */ = { + isa = PBXGroup; + children = ( + 1D9474222B5084690064A727 /* SavedStateHandle.swift */, + 1D9474262B5084BC0064A727 /* SavedStateObserver.swift */, + 1D9E9A892B51DB7C009F4D43 /* SavedStateManager.swift */, + ); + path = KMMViewModelSavedState; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -172,6 +198,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 1D9474142B5083310064A727 /* Headers */ = { + isa = PBXHeadersBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXHeadersBuildPhase section */ /* Begin PBXNativeTarget section */ @@ -231,6 +264,24 @@ productReference = 1D198B272933BFD900EF778D /* KMMViewModelCoreObjC.framework */; productType = "com.apple.product-type.framework"; }; + 1D9474112B5083310064A727 /* KMMViewModelSavedState */ = { + isa = PBXNativeTarget; + buildConfigurationList = 1D94741D2B5083310064A727 /* Build configuration list for PBXNativeTarget "KMMViewModelSavedState" */; + buildPhases = ( + 1D9474142B5083310064A727 /* Headers */, + 1D9474152B5083310064A727 /* Sources */, + 1D94741A2B5083310064A727 /* Frameworks */, + 1D94741C2B5083310064A727 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = KMMViewModelSavedState; + productName = KMMViewModelSwiftUI; + productReference = 1D9474202B5083310064A727 /* KMMViewModelSavedState.framework */; + productType = "com.apple.product-type.framework"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -251,6 +302,9 @@ 1D198B262933BFD900EF778D = { CreatedOnToolsVersion = 14.0.1; }; + 1D9474112B5083310064A727 = { + LastSwiftMigration = 1500; + }; }; }; buildConfigurationList = 1D0DA7D7293367DE0057DDAD /* Build configuration list for PBXProject "KMMViewModel" */; @@ -269,6 +323,7 @@ 1D0DA7F7293369A80057DDAD /* KMMViewModelCore */, 1D198B262933BFD900EF778D /* KMMViewModelCoreObjC */, 1D0DA807293370E60057DDAD /* KMMViewModelSwiftUI */, + 1D9474112B5083310064A727 /* KMMViewModelSavedState */, ); }; /* End PBXProject section */ @@ -295,6 +350,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 1D94741C2B5083310064A727 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -329,6 +391,16 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 1D9474152B5083310064A727 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 1D9474272B5084BC0064A727 /* SavedStateObserver.swift in Sources */, + 1D9E9A8A2B51DB7C009F4D43 /* SavedStateManager.swift in Sources */, + 1D9474232B5084690064A727 /* SavedStateHandle.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -658,6 +730,69 @@ }; name = Release; }; + 1D94741E2B5083310064A727 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "@executable_path/../Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rickclephas.kmm.KMMViewModelSavedState; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 1D94741F2B5083310064A727 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEFINES_MODULE = YES; + DYLIB_COMPATIBILITY_VERSION = 1; + DYLIB_CURRENT_VERSION = 1; + DYLIB_INSTALL_NAME_BASE = "@rpath"; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; + LD_RUNPATH_SEARCH_PATHS = ( + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = ( + "@executable_path/../Frameworks", + "@loader_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.rickclephas.kmm.KMMViewModelSavedState; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -697,6 +832,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 1D94741D2B5083310064A727 /* Build configuration list for PBXNativeTarget "KMMViewModelSavedState" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1D94741E2B5083310064A727 /* Debug */, + 1D94741F2B5083310064A727 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ }; rootObject = 1D0DA7D4293367DE0057DDAD /* Project object */; diff --git a/KMMViewModelSavedState/SavedStateHandle.swift b/KMMViewModelSavedState/SavedStateHandle.swift new file mode 100644 index 0000000..3dcb3b2 --- /dev/null +++ b/KMMViewModelSavedState/SavedStateHandle.swift @@ -0,0 +1,13 @@ +// +// SavedStateHandle.swift +// KMMViewModelSavedState +// +// Created by Rick Clephas on 11/01/2024. +// + +import Foundation + +public protocol SavedStateHandle: AnyObject { + var data: Data? { get set } + func setStateChangedListener(listener: @escaping (Data?) -> Void) +} diff --git a/KMMViewModelSavedState/SavedStateManager.swift b/KMMViewModelSavedState/SavedStateManager.swift new file mode 100644 index 0000000..4db6578 --- /dev/null +++ b/KMMViewModelSavedState/SavedStateManager.swift @@ -0,0 +1,63 @@ +// +// SavedStateManager.swift +// KMMViewModelSavedState +// +// Created by Rick Clephas on 12/01/2024. +// + +import Combine +import SwiftUI + +public extension View { + + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + func savedStateManager() -> some View { + modifier(SavedStateManagerViewModifier()) + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +private struct SavedStateManagerViewModifier: ViewModifier { + + @SceneStorage("KMMViewModelSavedState") private var state: Data? + @StateObject private var manager = SavedStateManager() + + func body(content: Content) -> some View { + content.environmentObject(manager).onReceive(manager.stateChanged) { + state = manager.getState() + }.onAppear { + manager.setState(state) + } + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +internal class SavedStateManager: ObservableObject { + + let stateChanged = ObservableObjectPublisher() + + private var states: [String:Data] = [:] + private var state: Data? = nil + + func getState(for key: String) -> Data? { states[key] } + + func setState(_ state: Data?, for key: String) { + if let state { + states[key] = state + } else { + states.removeValue(forKey: key) + } + self.state = try! NSKeyedArchiver.archivedData(withRootObject: states, requiringSecureCoding: true) + stateChanged.send() + } + + func getState() -> Data? { state } + + func setState(_ state: Data?) { + guard let state, self.state == nil else { return } + self.state = state + states = try! NSKeyedUnarchiver.unarchivedDictionary( + ofKeyClass: NSString.self, objectClass: NSData.self, from: state + ) as [String : Data]? ?? [:] + } +} diff --git a/KMMViewModelSavedState/SavedStateObserver.swift b/KMMViewModelSavedState/SavedStateObserver.swift new file mode 100644 index 0000000..264e1e5 --- /dev/null +++ b/KMMViewModelSavedState/SavedStateObserver.swift @@ -0,0 +1,63 @@ +// +// SavedStateObserver.swift +// KMMViewModelSavedState +// +// Created by Rick Clephas on 11/01/2024. +// + +import Combine +import SwiftUI + +public extension View { + + @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) + func savedState(_ key: String, _ savedStateHandle: SavedStateHandle) -> some View { + modifier(SavedStateObserverViewModifier(key, savedStateHandle)) + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +internal struct SavedStateObserverViewModifier: ViewModifier { + + @EnvironmentObject private var manager: SavedStateManager + @StateObject private var observer: SavedStateObserver + + init(_ key: String, _ savedStateHandle: SavedStateHandle) { + self._observer = StateObject(wrappedValue: { + SavedStateObserver(key, savedStateHandle) + }()) + } + + func body(content: Content) -> some View { + content.onAppear { + observer.setManager(manager) + } + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +private class SavedStateObserver: ObservableObject { + + private let key: String + private let savedStateHandle: SavedStateHandle + private var manager: SavedStateManager? = nil + + init(_ key: String, _ savedStateHandle: SavedStateHandle) { + self.key = key + self.savedStateHandle = savedStateHandle + savedStateHandle.setStateChangedListener { [weak self] data in + guard let self else { return } + self.manager?.setState(data, for: key) + } + } + + func setManager(_ manager: SavedStateManager) { + guard self.manager == nil else { return } + self.manager = manager + savedStateHandle.data = manager.getState(for: key) + } + + deinit { + manager?.setState(nil, for: key) + } +} diff --git a/Package.swift b/Package.swift index 63b2cb3..e2b24c1 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,10 @@ let package = Package( .library( name: "KMMViewModelSwiftUI", targets: ["KMMViewModelSwiftUI"] + ), + .library( + name: "KMMViewModelSavedState", + targets: ["KMMViewModelSavedState"] ) ], targets: [ @@ -29,6 +33,10 @@ let package = Package( name: "KMMViewModelSwiftUI", dependencies: [.target(name: "KMMViewModelCore")], path: "KMMViewModelSwiftUI" + ), + .target( + name: "KMMViewModelSavedState", + path: "KMMViewModelSavedState" ) ], swiftLanguageVersions: [.v5] diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f6ff808..67e0ef6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] kotlin = "1.9.22" kotlinx-coroutines = "1.7.3" +kotlinx-serialization = "1.6.2" android = "7.4.2" androidx-lifecycle = "2.6.2" @@ -14,7 +15,10 @@ nativecoroutines = "1.0.0-ALPHA-24" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" } +kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinx-serialization" } androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "androidx-lifecycle" } +androidx-lifecycle-viewmodel-savedstate = { module = "androidx.lifecycle:lifecycle-viewmodel-savedstate", version.ref = "androidx-lifecycle" } # Sample libraries androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "androidx-compose" } @@ -32,6 +36,7 @@ android-library = { id = "com.android.library", version.ref = "android" } # Sample plugins kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } android-application = { id = "com.android.application", version.ref = "android" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } nativecoroutines = { id = "com.rickclephas.kmp.nativecoroutines", version.ref = "nativecoroutines" } diff --git a/kmm-viewmodel-core/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/StateFlowUtils.kt b/kmm-viewmodel-core/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/StateFlowUtils.kt index 033465a..2f34db4 100644 --- a/kmm-viewmodel-core/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/StateFlowUtils.kt +++ b/kmm-viewmodel-core/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/StateFlowUtils.kt @@ -3,6 +3,7 @@ package com.rickclephas.kmm.viewmodel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlin.coroutines.EmptyCoroutineContext /** @@ -14,3 +15,9 @@ public inline fun Flow.stateIn( started: SharingStarted, initialValue: T ): StateFlow = stateIn(viewModelScope, EmptyCoroutineContext, started, initialValue) + +public fun StateFlow.map( + viewModelScope: ViewModelScope, + started: SharingStarted, + transform: (T) -> R +): StateFlow = map { transform(it) }.stateIn(viewModelScope, started, transform(value)) diff --git a/kmm-viewmodel-savedstate/build.gradle.kts b/kmm-viewmodel-savedstate/build.gradle.kts new file mode 100644 index 0000000..831fe51 --- /dev/null +++ b/kmm-viewmodel-savedstate/build.gradle.kts @@ -0,0 +1,82 @@ +plugins { + @Suppress("DSL_SCOPE_VIOLATION") + alias(libs.plugins.android.library) + @Suppress("DSL_SCOPE_VIOLATION") + alias(libs.plugins.kotlin.multiplatform) + `kmm-viewmodel-publish` +} + +kotlin { + explicitApi() + jvmToolchain(11) + + //region Apple and Android targets + macosX64() + macosArm64() + iosArm64() + iosX64() + iosSimulatorArm64() + watchosArm32() + watchosArm64() + watchosX64() + watchosSimulatorArm64() + watchosDeviceArm64() + tvosArm64() + tvosX64() + tvosSimulatorArm64() + androidTarget { + publishLibraryVariants("release") + } + //endregion + + targets.all { + compilations.all { + compilerOptions.configure { + freeCompilerArgs.add("-Xexpect-actual-classes") + } + } + } + + sourceSets { + all { + languageSettings { + optIn("kotlin.experimental.ExperimentalObjCRefinement") + optIn("kotlinx.cinterop.ExperimentalForeignApi") + } + } + + commonMain { + dependencies { + api(project(":kmm-viewmodel-core")) + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.serialization.core) + implementation(libs.kotlinx.serialization.cbor) + } + } + + androidMain { + dependencies { + api(libs.androidx.lifecycle.viewmodel.savedstate) + } + } + } +} + +android { + namespace = "com.rickclephas.kmm.viewmodel.savedstate" + compileSdk = 33 + defaultConfig { + minSdk = 14 + } + // TODO: Remove workaround for https://issuetracker.google.com/issues/260059413 + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + publishing { + singleVariant("release") { + withSourcesJar() + withJavadocJar() + } + } +} diff --git a/kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/AndroidSavedStateHandle.kt b/kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/AndroidSavedStateHandle.kt new file mode 100644 index 0000000..81d5501 --- /dev/null +++ b/kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/AndroidSavedStateHandle.kt @@ -0,0 +1,6 @@ +package com.rickclephas.kmm.viewmodel.savedstate + +public actual typealias AndroidSavedStateHandle = androidx.lifecycle.SavedStateHandle + +public actual fun AndroidSavedStateHandle.asSavedStateHandle(): SavedStateHandle = + SavedStateHandle(this) diff --git a/kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/CreationExtras.kt b/kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/CreationExtras.kt new file mode 100644 index 0000000..7343775 --- /dev/null +++ b/kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/CreationExtras.kt @@ -0,0 +1,7 @@ +package com.rickclephas.kmm.viewmodel.savedstate + +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.CreationExtras + +public fun CreationExtras.createSavedStateHandle(): SavedStateHandle = + SavedStateHandle(createSavedStateHandle()) diff --git a/kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt b/kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt new file mode 100644 index 0000000..51b0e79 --- /dev/null +++ b/kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt @@ -0,0 +1,44 @@ +package com.rickclephas.kmm.viewmodel.savedstate + +import com.rickclephas.kmm.viewmodel.ViewModelScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.serializer + +public actual class SavedStateHandle( + @PublishedApi internal val handle: AndroidSavedStateHandle +) { + public actual constructor() : this(AndroidSavedStateHandle()) + public constructor(initialState: Map) : this(AndroidSavedStateHandle(initialState)) + + public actual fun keys(): Set = handle.keys() + + public actual operator fun contains(key: String): Boolean = handle.contains(key) + + public actual inline fun getStateFlow( + viewModelScope: ViewModelScope, + key: String, + initialValue: T + ): StateFlow { + if (!shouldSerialize(T::class)) return handle.getStateFlow(key, initialValue) + val serializer = serializer() + val stateFlow = handle.getStateFlow(key, serializer.serialize(initialValue)) + return serializer.deserialize(stateFlow) + } + + public actual inline operator fun get(key: String): T? { + if (!shouldSerialize(T::class)) return handle[key] + val value: ByteArray = handle[key] ?: return null + return serializer().deserialize(value) + } + + public actual inline operator fun set(key: String, value: T?) { + if (!shouldSerialize(T::class)) return handle.set(key, value) + return handle.set(key, value?.let { serializer().serialize(value) }) + } + + public actual inline fun remove(key: String): T? { + if (!shouldSerialize(T::class)) return handle.remove(key) + val value = handle.remove(key) ?: return null + return serializer().deserialize(value) + } +} diff --git a/kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/Serialization.kt b/kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/Serialization.kt new file mode 100644 index 0000000..654bba8 --- /dev/null +++ b/kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/Serialization.kt @@ -0,0 +1,77 @@ +package com.rickclephas.kmm.viewmodel.savedstate + +import android.os.Binder +import android.os.Build +import android.os.Bundle +import android.os.Parcelable +import android.util.Size +import android.util.SizeF +import android.util.SparseArray +import kotlinx.coroutines.flow.FlowCollector +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.BinaryFormat +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.cbor.Cbor +import java.io.Serializable +import kotlin.reflect.KClass + +@PublishedApi +internal fun shouldSerialize(kClass: KClass<*>): Boolean = !nativeClasses.contains(kClass) + +private val nativeClasses = setOfNotNull( + Boolean::class, BooleanArray::class, + Double::class, DoubleArray::class, + Int::class, IntArray::class, + Long::class, LongArray::class, + String::class, Array::class, + Binder::class, + Bundle::class, + Byte::class, ByteArray::class, + Char::class, CharArray::class, + CharSequence::class, Array::class, + // ArrayList must be serialized as it can contain non-native classes + Float::class, FloatArray::class, + Parcelable::class, Array::class, + Serializable::class, + Short::class, ShortArray::class, + SparseArray::class, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) Size::class else null, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) SizeF::class else null, +) + +@OptIn(ExperimentalSerializationApi::class) +private val binaryFormat: BinaryFormat = Cbor { + encodeDefaults = true + ignoreUnknownKeys = true +} + +@PublishedApi +internal fun KSerializer.serialize(value: T): ByteArray? { + if (value == null) return null + return binaryFormat.encodeToByteArray(this, value) +} + +@PublishedApi +internal fun KSerializer.deserialize(value: ByteArray?): T { + @Suppress("UNCHECKED_CAST") + if (value == null) return null as T + return binaryFormat.decodeFromByteArray(this, value) +} + +@PublishedApi +internal fun KSerializer.deserialize(stateFlow: StateFlow): StateFlow = + DeserializeStateFlow(this, stateFlow) + +private class DeserializeStateFlow( + private val serializer: KSerializer, + private val stateFlow: StateFlow +): StateFlow { + + override val value: T get() = serializer.deserialize(stateFlow.value) + + override val replayCache: List get() = listOf(value) + + override suspend fun collect(collector: FlowCollector): Nothing = + stateFlow.collect { collector.emit(serializer.deserialize(it)) } +} diff --git a/kmm-viewmodel-savedstate/src/appleMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/AndroidSavedStateHandle.kt b/kmm-viewmodel-savedstate/src/appleMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/AndroidSavedStateHandle.kt new file mode 100644 index 0000000..c57466c --- /dev/null +++ b/kmm-viewmodel-savedstate/src/appleMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/AndroidSavedStateHandle.kt @@ -0,0 +1,5 @@ +package com.rickclephas.kmm.viewmodel.savedstate + +public actual object AndroidSavedStateHandle + +public actual fun AndroidSavedStateHandle.asSavedStateHandle(): SavedStateHandle = SavedStateHandle() diff --git a/kmm-viewmodel-savedstate/src/appleMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt b/kmm-viewmodel-savedstate/src/appleMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt new file mode 100644 index 0000000..ca06a53 --- /dev/null +++ b/kmm-viewmodel-savedstate/src/appleMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt @@ -0,0 +1,98 @@ +package com.rickclephas.kmm.viewmodel.savedstate + +import com.rickclephas.kmm.viewmodel.MutableStateFlow +import com.rickclephas.kmm.viewmodel.ViewModelScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.KSerializer +import kotlinx.serialization.serializer +import platform.Foundation.NSData + +public actual class SavedStateHandle actual constructor() { + + public constructor(configure: SavedStateHandle.() -> Unit): this() { configure() } + + private val serializedState = mutableMapOf() + private val state = mutableMapOf() + private val flows = mutableMapOf, KSerializer<*>>>() + + private var stateChangedListener: ((NSData?) -> Unit)? = null + + public fun setStateChangedListener(listener: (NSData?) -> Unit) { + stateChangedListener = listener + } + + private fun invokeStateChangedListener() { + stateChangedListener?.invoke(data) + } + + public var data: NSData? + get() = serializedState.serialize() + set(value) { + if (value != null) { + value.deserialize().forEach { (key, value) -> serializedState[key] = value } + state.clear() + flows.forEach { (key, value) -> + val (flow, serializer) = value + flow.value = get(key, serializer) + } + } + invokeStateChangedListener() + } + + public actual fun keys(): Set = serializedState.keys + + public actual operator fun contains(key: String): Boolean = serializedState.contains(key) + + @HiddenFromObjC + public actual inline fun getStateFlow( + viewModelScope: ViewModelScope, + key: String, + initialValue: T + ): StateFlow = getStateFlow(viewModelScope, key, initialValue, serializer()) + + @PublishedApi + @Suppress("UNCHECKED_CAST") + internal fun getStateFlow( + viewModelScope: ViewModelScope, + key: String, + initialValue: T, + serializer: KSerializer + ): StateFlow = flows.getOrPut(key) { + val value = get(key, serializer) ?: initialValue.also { set(key, it, serializer) } + Pair(MutableStateFlow(viewModelScope, value), serializer) + }.first.asStateFlow() as StateFlow + + @HiddenFromObjC + public actual inline operator fun get(key: String): T? = get(key, serializer()) + + @PublishedApi + @Suppress("UNCHECKED_CAST") + internal fun get(key: String, serializer: KSerializer): T? = + state.getOrPut(key) { serializedState[key]?.let(serializer::deserialize) } as T? + + @HiddenFromObjC + public actual inline operator fun set(key: String, value: T?): Unit = set(key, value, serializer()) + + @PublishedApi + internal fun set(key: String, value: T?, serializer: KSerializer) { + state[key] = value + serializedState[key] = value?.let(serializer::serialize) + flows[key]?.first?.value = value + invokeStateChangedListener() + } + + @HiddenFromObjC + public actual inline fun remove(key: String): T? = remove(key, serializer()) + + @PublishedApi + internal fun remove(key: String, serializer: KSerializer): T? { + val value = state.remove(key) + val serializedValue = serializedState.remove(key) + flows.remove(key) + invokeStateChangedListener() + @Suppress("UNCHECKED_CAST") + return value as T? ?: serializedValue?.let(serializer::deserialize) + } +} diff --git a/kmm-viewmodel-savedstate/src/appleMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/Serialization.kt b/kmm-viewmodel-savedstate/src/appleMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/Serialization.kt new file mode 100644 index 0000000..2208fcf --- /dev/null +++ b/kmm-viewmodel-savedstate/src/appleMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/Serialization.kt @@ -0,0 +1,47 @@ +package com.rickclephas.kmm.viewmodel.savedstate + +import kotlinx.cinterop.UnsafeNumber +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.convert +import kotlinx.cinterop.usePinned +import kotlinx.serialization.* +import kotlinx.serialization.cbor.Cbor +import platform.Foundation.NSData +import platform.Foundation.dataWithBytes +import platform.posix.memcpy + +@OptIn(ExperimentalSerializationApi::class) +internal val binaryFormat: BinaryFormat = Cbor { + encodeDefaults = true + ignoreUnknownKeys = true +} + +@PublishedApi +internal fun KSerializer.serialize(value: T): ByteArray? { + if (value == null) return null + return binaryFormat.encodeToByteArray(this, value) +} + +@PublishedApi +internal fun KSerializer.deserialize(value: ByteArray?): T { + @Suppress("UNCHECKED_CAST") + if (value == null) return null as T + return binaryFormat.decodeFromByteArray(this, value) +} + +@Suppress("UnnecessaryOptInAnnotation") +@OptIn(UnsafeNumber::class) +internal fun Map.serialize(): NSData { + val byteArray = binaryFormat.encodeToByteArray(this) + return byteArray.usePinned { + NSData.dataWithBytes(it.addressOf(0), byteArray.size.convert()) + } +} + +@OptIn(UnsafeNumber::class) +internal fun NSData.deserialize(): Map { + val byteArray = ByteArray(this.length.toInt()).apply { + usePinned { memcpy(it.addressOf(0), this@deserialize.bytes, this@deserialize.length) } + } + return binaryFormat.decodeFromByteArray(byteArray) +} diff --git a/kmm-viewmodel-savedstate/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/AndroidSavedStateHandle.kt b/kmm-viewmodel-savedstate/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/AndroidSavedStateHandle.kt new file mode 100644 index 0000000..1f6dee3 --- /dev/null +++ b/kmm-viewmodel-savedstate/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/AndroidSavedStateHandle.kt @@ -0,0 +1,5 @@ +package com.rickclephas.kmm.viewmodel.savedstate + +public expect class AndroidSavedStateHandle + +public expect fun AndroidSavedStateHandle.asSavedStateHandle(): SavedStateHandle diff --git a/kmm-viewmodel-savedstate/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/MutableStateFlow.kt b/kmm-viewmodel-savedstate/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/MutableStateFlow.kt new file mode 100644 index 0000000..ba6674a --- /dev/null +++ b/kmm-viewmodel-savedstate/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/MutableStateFlow.kt @@ -0,0 +1,48 @@ +package com.rickclephas.kmm.viewmodel.savedstate + +import com.rickclephas.kmm.viewmodel.ViewModelScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +public inline fun SavedStateHandle.getMutableStateFlow( + viewModelScope: ViewModelScope, + key: String, + initialValue: T +): MutableStateFlow = getStateFlow(viewModelScope, key, initialValue).asMutableStateFlow { set(key, it) } + +@PublishedApi +internal fun StateFlow.asMutableStateFlow(setValue: (T) -> Unit): MutableStateFlow = + MutableStateFlowImpl(this, setValue) + +private class MutableStateFlowImpl( + private val stateFlow: StateFlow, + private val setValue: (T) -> Unit, +): MutableStateFlow, StateFlow by stateFlow { + + override val subscriptionCount: StateFlow + get() = throw UnsupportedOperationException("SavedStateHandle MutableStateFlow.subscriptionCount is not supported") + + override var value: T + get() = stateFlow.value + set(value) { setValue(value) } + + override fun compareAndSet(expect: T, update: T): Boolean { + value = update + return true + } + + @ExperimentalCoroutinesApi + override fun resetReplayCache() { + throw UnsupportedOperationException("MutableStateFlow.resetReplayCache is not supported") + } + + override fun tryEmit(value: T): Boolean { + this.value = value + return true + } + + override suspend fun emit(value: T) { + this.value = value + } +} diff --git a/kmm-viewmodel-savedstate/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt b/kmm-viewmodel-savedstate/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt new file mode 100644 index 0000000..9818c7a --- /dev/null +++ b/kmm-viewmodel-savedstate/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt @@ -0,0 +1,18 @@ +package com.rickclephas.kmm.viewmodel.savedstate + +import com.rickclephas.kmm.viewmodel.ViewModelScope +import kotlinx.coroutines.flow.StateFlow +import kotlin.native.HiddenFromObjC + +public expect class SavedStateHandle() { + public fun keys(): Set + public operator fun contains(key: String): Boolean + @HiddenFromObjC + public inline fun getStateFlow(viewModelScope: ViewModelScope, key: String, initialValue: T): StateFlow + @HiddenFromObjC + public inline operator fun get(key: String): T? + @HiddenFromObjC + public inline operator fun set(key: String, value: T?) + @HiddenFromObjC + public inline fun remove(key: String): T? +} diff --git a/sample/iosApp/KMMViewModelSample.xcodeproj/project.pbxproj b/sample/iosApp/KMMViewModelSample.xcodeproj/project.pbxproj index 71c39a9..e5f7037 100644 --- a/sample/iosApp/KMMViewModelSample.xcodeproj/project.pbxproj +++ b/sample/iosApp/KMMViewModelSample.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 1D44EF89292C066C00465C43 /* KMMViewModelSampleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D44EF88292C066C00465C43 /* KMMViewModelSampleTests.swift */; }; 1D44EF93292C066C00465C43 /* KMMViewModelSampleUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D44EF92292C066C00465C43 /* KMMViewModelSampleUITests.swift */; }; 1D44EF95292C066C00465C43 /* KMMViewModelSampleUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D44EF94292C066C00465C43 /* KMMViewModelSampleUITestsLaunchTests.swift */; }; + 1D9474292B5086E10064A727 /* KMMViewModelSavedState in Frameworks */ = {isa = PBXBuildFile; productRef = 1D9474282B5086E10064A727 /* KMMViewModelSavedState */; }; 1DCF89AB2933929400A4C54A /* KMMViewModelSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 1DCF89AA2933929400A4C54A /* KMMViewModelSwiftUI */; }; 1DDAF2202935470A0049C114 /* KMMViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DDAF21F2935470A0049C114 /* KMMViewModel.swift */; }; 1DDAF222293548A60049C114 /* TimeTravelViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DDAF221293548A60049C114 /* TimeTravelViewModel.swift */; }; @@ -59,6 +60,7 @@ buildActionMask = 2147483647; files = ( 1DCF89AB2933929400A4C54A /* KMMViewModelSwiftUI in Frameworks */, + 1D9474292B5086E10064A727 /* KMMViewModelSavedState in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -174,6 +176,7 @@ name = KMMViewModelSample; packageProductDependencies = ( 1DCF89AA2933929400A4C54A /* KMMViewModelSwiftUI */, + 1D9474282B5086E10064A727 /* KMMViewModelSavedState */, ); productName = KMMViewModelSample; productReference = 1D44EF74292C066B00465C43 /* KMMViewModelSample.app */; @@ -647,6 +650,10 @@ /* End XCConfigurationList section */ /* Begin XCSwiftPackageProductDependency section */ + 1D9474282B5086E10064A727 /* KMMViewModelSavedState */ = { + isa = XCSwiftPackageProductDependency; + productName = KMMViewModelSavedState; + }; 1DCF89AA2933929400A4C54A /* KMMViewModelSwiftUI */ = { isa = XCSwiftPackageProductDependency; productName = KMMViewModelSwiftUI; diff --git a/sample/iosApp/KMMViewModelSample/ContentView.swift b/sample/iosApp/KMMViewModelSample/ContentView.swift index 6ca9bb2..c801e93 100644 --- a/sample/iosApp/KMMViewModelSample/ContentView.swift +++ b/sample/iosApp/KMMViewModelSample/ContentView.swift @@ -7,6 +7,7 @@ import SwiftUI import KMMViewModelSwiftUI +import KMMViewModelSavedState struct ContentView: View { @@ -62,7 +63,7 @@ struct ContentView: View { }.foregroundColor(viewModel.isResetDisabled ? Color.red : Color.green) } Spacer() - } + }.savedState("TimeTravelViewModel", viewModel.savedStateHandle) } } diff --git a/sample/iosApp/KMMViewModelSample/KMMViewModel.swift b/sample/iosApp/KMMViewModelSample/KMMViewModel.swift index 1622b09..2fa789a 100644 --- a/sample/iosApp/KMMViewModelSample/KMMViewModel.swift +++ b/sample/iosApp/KMMViewModelSample/KMMViewModel.swift @@ -6,6 +6,9 @@ // import KMMViewModelCore +import KMMViewModelSavedState import KMMViewModelSampleShared extension Kmm_viewmodel_coreKMMViewModel: KMMViewModel { } + +extension Kmm_viewmodel_savedstateSavedStateHandle: SavedStateHandle { } diff --git a/sample/iosApp/KMMViewModelSample/KMMViewModelSampleApp.swift b/sample/iosApp/KMMViewModelSample/KMMViewModelSampleApp.swift index 112452d..ce6472f 100644 --- a/sample/iosApp/KMMViewModelSample/KMMViewModelSampleApp.swift +++ b/sample/iosApp/KMMViewModelSample/KMMViewModelSampleApp.swift @@ -6,6 +6,8 @@ // import SwiftUI +import KMMViewModelSavedState +import KMMViewModelSwiftUI @main struct KMMViewModelSampleApp: App { @@ -13,8 +15,21 @@ struct KMMViewModelSampleApp: App { var body: some Scene { WindowGroup { NavigationStack { - NavigationLink("GO!", destination: ContentView()) - } + RootView() + }.savedStateManager() + } + } +} + +struct RootView: View { + + @SceneStorage("navigated") var navigated: Bool = false + + var body: some View { + Button("GO!") { + navigated = true + }.navigationDestination(isPresented: $navigated) { + ContentView() } } } diff --git a/sample/settings.gradle.kts b/sample/settings.gradle.kts index 02d3cd2..01f61b2 100644 --- a/sample/settings.gradle.kts +++ b/sample/settings.gradle.kts @@ -23,6 +23,8 @@ includeBuild("..") { dependencySubstitution { substitute(module("com.rickclephas.kmm:kmm-viewmodel-core")) .using(project(":kmm-viewmodel-core")) + substitute(module("com.rickclephas.kmm:kmm-viewmodel-savedstate")) + .using(project(":kmm-viewmodel-savedstate")) } } diff --git a/sample/shared/build.gradle.kts b/sample/shared/build.gradle.kts index fb21450..475ae2c 100644 --- a/sample/shared/build.gradle.kts +++ b/sample/shared/build.gradle.kts @@ -7,6 +7,8 @@ plugins { alias(libs.plugins.ksp) @Suppress("DSL_SCOPE_VIOLATION") alias(libs.plugins.nativecoroutines) + @Suppress("DSL_SCOPE_VIOLATION") + alias(libs.plugins.kotlin.serialization) } kotlin { @@ -34,6 +36,7 @@ kotlin { dependencies { implementation(libs.kotlinx.coroutines.core) api("com.rickclephas.kmm:kmm-viewmodel-core") + api("com.rickclephas.kmm:kmm-viewmodel-savedstate") } } commonTest { diff --git a/sample/shared/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/sample/shared/TimeTravelViewModel.kt b/sample/shared/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/sample/shared/TimeTravelViewModel.kt index 18c586a..391ef63 100644 --- a/sample/shared/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/sample/shared/TimeTravelViewModel.kt +++ b/sample/shared/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/sample/shared/TimeTravelViewModel.kt @@ -1,11 +1,19 @@ package com.rickclephas.kmm.viewmodel.sample.shared import com.rickclephas.kmm.viewmodel.* +import com.rickclephas.kmm.viewmodel.savedstate.AndroidSavedStateHandle +import com.rickclephas.kmm.viewmodel.savedstate.SavedStateHandle +import com.rickclephas.kmm.viewmodel.savedstate.asSavedStateHandle +import com.rickclephas.kmm.viewmodel.savedstate.getMutableStateFlow import com.rickclephas.kmp.nativecoroutines.NativeCoroutinesState import kotlinx.coroutines.flow.* import kotlin.random.Random -open class TimeTravelViewModel: KMMViewModel() { +open class TimeTravelViewModel( + val savedStateHandle: SavedStateHandle +): KMMViewModel() { + constructor(androidSavedStateHandle: AndroidSavedStateHandle): this(androidSavedStateHandle.asSavedStateHandle()) + constructor(): this(SavedStateHandle()) private val clockTime = Clock.time @@ -16,7 +24,7 @@ open class TimeTravelViewModel: KMMViewModel() { val actualTime = clockTime.map { formatTime(it) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), "N/A") - private val _travelEffect = MutableStateFlow(viewModelScope, null) + private val _travelEffect = savedStateHandle.getMutableStateFlow(viewModelScope, "travelEffect", null) /** * A [StateFlow] that emits the applied [TravelEffect]. */ @@ -29,8 +37,9 @@ open class TimeTravelViewModel: KMMViewModel() { * @see stopTime */ @NativeCoroutinesState - val isFixedTime = _travelEffect.map { it is TravelEffect.Fixed } - .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), false) + val isFixedTime = _travelEffect.map(viewModelScope, SharingStarted.WhileSubscribed()) { + it is TravelEffect.Fixed + } /** * A [StateFlow] that emits the current time. diff --git a/sample/shared/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/sample/shared/TravelEffect.kt b/sample/shared/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/sample/shared/TravelEffect.kt index 8781127..d8f7b5c 100644 --- a/sample/shared/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/sample/shared/TravelEffect.kt +++ b/sample/shared/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/sample/shared/TravelEffect.kt @@ -1,9 +1,13 @@ package com.rickclephas.kmm.viewmodel.sample.shared +import kotlinx.serialization.Serializable import kotlin.random.Random +@Serializable sealed class TravelEffect { + @Serializable data class Offset(val offset: Long): TravelEffect() + @Serializable data class Fixed(val time: Long): TravelEffect() } diff --git a/settings.gradle.kts b/settings.gradle.kts index b546f28..649c519 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -9,3 +9,4 @@ pluginManagement { rootProject.name = "kmm-viewmodel" include(":kmm-viewmodel-core") +include(":kmm-viewmodel-savedstate")