From 9891694174ac14f95899e29836a2dd3599661c58 Mon Sep 17 00:00:00 2001 From: Rick Clephas Date: Wed, 10 Jan 2024 22:47:05 +0100 Subject: [PATCH 1/6] Add Android and Apple SavedStateHandle implementation --- gradle/libs.versions.toml | 5 ++ kmm-viewmodel-savedstate/build.gradle.kts | 81 +++++++++++++++++ .../savedstate/AndroidSavedStateHandle.kt | 6 ++ .../viewmodel/savedstate/CreationExtras.kt | 7 ++ .../viewmodel/savedstate/SavedStateHandle.kt | 44 +++++++++ .../kmm/viewmodel/savedstate/Serialization.kt | 77 ++++++++++++++++ .../savedstate/AndroidSavedStateHandle.kt | 5 ++ .../viewmodel/savedstate/SavedStateHandle.kt | 89 +++++++++++++++++++ .../kmm/viewmodel/savedstate/Serialization.kt | 47 ++++++++++ .../savedstate/AndroidSavedStateHandle.kt | 5 ++ .../viewmodel/savedstate/MutableStateFlow.kt | 48 ++++++++++ .../viewmodel/savedstate/SavedStateHandle.kt | 14 +++ sample/settings.gradle.kts | 2 + sample/shared/build.gradle.kts | 3 + .../sample/shared/TimeTravelViewModel.kt | 11 ++- .../viewmodel/sample/shared/TravelEffect.kt | 4 + settings.gradle.kts | 1 + 17 files changed, 447 insertions(+), 2 deletions(-) create mode 100644 kmm-viewmodel-savedstate/build.gradle.kts create mode 100644 kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/AndroidSavedStateHandle.kt create mode 100644 kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/CreationExtras.kt create mode 100644 kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt create mode 100644 kmm-viewmodel-savedstate/src/androidMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/Serialization.kt create mode 100644 kmm-viewmodel-savedstate/src/appleMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/AndroidSavedStateHandle.kt create mode 100644 kmm-viewmodel-savedstate/src/appleMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt create mode 100644 kmm-viewmodel-savedstate/src/appleMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/Serialization.kt create mode 100644 kmm-viewmodel-savedstate/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/AndroidSavedStateHandle.kt create mode 100644 kmm-viewmodel-savedstate/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/MutableStateFlow.kt create mode 100644 kmm-viewmodel-savedstate/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt 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-savedstate/build.gradle.kts b/kmm-viewmodel-savedstate/build.gradle.kts new file mode 100644 index 0000000..41fd2af --- /dev/null +++ b/kmm-viewmodel-savedstate/build.gradle.kts @@ -0,0 +1,81 @@ +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("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..d820708 --- /dev/null +++ b/kmm-viewmodel-savedstate/src/appleMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt @@ -0,0 +1,89 @@ +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: (() -> Unit)? = null + + public fun setStateChangedListener(listener: () -> Unit) { + stateChangedListener = listener + } + + 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) + } + } + stateChangedListener?.invoke() + } + + public actual fun keys(): Set = serializedState.keys + + public actual operator fun contains(key: String): Boolean = serializedState.contains(key) + + 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 + + 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? + + 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 + stateChangedListener?.invoke() + } + + 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) + @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..3ba4363 --- /dev/null +++ b/kmm-viewmodel-savedstate/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt @@ -0,0 +1,14 @@ +package com.rickclephas.kmm.viewmodel.savedstate + +import com.rickclephas.kmm.viewmodel.ViewModelScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.KSerializer + +public expect class SavedStateHandle() { + public fun keys(): Set + public operator fun contains(key: String): Boolean + public inline fun getStateFlow(viewModelScope: ViewModelScope, key: String, initialValue: T): StateFlow + public inline operator fun get(key: String): T? + public inline operator fun set(key: String, value: T?) + public inline fun remove(key: String): T? +} 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..0b8ff43 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,18 @@ 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()) private val clockTime = Clock.time @@ -16,7 +23,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]. */ 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") From cb0c860536536e0def498fee9830b9dbbcf802d7 Mon Sep 17 00:00:00 2001 From: Rick Clephas Date: Wed, 10 Jan 2024 22:47:29 +0100 Subject: [PATCH 2/6] Add StateFlow.map util function --- .../kotlin/com/rickclephas/kmm/viewmodel/StateFlowUtils.kt | 7 +++++++ .../kmm/viewmodel/sample/shared/TimeTravelViewModel.kt | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) 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/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 0b8ff43..b791ccc 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 @@ -36,8 +36,9 @@ open class TimeTravelViewModel( * @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. From 218d40ce600571372b1377cc395054a757f22c2b Mon Sep 17 00:00:00 2001 From: Rick Clephas Date: Sat, 13 Jan 2024 00:00:06 +0100 Subject: [PATCH 3/6] Add SavedState SwiftUI implementation --- KMMViewModel.xcodeproj/project.pbxproj | 144 ++++++++++++++++++ KMMViewModelSavedState/SavedStateHandle.swift | 13 ++ .../SavedStateManager.swift | 67 ++++++++ .../SavedStateObserver.swift | 64 ++++++++ Package.swift | 8 + .../project.pbxproj | 7 + .../KMMViewModelSample/ContentView.swift | 3 +- .../KMMViewModelSample/KMMViewModel.swift | 3 + .../KMMViewModelSampleApp.swift | 19 ++- .../sample/shared/TimeTravelViewModel.kt | 1 + 10 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 KMMViewModelSavedState/SavedStateHandle.swift create mode 100644 KMMViewModelSavedState/SavedStateManager.swift create mode 100644 KMMViewModelSavedState/SavedStateObserver.swift 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..2948509 --- /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 () -> Void) +} diff --git a/KMMViewModelSavedState/SavedStateManager.swift b/KMMViewModelSavedState/SavedStateManager.swift new file mode 100644 index 0000000..708d977 --- /dev/null +++ b/KMMViewModelSavedState/SavedStateManager.swift @@ -0,0 +1,67 @@ +// +// 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(SavedStateManagerModifier()) + } +} + +@available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) +private struct SavedStateManagerModifier: ViewModifier { + + @SceneStorage("KMMViewModelSavedState") private var state: Data? + @StateObject private var savedStateManager = SavedStateManager() + + func body(content: Content) -> some View { + content.environmentObject(savedStateManager).onReceive(savedStateManager.stateChanged) { + state = savedStateManager.getState() + }.onAppear { + savedStateManager.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 self.state != state else { return } + self.state = state + if let state { + states = try! NSKeyedUnarchiver.unarchivedDictionary( + ofKeyClass: NSString.self, objectClass: NSData.self, from: state + ) as [String : Data]? ?? [:] + } else { + states = [:] + } + } +} diff --git a/KMMViewModelSavedState/SavedStateObserver.swift b/KMMViewModelSavedState/SavedStateObserver.swift new file mode 100644 index 0000000..8baa2f4 --- /dev/null +++ b/KMMViewModelSavedState/SavedStateObserver.swift @@ -0,0 +1,64 @@ +// +// 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 savedStateManager: 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.setSavedStateManager(savedStateManager) + } + } +} + +@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 savedStateManager: SavedStateManager? = nil + private var cancellable: AnyCancellable? = nil + + init(_ key: String, _ savedStateHandle: SavedStateHandle) { + self.key = key + self.savedStateHandle = savedStateHandle + savedStateHandle.setStateChangedListener { [weak self, unowned savedStateHandle] in + guard let self else { return } + self.savedStateManager?.setState(savedStateHandle.data, for: self.key) + } + } + + func setSavedStateManager(_ savedStateManager: SavedStateManager) { + guard self.savedStateManager == nil else { return } + self.savedStateManager = savedStateManager + savedStateHandle.data = savedStateManager.getState(for: key) + } + + deinit { + savedStateManager?.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/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/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 b791ccc..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 @@ -13,6 +13,7 @@ open class TimeTravelViewModel( val savedStateHandle: SavedStateHandle ): KMMViewModel() { constructor(androidSavedStateHandle: AndroidSavedStateHandle): this(androidSavedStateHandle.asSavedStateHandle()) + constructor(): this(SavedStateHandle()) private val clockTime = Clock.time From 5328559c73075427aeff0386045cd586fc3a1642 Mon Sep 17 00:00:00 2001 From: Rick Clephas Date: Sat, 13 Jan 2024 13:24:43 +0100 Subject: [PATCH 4/6] Minor KMMViewModelSavedState code improvements --- KMMViewModelSavedState/SavedStateHandle.swift | 2 +- .../SavedStateManager.swift | 24 ++++++++----------- .../SavedStateObserver.swift | 21 ++++++++-------- .../viewmodel/savedstate/SavedStateHandle.kt | 8 +++---- 4 files changed, 25 insertions(+), 30 deletions(-) diff --git a/KMMViewModelSavedState/SavedStateHandle.swift b/KMMViewModelSavedState/SavedStateHandle.swift index 2948509..3dcb3b2 100644 --- a/KMMViewModelSavedState/SavedStateHandle.swift +++ b/KMMViewModelSavedState/SavedStateHandle.swift @@ -9,5 +9,5 @@ import Foundation public protocol SavedStateHandle: AnyObject { var data: Data? { get set } - func setStateChangedListener(listener: @escaping () -> Void) + func setStateChangedListener(listener: @escaping (Data?) -> Void) } diff --git a/KMMViewModelSavedState/SavedStateManager.swift b/KMMViewModelSavedState/SavedStateManager.swift index 708d977..4db6578 100644 --- a/KMMViewModelSavedState/SavedStateManager.swift +++ b/KMMViewModelSavedState/SavedStateManager.swift @@ -12,21 +12,21 @@ public extension View { @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) func savedStateManager() -> some View { - modifier(SavedStateManagerModifier()) + modifier(SavedStateManagerViewModifier()) } } @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) -private struct SavedStateManagerModifier: ViewModifier { +private struct SavedStateManagerViewModifier: ViewModifier { @SceneStorage("KMMViewModelSavedState") private var state: Data? - @StateObject private var savedStateManager = SavedStateManager() + @StateObject private var manager = SavedStateManager() func body(content: Content) -> some View { - content.environmentObject(savedStateManager).onReceive(savedStateManager.stateChanged) { - state = savedStateManager.getState() + content.environmentObject(manager).onReceive(manager.stateChanged) { + state = manager.getState() }.onAppear { - savedStateManager.setState(state) + manager.setState(state) } } } @@ -54,14 +54,10 @@ internal class SavedStateManager: ObservableObject { func getState() -> Data? { state } func setState(_ state: Data?) { - guard self.state != state else { return } + guard let state, self.state == nil else { return } self.state = state - if let state { - states = try! NSKeyedUnarchiver.unarchivedDictionary( - ofKeyClass: NSString.self, objectClass: NSData.self, from: state - ) as [String : Data]? ?? [:] - } else { - states = [:] - } + 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 index 8baa2f4..264e1e5 100644 --- a/KMMViewModelSavedState/SavedStateObserver.swift +++ b/KMMViewModelSavedState/SavedStateObserver.swift @@ -19,7 +19,7 @@ public extension View { @available(iOS 14.0, macOS 11.0, tvOS 14.0, watchOS 7.0, *) internal struct SavedStateObserverViewModifier: ViewModifier { - @EnvironmentObject private var savedStateManager: SavedStateManager + @EnvironmentObject private var manager: SavedStateManager @StateObject private var observer: SavedStateObserver init(_ key: String, _ savedStateHandle: SavedStateHandle) { @@ -30,7 +30,7 @@ internal struct SavedStateObserverViewModifier: ViewModifier { func body(content: Content) -> some View { content.onAppear { - observer.setSavedStateManager(savedStateManager) + observer.setManager(manager) } } } @@ -40,25 +40,24 @@ private class SavedStateObserver: ObservableObject { private let key: String private let savedStateHandle: SavedStateHandle - private var savedStateManager: SavedStateManager? = nil - private var cancellable: AnyCancellable? = nil + private var manager: SavedStateManager? = nil init(_ key: String, _ savedStateHandle: SavedStateHandle) { self.key = key self.savedStateHandle = savedStateHandle - savedStateHandle.setStateChangedListener { [weak self, unowned savedStateHandle] in + savedStateHandle.setStateChangedListener { [weak self] data in guard let self else { return } - self.savedStateManager?.setState(savedStateHandle.data, for: self.key) + self.manager?.setState(data, for: key) } } - func setSavedStateManager(_ savedStateManager: SavedStateManager) { - guard self.savedStateManager == nil else { return } - self.savedStateManager = savedStateManager - savedStateHandle.data = savedStateManager.getState(for: key) + func setManager(_ manager: SavedStateManager) { + guard self.manager == nil else { return } + self.manager = manager + savedStateHandle.data = manager.getState(for: key) } deinit { - savedStateManager?.setState(nil, for: key) + manager?.setState(nil, for: key) } } 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 index d820708..87c606b 100644 --- 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 @@ -17,9 +17,9 @@ public actual class SavedStateHandle actual constructor() { private val state = mutableMapOf() private val flows = mutableMapOf, KSerializer<*>>>() - private var stateChangedListener: (() -> Unit)? = null + private var stateChangedListener: ((NSData?) -> Unit)? = null - public fun setStateChangedListener(listener: () -> Unit) { + public fun setStateChangedListener(listener: (NSData?) -> Unit) { stateChangedListener = listener } @@ -34,7 +34,7 @@ public actual class SavedStateHandle actual constructor() { flow.value = get(key, serializer) } } - stateChangedListener?.invoke() + stateChangedListener?.invoke(data) } public actual fun keys(): Set = serializedState.keys @@ -73,7 +73,7 @@ public actual class SavedStateHandle actual constructor() { state[key] = value serializedState[key] = value?.let(serializer::serialize) flows[key]?.first?.value = value - stateChangedListener?.invoke() + stateChangedListener?.invoke(data) } public actual inline fun remove(key: String): T? = remove(key, serializer()) From 28d35fd7b06ffeb179c7c670ed0c7a9fbda9b701 Mon Sep 17 00:00:00 2001 From: Rick Clephas Date: Sat, 13 Jan 2024 14:03:59 +0100 Subject: [PATCH 5/6] Invoke StateChangedListener when state is removed --- .../kmm/viewmodel/savedstate/SavedStateHandle.kt | 9 +++++++-- .../kmm/viewmodel/savedstate/SavedStateHandle.kt | 1 - 2 files changed, 7 insertions(+), 3 deletions(-) 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 index 87c606b..7aae2d7 100644 --- 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 @@ -23,6 +23,10 @@ public actual class SavedStateHandle actual constructor() { stateChangedListener = listener } + private fun invokeStateChangedListener() { + stateChangedListener?.invoke(data) + } + public var data: NSData? get() = serializedState.serialize() set(value) { @@ -34,7 +38,7 @@ public actual class SavedStateHandle actual constructor() { flow.value = get(key, serializer) } } - stateChangedListener?.invoke(data) + invokeStateChangedListener() } public actual fun keys(): Set = serializedState.keys @@ -73,7 +77,7 @@ public actual class SavedStateHandle actual constructor() { state[key] = value serializedState[key] = value?.let(serializer::serialize) flows[key]?.first?.value = value - stateChangedListener?.invoke(data) + invokeStateChangedListener() } public actual inline fun remove(key: String): T? = remove(key, serializer()) @@ -83,6 +87,7 @@ public actual class SavedStateHandle actual constructor() { 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/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt b/kmm-viewmodel-savedstate/src/commonMain/kotlin/com/rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt index 3ba4363..ed7bb8d 100644 --- 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 @@ -2,7 +2,6 @@ package com.rickclephas.kmm.viewmodel.savedstate import com.rickclephas.kmm.viewmodel.ViewModelScope import kotlinx.coroutines.flow.StateFlow -import kotlinx.serialization.KSerializer public expect class SavedStateHandle() { public fun keys(): Set From d27ed95b6aa904b454511835fffe75848f2edaa9 Mon Sep 17 00:00:00 2001 From: Rick Clephas Date: Sat, 13 Jan 2024 19:06:17 +0100 Subject: [PATCH 6/6] Hide SavedStateHandle functions from ObjC --- kmm-viewmodel-savedstate/build.gradle.kts | 1 + .../rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt | 4 ++++ .../rickclephas/kmm/viewmodel/savedstate/SavedStateHandle.kt | 5 +++++ 3 files changed, 10 insertions(+) diff --git a/kmm-viewmodel-savedstate/build.gradle.kts b/kmm-viewmodel-savedstate/build.gradle.kts index 41fd2af..831fe51 100644 --- a/kmm-viewmodel-savedstate/build.gradle.kts +++ b/kmm-viewmodel-savedstate/build.gradle.kts @@ -40,6 +40,7 @@ kotlin { sourceSets { all { languageSettings { + optIn("kotlin.experimental.ExperimentalObjCRefinement") optIn("kotlinx.cinterop.ExperimentalForeignApi") } } 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 index 7aae2d7..ca06a53 100644 --- 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 @@ -45,6 +45,7 @@ public actual class SavedStateHandle actual constructor() { public actual operator fun contains(key: String): Boolean = serializedState.contains(key) + @HiddenFromObjC public actual inline fun getStateFlow( viewModelScope: ViewModelScope, key: String, @@ -63,6 +64,7 @@ public actual class SavedStateHandle actual constructor() { Pair(MutableStateFlow(viewModelScope, value), serializer) }.first.asStateFlow() as StateFlow + @HiddenFromObjC public actual inline operator fun get(key: String): T? = get(key, serializer()) @PublishedApi @@ -70,6 +72,7 @@ public actual class SavedStateHandle actual constructor() { 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 @@ -80,6 +83,7 @@ public actual class SavedStateHandle actual constructor() { invokeStateChangedListener() } + @HiddenFromObjC public actual inline fun remove(key: String): T? = remove(key, serializer()) @PublishedApi 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 index ed7bb8d..9818c7a 100644 --- 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 @@ -2,12 +2,17 @@ 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? }