From 639206719125bb8fe2a81f4c64e2fb1cee01a9c3 Mon Sep 17 00:00:00 2001 From: Thomas SALVETAT Date: Wed, 20 Sep 2023 17:16:53 +0200 Subject: [PATCH 1/3] chore: update project configuration with kotlin 1.9.10 --- build.gradle | 3 +++ gradle/wrapper/gradle-wrapper.properties | 5 ++--- mobk-compose/build.gradle | 2 +- mobk-core/build.gradle | 4 ++++ 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/build.gradle b/build.gradle index 74dc6b9..454158d 100644 --- a/build.gradle +++ b/build.gradle @@ -6,13 +6,16 @@ buildscript { bintrayUsername = properties.getProperty("bintrayUsername") bintrayPassword = properties.getProperty("bintrayPassword") coroutines_version = "1.5.1-native-mt" + kotlin_version = '1.9.10' } repositories { google() + mavenCentral() } dependencies { classpath 'com.android.tools.build:gradle:7.0.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" } } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 673e9fd..ffed3a2 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Wed Aug 11 10:48:24 CEST 2021 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/mobk-compose/build.gradle b/mobk-compose/build.gradle index fdf5c49..cdde7a9 100644 --- a/mobk-compose/build.gradle +++ b/mobk-compose/build.gradle @@ -30,13 +30,13 @@ android { kotlinOptions { jvmTarget = "1.8" - useIR = true } } dependencies { implementation project(":mobk-core") implementation "androidx.compose.ui:ui:1.0.1" + implementation 'androidx.core:core-ktx:+' } publishing { diff --git a/mobk-core/build.gradle b/mobk-core/build.gradle index 331b461..afedf86 100644 --- a/mobk-core/build.gradle +++ b/mobk-core/build.gradle @@ -54,4 +54,8 @@ publishing { } } } +} + +dependencies { + implementation 'androidx.core:core-ktx:+' } \ No newline at end of file From 24a12768955cbc545bc0716bf3c53a55a28eeb3d Mon Sep 17 00:00:00 2001 From: Thomas SALVETAT Date: Thu, 21 Sep 2023 13:41:08 +0200 Subject: [PATCH 2/3] chore: update dependencies + remove mobk-compose thanks to compose multiplatform + update docs --- README.md | 2 +- build.gradle | 35 --------- build.gradle.kts | 27 +++++++ gradle.properties | 3 +- gradle/wrapper/gradle-wrapper.properties | 3 +- mobk-compose/build.gradle | 73 ------------------ mobk-core/build.gradle | 61 --------------- mobk-core/build.gradle.kts | 74 +++++++++++++++++++ .../src/androidMain}/AndroidManifest.xml | 2 +- .../io/monkeypatch/mobk/ui/Observer.kt | 0 mobk-swift/Observer.swift | 39 ++++++++++ settings.gradle | 2 - settings.gradle.kts | 2 + 13 files changed, 148 insertions(+), 175 deletions(-) delete mode 100644 build.gradle create mode 100644 build.gradle.kts delete mode 100644 mobk-compose/build.gradle delete mode 100644 mobk-core/build.gradle create mode 100644 mobk-core/build.gradle.kts rename {mobk-compose/src/main => mobk-core/src/androidMain}/AndroidManifest.xml (76%) rename {mobk-compose/src/main/java => mobk-core/src/commonMain/kotlin}/io/monkeypatch/mobk/ui/Observer.kt (100%) create mode 100644 mobk-swift/Observer.swift delete mode 100644 settings.gradle create mode 100644 settings.gradle.kts diff --git a/README.md b/README.md index 84838ca..6aa577c 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ kotlin { ### SwiftUI ### -You need add the following file [Observer.kt](./mobk-swift/Observer.kt) to your +You need add the following file [Observer.swift](./mobk-swift/Observer.swift) to your Xcode project. Be sure to replace the import line with the name of your Kotlin framework. diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 454158d..0000000 --- a/build.gradle +++ /dev/null @@ -1,35 +0,0 @@ -buildscript { - Properties properties = new Properties() - properties.load(project.rootProject.file("secret.properties").newDataInputStream()) - - ext { - bintrayUsername = properties.getProperty("bintrayUsername") - bintrayPassword = properties.getProperty("bintrayPassword") - coroutines_version = "1.5.1-native-mt" - kotlin_version = '1.9.10' - } - repositories { - google() - mavenCentral() - } - - dependencies { - classpath 'com.android.tools.build:gradle:7.0.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -plugins { - id 'org.jetbrains.kotlin.multiplatform' version '1.5.21' -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - -kotlin { - jvm() -} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..6c8d832 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,27 @@ +buildscript { + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:8.1.1") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.21") + classpath("org.jetbrains.compose:compose-gradle-plugin:1.5.1") + } +} + +plugins { + kotlin("multiplatform") version "1.9.10" +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +kotlin { + jvm() +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 2d8d1e4..a50f1ab 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1 +1,2 @@ -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true +org.jetbrains.compose.experimental.uikit.enabled=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ffed3a2..7f50d7b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,6 @@ +#Wed Sep 20 20:55:00 CEST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/mobk-compose/build.gradle b/mobk-compose/build.gradle deleted file mode 100644 index cdde7a9..0000000 --- a/mobk-compose/build.gradle +++ /dev/null @@ -1,73 +0,0 @@ -plugins { - id 'maven-publish' -} - -apply plugin: 'com.android.library' -apply plugin: 'org.jetbrains.kotlin.android' - -group "io.monkeypatch" -version "0.0.9" - -android { - compileSdkVersion(30) - defaultConfig { - minSdkVersion(24) - targetSdkVersion(30) - } - buildFeatures { - compose = true - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - - composeOptions { - kotlinCompilerExtensionVersion "1.0.1" - kotlinCompilerVersion "1.5.21" - } - - kotlinOptions { - - jvmTarget = "1.8" - } -} - -dependencies { - implementation project(":mobk-core") - implementation "androidx.compose.ui:ui:1.0.1" - implementation 'androidx.core:core-ktx:+' -} - -publishing { - repositories { - maven{ - url = "https://api.bintray.com/maven/alexandre-delattre/MonkeyPatchLibs/mobk-compose/;publish=1" - credentials { - username bintrayUsername - password bintrayPassword - } - authentication { - basic(BasicAuthentication) - } - } - } -} - - -afterEvaluate { - publishing { - publications { - // Creates a Maven publication called "release". - release(MavenPublication) { - // Applies the component for the release build variant. - from components.release - } - // Creates a Maven publication called “debug”. - debug(MavenPublication) { - // Applies the component for the debug build variant. - from components.debug - } - } - } -} \ No newline at end of file diff --git a/mobk-core/build.gradle b/mobk-core/build.gradle deleted file mode 100644 index afedf86..0000000 --- a/mobk-core/build.gradle +++ /dev/null @@ -1,61 +0,0 @@ -plugins { - id 'org.jetbrains.kotlin.multiplatform' - id 'maven-publish' -} - -apply plugin: 'com.android.library' -group = "io.monkeypatch" -version = "0.0.9" - -kotlin { - explicitApi() - - android { - publishLibraryVariants("release", "debug") - } - ios() - sourceSets { - commonMain { - dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version") - } - } - commonTest { - dependencies { - implementation(kotlin("test-common")) - implementation(kotlin("test-annotations-common")) - } - } - - androidTest { - dependencies { - implementation(kotlin("test")) - implementation(kotlin("test-junit")) - } - } - } -} - -android { - compileSdkVersion(30) - defaultConfig { - minSdkVersion(21) - targetSdkVersion(30) - } -} - -publishing { - repositories { - maven{ - url = "https://api.bintray.com/maven/alexandre-delattre/MonkeyPatchLibs/mobk-core/;publish=1" - credentials { - username bintrayUsername - password bintrayPassword - } - } - } -} - -dependencies { - implementation 'androidx.core:core-ktx:+' -} \ No newline at end of file diff --git a/mobk-core/build.gradle.kts b/mobk-core/build.gradle.kts new file mode 100644 index 0000000..20677b8 --- /dev/null +++ b/mobk-core/build.gradle.kts @@ -0,0 +1,74 @@ +plugins { + kotlin("multiplatform") + id("com.android.library") + id("org.jetbrains.compose") + id("maven-publish") +} + +group = "io.monkeypatch" +version = "0.0.11" + +@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) +kotlin { + targetHierarchy.default() + + androidTarget { + compilations.all { + kotlinOptions { + jvmTarget = "1.8" + } + } + + publishLibraryVariants("release") + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + baseName = "shared" + } + + } + sourceSets { + val commonMain by getting{ + dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + implementation("org.jetbrains.kotlinx:atomicfu:0.21.0") + } + + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + val iosMain by getting + val iosTest by getting + } +} + +android { + namespace = "io.monkeypatch.mobk.core" + compileSdk = 34 + defaultConfig { + minSdk = 24 + } +} + + + +publishing { + repositories { + maven{ + url = uri("https://api.bintray.com/maven/alexandre-delattre/MonkeyPatchLibs/mobk-core/;publish=1") + credentials { + username = project.findProperty("bintrayUsername") as String? ?: "" + password = project.findProperty("bintrayPassword") as String? ?: "" + } + } + } +} \ No newline at end of file diff --git a/mobk-compose/src/main/AndroidManifest.xml b/mobk-core/src/androidMain/AndroidManifest.xml similarity index 76% rename from mobk-compose/src/main/AndroidManifest.xml rename to mobk-core/src/androidMain/AndroidManifest.xml index 4c760d5..abc6037 100644 --- a/mobk-compose/src/main/AndroidManifest.xml +++ b/mobk-core/src/androidMain/AndroidManifest.xml @@ -1,4 +1,4 @@ - diff --git a/mobk-compose/src/main/java/io/monkeypatch/mobk/ui/Observer.kt b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/ui/Observer.kt similarity index 100% rename from mobk-compose/src/main/java/io/monkeypatch/mobk/ui/Observer.kt rename to mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/ui/Observer.kt diff --git a/mobk-swift/Observer.swift b/mobk-swift/Observer.swift new file mode 100644 index 0000000..e549034 --- /dev/null +++ b/mobk-swift/Observer.swift @@ -0,0 +1,39 @@ +// +// Observer.swift +// + +import SwiftUI +import + +struct ObserverView : View { + @ObservedObject var reactionObservable: ReactionObservable + + var body: some View { + self.reactionObservable.view + } +} + +func Observer(@ViewBuilder f: @escaping () -> V) -> some View { + let reactionObservable = ReactionObservable(f: f) + return ObserverView(reactionObservable: reactionObservable) +} + +class ReactionObservable: ObservableObject { + @Published var view: V? = nil + var disposer: ReactionDisposer? = nil + + init(f: @escaping () -> V) { + disposer = ApiKt.autorun { [weak self] in + self?.view = f() + } + } + + deinit { + let d = disposer + disposer = nil + DispatchQueue.main.async { + d?.invoke() + } + } + +} diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index 604fc13..0000000 --- a/settings.gradle +++ /dev/null @@ -1,2 +0,0 @@ -rootProject.name = 'mobk' -include ':mobk-core', ':mobk-compose' \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..3691190 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,2 @@ +rootProject.name = "mobk" +include(":mobk-core") \ No newline at end of file From 407cf7f29dd4894847b3ed7ba497356f64c103ba Mon Sep 17 00:00:00 2001 From: Thomas SALVETAT Date: Fri, 22 Sep 2023 13:29:16 +0200 Subject: [PATCH 3/3] feat: add reactions + mobk viewmodel wrapper --- .gitignore | 2 +- README.md | 105 ++++++++++++++ mobk-core/build.gradle.kts | 2 +- .../kotlin/io/monkeypatch/mobk/api/Api.kt | 18 +++ .../io/monkeypatch/mobk/core/Autorun.kt | 9 -- .../io/monkeypatch/mobk/core/Derivation.kt | 2 +- .../io/monkeypatch/mobk/core/Exception.kt | 2 + .../io/monkeypatch/mobk/core/Reaction.kt | 4 +- .../mobk/core/ReactionDisposers.kt | 14 ++ .../monkeypatch/mobk/core/ReactionHelper.kt | 133 ++++++++++++++++++ .../monkeypatch/mobk/core/ReactiveContext.kt | 5 +- .../mobk/core/TestReactiveContext.kt | 59 ++++++++ mobk-swift/MobKViewModel.swift | 22 +++ mobk-viewmodel/build.gradle.kts | 58 ++++++++ .../mobk/viewmodel/MobkViewModel.kt | 44 ++++++ settings.gradle.kts | 3 +- 16 files changed, 466 insertions(+), 16 deletions(-) create mode 100644 mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/ReactionDisposers.kt create mode 100644 mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/ReactionHelper.kt create mode 100644 mobk-swift/MobKViewModel.swift create mode 100644 mobk-viewmodel/build.gradle.kts create mode 100644 mobk-viewmodel/src/commonMain/kotlin/io/monkeypatch/mobk/viewmodel/MobkViewModel.kt diff --git a/.gitignore b/.gitignore index e7e0aac..243954b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ /secret.properties /local.properties -/build/ /.gradle/ /.idea/ /mobk-compose/build/ /mobk-core/build/ +build/ diff --git a/README.md b/README.md index 6aa577c..7c1de08 100644 --- a/README.md +++ b/README.md @@ -133,3 +133,108 @@ Observer { } } ``` + +### MobK ViewModel ### + +#### Dependency #### +Add the mobk-viewmodel dependency to your commonMain dependencies : + +``` gradle +kotlin { + ... + sourceSets { + commonMain { + dependencies { + ... + api "io.monkeypatch:mobk-viewmodel:$mobk_version" + } + } +``` + +#### Usage #### + +The ViewModel class is a simple class that can be used to store state and logic for your application. +It is designed to be used with SwiftUI and Jetpack Compose, but can be used with any UI toolkit. + +It provides reactions, that respect lifecycle of the ViewModel, and can be used to perform side effects. + +``` kotlin +class CounterViewModel: MobKViewModel { + var counter by observable(0) + + val counterWatch = reaction( + delay = 5.seconds, + trackingFn = { counter }) { counter -> + if (counter != null && counter > 10) { + message = "Counter is too high" + } + } + + val warningWatch = whenReaction(predicate = { counter > 12 }) { + message = "Counter is really too high" + } + + fun increment() { + counter++ + } + + @override + fun onCleared() { + super.onCleared() + // Do some cleanup here + } +} +``` + +#### SwiftUI #### + +You need add the following file [MobKViewModel.swift](./mobk-swift/MobKViewModel.swift) to your Xcode project. Be sure to replace the import line with the name of your Kotlin framework. + +After that, you can use the @VM property wrapper to manage the lifecycle of your ViewModel. When component is destroyed, the ViewModel will be cleared. +Usually, you may also use the @StateObject property wrapper to manage the persistence of your ViewModel. + +``` swift +struct ContentView: View { + @StateObject @VM var counterViewModel: CounterViewModel = CounterViewModel() + + var body: some View { + VStack { + Text(verbatim: self.counterViewModel.counter) + + HStack { + Button(action: { + self.counterViewModel.increment() + }) { + Text("Increment") + } + } + } + } +} +``` + +If your view model has a dependency on a parameter, you have to use the following syntax: + +``` swift +struct ContentView: View { + @StateObject @VM var counterViewModel: CounterViewModel + + init(counter: Int) { + _counterViewModel = asStateObject(CounterViewModel(counter: counter)) + } + + var body: some View { + VStack { + Text(verbatim: self.counterViewModel.counter) + + HStack { + Button(action: { + self.counterViewModel.increment() + }) { + Text("Increment") + } + } + } + } +} +``` \ No newline at end of file diff --git a/mobk-core/build.gradle.kts b/mobk-core/build.gradle.kts index 20677b8..50eeb49 100644 --- a/mobk-core/build.gradle.kts +++ b/mobk-core/build.gradle.kts @@ -28,7 +28,7 @@ kotlin { iosSimulatorArm64() ).forEach { it.binaries.framework { - baseName = "shared" + baseName = "mobk-core" } } diff --git a/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/api/Api.kt b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/api/Api.kt index 10ce0da..d48bad8 100644 --- a/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/api/Api.kt +++ b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/api/Api.kt @@ -4,6 +4,7 @@ import io.monkeypatch.mobk.core.* import kotlin.properties.ReadOnlyProperty import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty +import kotlin.time.Duration public fun autorun(body: () -> Unit): ReactionDisposer = @@ -13,6 +14,23 @@ public fun action(body: () -> Unit) { Action(body).runAction() } +public fun reaction(context: ReactiveContext = ReactiveContext.main, + delay: Duration? = null, + equals: ((T?, T?) -> Boolean)? = null, + onError: ReactionErrorHandler? = null,trackingFn: (Reaction) -> T, effect: (T?) -> Unit): ReactionDisposer = + createReaction( + context = context, + delay = delay, + equals = equals, + onError = onError, + trackingFn = trackingFn, effect = effect) + +public fun whenReaction(context: ReactiveContext = ReactiveContext.main, + timeout: Duration? = null, + onError: ReactionErrorHandler? = null, + predicate: (Reaction) -> Boolean, + effect: () -> Unit) = createWhenReaction(context = context, timeout = timeout, onError = onError, predicate = predicate, effect = effect) + public fun observable(initialValue: T): ReadWriteProperty = ObservableDelegate(initialValue) public fun computed(body: () -> T): ReadOnlyProperty = ComputedDelegate(body) diff --git a/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Autorun.kt b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Autorun.kt index d327c17..ed2d0b7 100644 --- a/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Autorun.kt +++ b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Autorun.kt @@ -1,14 +1,5 @@ package io.monkeypatch.mobk.core -public interface ReactionDisposer { - public operator fun invoke() -} - -internal data class ReactionDisposerImpl(private val reaction: Reaction): ReactionDisposer { - override operator fun invoke() { - reaction.dispose() - } -} internal fun createAutorun( context: ReactiveContext = ReactiveContext.main, diff --git a/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Derivation.kt b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Derivation.kt index 33612af..91a3e08 100644 --- a/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Derivation.kt +++ b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Derivation.kt @@ -7,7 +7,7 @@ public enum class DerivationState { STALE } -internal interface Derivation { + interface Derivation { val name: String var observables: Set var newObservables: MutableSet diff --git a/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Exception.kt b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Exception.kt index b775609..76e46fb 100644 --- a/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Exception.kt +++ b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Exception.kt @@ -8,4 +8,6 @@ public sealed class MobXException(message: String): Exception(message) { /// This captures the stack trace when user-land code throws an exception public class MobXCaughtException(public val exception: Throwable): MobXException("MobXCaughtException: $exception") + + public class MobXTimeoutException(message: String): MobXException(message) } \ No newline at end of file diff --git a/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Reaction.kt b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Reaction.kt index f524942..60b7185 100644 --- a/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Reaction.kt +++ b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/Reaction.kt @@ -1,8 +1,8 @@ package io.monkeypatch.mobk.core -internal typealias ReactionErrorHandler = (error: Throwable, reaction: Reaction) -> Unit + typealias ReactionErrorHandler = (error: Throwable, reaction: Reaction) -> Unit -internal interface Reaction : Derivation { + interface Reaction : Derivation { val isDisposed: Boolean fun dispose() diff --git a/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/ReactionDisposers.kt b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/ReactionDisposers.kt new file mode 100644 index 0000000..ce2bcab --- /dev/null +++ b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/ReactionDisposers.kt @@ -0,0 +1,14 @@ +package io.monkeypatch.mobk.core + + class ReactionDisposers { + private val disposers = mutableListOf() + + fun add(disposer: ReactionDisposer) { + disposers.add(disposer) + } + + fun clear() { + disposers.forEach { it() } + disposers.clear() + } +} \ No newline at end of file diff --git a/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/ReactionHelper.kt b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/ReactionHelper.kt new file mode 100644 index 0000000..afb136d --- /dev/null +++ b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/ReactionHelper.kt @@ -0,0 +1,133 @@ +package io.monkeypatch.mobk.core + +import kotlinx.coroutines.Delay +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration + +public interface ReactionDisposer { + public operator fun invoke() +} + +internal data class ReactionDisposerImpl(private val reaction: Reaction): ReactionDisposer { + override operator fun invoke() { + reaction.dispose() + } +} + +internal fun createReaction( + context: ReactiveContext = ReactiveContext.main, + name: String = context.nameFor("Reaction"), + delay: Duration? = null, + equals: ((T?, T?) -> Boolean)? = null, + onError: ReactionErrorHandler? = null, + trackingFn: (Reaction) -> T, + effect: (T?) -> Unit +): ReactionDisposer { + var lastValue: T? = null + var firstTime = true + var runSync = delay == null + var rxn: ReactionImpl? = null + val effectAction = effect + + val reactionRunner: () -> Unit = { + val reaction = rxn + if (reaction == null || !reaction.isDisposed) { + var changed = false + + reaction?.run { + track { + val nextValue = trackingFn(reaction) + val isEqual = if (equals != null) equals(nextValue, lastValue) else lastValue == nextValue + + changed = firstTime || !isEqual + lastValue = nextValue + } + } + + val canInvokeEffect = !firstTime && changed + + if (canInvokeEffect) { + effectAction(lastValue) + } + + if (firstTime) { + firstTime = false + } + } + } + + var isScheduled = false + rxn = ReactionImpl( + context, name, onError + ) { + if (firstTime || runSync) { + reactionRunner() + } else if(!isScheduled) { + isScheduled = true + + context.config.reactionCoroutineScope.launch { + if (delay != null) { + delay(delay) + } + + isScheduled = false + + withContext(Dispatchers.Main) { + reactionRunner() + } + } + } + } + rxn.schedule() + return ReactionDisposerImpl(rxn) +} + +internal fun createWhenReaction( + context: ReactiveContext = ReactiveContext.main, + name: String = context.nameFor("Reaction"), + timeout: Duration? = null, + onError: ReactionErrorHandler? = null, + predicate: (Reaction) -> Boolean, + effect: () -> Unit +): ReactionDisposer { + val effectAction = effect + var dispose: ReactionDisposer? = null + val rxn: ReactionImpl? = null + + if (timeout != null) { + context.config.reactionCoroutineScope.launch { + withTimeout(timeout) { + val d = dispose + val r = rxn + if (d != null && r != null) { + if (!r.isDisposed) { + d() + + val error = MobXException.MobXTimeoutException("WHEN_TIMEOUT") + if (onError != null) { + onError(error, r) + } else { + throw error + } + } + } + } + } + } + + dispose = createAutorun( + context = context, + name = name, + onError = onError + ) { reaction -> + if (predicate(reaction)) { + reaction.dispose() + effectAction() + } + } + return dispose +} \ No newline at end of file diff --git a/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/ReactiveContext.kt b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/ReactiveContext.kt index 7fd9667..90ee19c 100644 --- a/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/ReactiveContext.kt +++ b/mobk-core/src/commonMain/kotlin/io/monkeypatch/mobk/core/ReactiveContext.kt @@ -1,6 +1,8 @@ package io.monkeypatch.mobk.core import io.monkeypatch.mobk.utils.isMainThread +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlin.native.concurrent.ThreadLocal import kotlin.properties.Delegates @@ -59,7 +61,8 @@ public data class ReactiveConfig( val writePolicy: ReactiveWritePolicy, val readPolicy: ReactiveReadPolicy, val maxIterations: Int = 100, - val enforceWriteOnMainThread: Boolean = true + val enforceWriteOnMainThread: Boolean = true, + val reactionCoroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default) ) { internal val reactionErrorHandlers: MutableSet = mutableSetOf() diff --git a/mobk-core/src/commonTest/kotlin/io/monkeypatch/mobk/core/TestReactiveContext.kt b/mobk-core/src/commonTest/kotlin/io/monkeypatch/mobk/core/TestReactiveContext.kt index ef004f4..9a34d3e 100644 --- a/mobk-core/src/commonTest/kotlin/io/monkeypatch/mobk/core/TestReactiveContext.kt +++ b/mobk-core/src/commonTest/kotlin/io/monkeypatch/mobk/core/TestReactiveContext.kt @@ -96,4 +96,63 @@ class TestReactiveContext { assertEquals(listOf(10, 11, 12, 20), xHistory) assertEquals(listOf(true, false, true), isPairHistory) } + + @Test + fun testReaction() { + val x = Observable(10) + val isPair = Computed { x.value % 2 == 0 } + + val xHistory = mutableListOf() + var pairCounter = 1 + + createAutorun { + xHistory.add(x.value) + } + + createReaction ( + trackingFn = { + isPair.value + } + ) { value -> + value?.let { + pairCounter++ + } + } + + x.value = 11 + x.value = 12 + x.value = 20 + + assertEquals(listOf(10, 11, 12, 20), xHistory) + assertEquals(3, pairCounter) + } + + @Test + fun testWhenReaction() { + val x = Observable(10) + + val xHistory = mutableListOf() + var isTwelve = 0 + + createAutorun { + xHistory.add(x.value) + } + + createWhenReaction ( + predicate = { + x.value == 12 + } + ) { + isTwelve++ + } + + x.value = 11 + x.value = 12 + x.value = 20 + x.value= 12 + x.value = 24 + + assertEquals(listOf(10, 11, 12, 20,12,24), xHistory) + assertEquals(1, isTwelve) + } } \ No newline at end of file diff --git a/mobk-swift/MobKViewModel.swift b/mobk-swift/MobKViewModel.swift new file mode 100644 index 0000000..ce6ac1e --- /dev/null +++ b/mobk-swift/MobKViewModel.swift @@ -0,0 +1,22 @@ +// +// MobKViewModel.swift +// + +import SwiftUI +import + +@propertyWrapper class VM: ObservableObject where S: Mobk_viewmodelMobkViewModel { + var wrappedValue: S + + public init(wrappedValue: S) { + self.wrappedValue = wrappedValue() + } + + deinit { + self.wrappedValue.onCleared() + } +} + +func asStateObject(_ factory: @autoclosure @escaping () -> S) -> StateObject> where S: Mobk_viewmodelMobkViewModel { + return StateObject(wrappedValue: VM(wrappedValue: factory())) +} diff --git a/mobk-viewmodel/build.gradle.kts b/mobk-viewmodel/build.gradle.kts new file mode 100644 index 0000000..e7423b1 --- /dev/null +++ b/mobk-viewmodel/build.gradle.kts @@ -0,0 +1,58 @@ +plugins { + kotlin("multiplatform") + id("com.android.library") + id("maven-publish") +} + + +group = "io.monkeypatch" +version = "0.0.11" + +@OptIn(org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi::class) +kotlin { + targetHierarchy.default() + + androidTarget { + compilations.all { + kotlinOptions { + jvmTarget = "1.8" + } + } + + publishLibraryVariants("release") + } + + listOf( + iosX64(), + iosArm64(), + iosSimulatorArm64() + ).forEach { + it.binaries.framework { + baseName = "mobk-viewmodel" + + export("dev.icerock.moko:mvvm-core:0.16.1") + } + } + + sourceSets { + val commonMain by getting { + dependencies { + api(project(":mobk-core")) + api("dev.icerock.moko:mvvm-core:0.16.1") + } + } + val commonTest by getting { + dependencies { + implementation(kotlin("test")) + } + } + } +} + +android { + namespace = "io.monkeypatch.mobk.viewmodel" + compileSdk = 33 + defaultConfig { + minSdk = 24 + } +} \ No newline at end of file diff --git a/mobk-viewmodel/src/commonMain/kotlin/io/monkeypatch/mobk/viewmodel/MobkViewModel.kt b/mobk-viewmodel/src/commonMain/kotlin/io/monkeypatch/mobk/viewmodel/MobkViewModel.kt new file mode 100644 index 0000000..73c867d --- /dev/null +++ b/mobk-viewmodel/src/commonMain/kotlin/io/monkeypatch/mobk/viewmodel/MobkViewModel.kt @@ -0,0 +1,44 @@ +package io.monkeypatch.mobk.viewmodel + +import dev.icerock.moko.mvvm.viewmodel.ViewModel +import io.monkeypatch.mobk.core.Reaction +import io.monkeypatch.mobk.core.ReactionDisposers +import io.monkeypatch.mobk.core.ReactionErrorHandler +import io.monkeypatch.mobk.core.ReactiveContext +import kotlin.time.Duration + + +interface MobkLifecycleAware { + fun reaction( + context: ReactiveContext = ReactiveContext.main, delay: Duration? = null, + equals: ((T?, T?) -> Boolean)? = null, + onError: ReactionErrorHandler? = null, + trackingFn: (Reaction) -> T, effect: (T?) -> Unit + ) +} + +abstract class MobkViewModel : ViewModel(), MobkLifecycleAware { + private val reactionDispatcher = ReactionDisposers() + + override fun reaction( + context: ReactiveContext, + delay: Duration?, + equals: ((T?, T?) -> Boolean)?, + onError: ReactionErrorHandler?, + trackingFn: (Reaction) -> T, effect: (T?) -> Unit + ) = + reactionDispatcher.add( + io.monkeypatch.mobk.api.reaction( + context = context, + delay = delay, + equals = equals, + trackingFn = trackingFn, + effect = effect + ) + ) + + override fun onCleared() { + super.onCleared() + reactionDispatcher.clear() + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 3691190..c801984 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,2 +1,3 @@ rootProject.name = "mobk" -include(":mobk-core") \ No newline at end of file +include(":mobk-core") +include(":mobk-viewmodel")