Skip to content

Commit

Permalink
Improved Compose APIs
Browse files Browse the repository at this point in the history
  • Loading branch information
wkornewald committed Oct 31, 2024
1 parent aee5e74 commit 23508e6
Show file tree
Hide file tree
Showing 11 changed files with 219 additions and 95 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## 5.10.0

* Improved experimental Jetpack Compose APIs (`ReactiveViewModel`, `by reactiveViewModel`).
* Support for Compose APIs on iOS and JS targets.

## 5.9.0

* Added `JvmSerializable`. Deprecated `Serializable` which is now an alias for `JvmSerializable`.
Expand Down
28 changes: 20 additions & 8 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ buildscript {
}

plugins {
id 'com.android.application' version '8.4.2' apply false
id 'com.android.application' version '8.6.1' apply false
id 'org.jetbrains.kotlin.android' version "$kotlin_version" apply false
id "org.jetbrains.compose" version "$composeCompilerVersion" apply false
id "org.jetbrains.kotlin.plugin.compose" version "$kotlin_version" apply false
id "org.jetbrains.dokka" version "1.9.20"
id 'pl.allegro.tech.build.axion-release' version '1.17.2'
id 'pl.allegro.tech.build.axion-release' version '1.18.13'
id 'com.github.ben-manes.versions' version '0.51.0'
id "io.github.gradle-nexus.publish-plugin" version "2.0.0"
}
Expand Down Expand Up @@ -67,17 +67,25 @@ subprojects {
)

kotlin {
applyDefaultHierarchyTemplate()

jvm()
testAll.dependsOn "jvmTest"
sourceSets {
composeMain { dependsOn(commonMain) }
composeTest { dependsOn(commonTest) }
nonJvmMain { dependsOn(commonMain) }
nonJvmTest { dependsOn(commonTest) }
nativeMain { dependsOn(nonJvmMain) }
nativeTest { dependsOn(nonJvmTest) }
iosMain { dependsOn(composeMain) }
iosTest { dependsOn(composeTest) }
macosMain { dependsOn(composeMain) }
macosTest { dependsOn(composeTest) }
wasmJsMain { dependsOn(nonJvmMain) }
wasmJsTest { dependsOn(nonJvmTest) }
jvmCommonMain { dependsOn(commonMain) }
jvmCommonTest { dependsOn(commonTest) }
jvmCommonMain { dependsOn(composeMain) }
jvmCommonTest { dependsOn(composeTest) }
jvmMain { dependsOn(jvmCommonMain) }
jvmTest { dependsOn(jvmCommonTest) }
}
Expand All @@ -104,17 +112,21 @@ subprojects {
}
// testAll.dependsOn "jsIrNodeTest"
sourceSets {
jsMain { dependsOn(nonJvmMain) }
jsTest { dependsOn(nonJvmTest) }
jsMain {
dependsOn(nonJvmMain)
dependsOn(composeMain)
}
jsTest {
dependsOn(nonJvmTest)
dependsOn(composeTest)
}
}

wasmJs {
browser()
nodejs()
}

applyDefaultHierarchyTemplate()

iosArm64()
iosX64()
iosSimulatorArm64()
Expand Down
10 changes: 6 additions & 4 deletions dependencies.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ dependencies {

activityVersion = "1.8.2"
fragmentVersion = "1.5.7"
lifecycleVersion = "2.8.0"
lifecycleVersion = "2.8.3"
androidBase = {
androidMainApi "androidx.annotation:annotation:1.6.0"
androidMainApi "androidx.appcompat:appcompat:1.6.1"
Expand All @@ -74,15 +74,17 @@ dependencies {
}

jetpackCompose = {
api platform('androidx.compose:compose-bom:2024.08.00')
composeCompiler {
enableStrongSkippingMode = true
}

androidMainApi "androidx.activity:activity-compose:$activityVersion"
androidMainApi "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion"
implementation platform('androidx.compose:compose-bom:2024.08.00')

commonMainApi compose.runtime
composeMainApi compose.foundation
composeMainApi compose.runtime
composeMainApi compose.ui
composeMainApi "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion"
}
}
}
2 changes: 2 additions & 0 deletions gradle.properties
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,5 @@ org.gradle.caching=true
kotlin.compiler.preciseCompilationResultsBackup=true

android.nonTransitiveRClass=true
org.jetbrains.compose.experimental.jscanvas.enabled=true
org.jetbrains.compose.experimental.macos.enabled=true
2 changes: 1 addition & 1 deletion reactivestate-compose/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@ apply plugin: "org.jetbrains.kotlin.plugin.compose"
dependencies {
jetpackCompose()

commonMainApi project(":reactivestate")
commonMainApi project(":reactivestate-core")
jvmTestImplementation project(":reactivestate-test")
}

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,9 @@ import androidx.compose.runtime.Composable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ensody.reactivestate.ExperimentalReactiveStateApi
import com.ensody.reactivestate.ReactiveState
import com.ensody.reactivestate.compose.ReactiveStateBuildContext
import com.ensody.reactivestate.compose.reactiveState
import kotlinx.coroutines.CoroutineScope

/**
* Returns an existing [ViewModel] or creates a new one in the given owner (usually, a fragment or
Expand Down Expand Up @@ -46,29 +41,4 @@ public inline fun <reified VM : ViewModel> viewModel(
},
)

/**
* Creates an object living on a wrapper [ViewModel]. This allows for building multiplatform ViewModels.
*
* The [provider] should instantiate the object directly.
*
* @see [reactiveState] if you want to instantiate a multiplatform [ReactiveState] ViewModel directly.
*/
@ExperimentalReactiveStateApi
@Composable
public inline fun <reified T : Any?> onViewModel(
viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
},
key: String? = null,
crossinline provider: ReactiveStateBuildContext.() -> T,
): T {
val fullKey = (key ?: "") + ":onViewModel:${T::class.qualifiedName}"
return viewModel(viewModelStoreOwner = viewModelStoreOwner, key = fullKey) {
WrapperViewModel { scope -> ReactiveStateBuildContext(scope).provider() }
}.value
}

/** A wrapper ViewModel used to hold an arbitrary [value]. */
public class WrapperViewModel<T : Any?>(provider: (CoroutineScope) -> T) : ViewModel() {
public val value: T = provider(viewModelScope)
}
public typealias WrapperViewModel<T> = com.ensody.reactivestate.compose.WrapperViewModel<T>
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package com.ensody.reactivestate.compose

import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshots.SnapshotStateMap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelStoreOwner
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner
import androidx.lifecycle.viewmodel.compose.viewModel
import com.ensody.reactivestate.ErrorEvents
import com.ensody.reactivestate.ExperimentalReactiveStateApi
import com.ensody.reactivestate.InMemoryStateFlowStore
import com.ensody.reactivestate.ReactiveState
import com.ensody.reactivestate.ReactiveStateContext
import com.ensody.reactivestate.ReactiveViewModel
import com.ensody.reactivestate.ReactiveViewModelContext
import com.ensody.reactivestate.handleEvents
import kotlinx.coroutines.CoroutineScope

/**
* Creates a multiplatform [ReactiveState] ViewModel and observes its [ReactiveState.eventNotifier].
*
* The [provider] should instantiate the object directly.
*/
@ExperimentalReactiveStateApi
@Suppress("UNCHECKED_CAST")
@Composable
public inline fun <reified VM : ReactiveViewModel> ErrorEvents.reactiveViewModel(
key: String? = null,
crossinline observeLoadingEffect: @Composable (viewModel: VM) -> Unit,
crossinline provider: ReactiveViewModelContext.() -> VM,
): VM =
reactiveState(key = key, observeLoadingEffect = observeLoadingEffect) {
ReactiveViewModelContext(this).provider()
}

/**
* Creates a multiplatform [ReactiveState] ViewModel and observes its [ReactiveState.eventNotifier].
*
* The [provider] should instantiate the object directly.
*/
@ExperimentalReactiveStateApi
@Suppress("UNCHECKED_CAST")
@Composable
public inline fun <reified E : ErrorEvents, reified VM : ReactiveState<E>> E.reactiveState(
key: String? = null,
crossinline observeLoadingEffect: @Composable (viewModel: VM) -> Unit,
crossinline provider: ReactiveStateContext.() -> VM,
): VM {
val viewModel = onViewModel(key = key, provider = provider)
observeLoadingEffect(viewModel)
LaunchedEffect(this, viewModel.eventNotifier) {
viewModel.eventNotifier.handleEvents(this@reactiveState)
}
return viewModel
}

/**
* Creates an object living on a wrapper [ViewModel]. This allows for building more flexible (e.g. nestable) ViewModels.
*
* The [provider] should instantiate the object directly.
*
* @see [reactiveState] if you want to instantiate a multiplatform [ReactiveState] ViewModel directly.
*/
@ExperimentalReactiveStateApi
@Composable
public inline fun <reified T : Any?> onViewModel(
viewModelStoreOwner: ViewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
},
key: String? = null,
crossinline provider: ReactiveStateContext.() -> T,
): T {
// TODO: Use qualifiedName once JS supports it
val fullKey = (key ?: "") + ":onViewModel:${T::class.simpleName}"
val storage = rememberSaveable<SnapshotStateMap<String, Any?>> { mutableStateMapOf() }
val stateFlowStore = remember { InMemoryStateFlowStore(storage) }
return viewModel(viewModelStoreOwner = viewModelStoreOwner, key = fullKey) {
WrapperViewModel { scope -> ReactiveStateContext(scope, stateFlowStore).provider() }
}.value
}

/** A wrapper ViewModel used to hold an arbitrary [value]. */
public class WrapperViewModel<T : Any?>(provider: (CoroutineScope) -> T) : ViewModel() {
public val value: T = provider(viewModelScope)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.ensody.reactivestate

/**
* Simple synchronous one-time event. Once [trigger] is called, subsequent [observe] calls are triggered immediately.
*
* This is not a stream of multiple events. That's what `Channel` and `Flow` would be used for, instead.
* This kind of event can be activated exactly once and then it stays active forever.
*/
public interface OneTimeEvent<T> {
public fun observe(block: T.() -> Unit)
public fun unobserve(block: T.() -> Unit)
public fun trigger(source: T)
}

public class DefaultOneTimeEvent<T> : OneTimeEvent<T> {
private var source: T? = null
private val observers: MutableList<T.() -> Unit> = mutableListOf()

override fun observe(block: T.() -> Unit) {
source?.block() ?: observers.add(block)
}

override fun unobserve(block: T.() -> Unit) {
observers.remove(block)
}

override fun trigger(source: T) {
if (this.source == null) {
this.source = source
}

observers.forEach {
it.invoke(source)
}
observers.clear()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.ensody.reactivestate

import kotlinx.coroutines.CoroutineScope

/**
* Contains all values which are part of the
*/
@ExperimentalReactiveStateApi
public interface ReactiveStateContext {
public val scope: CoroutineScope
public val stateFlowStore: StateFlowStore
}

@ExperimentalReactiveStateApi
public fun ReactiveStateContext(scope: CoroutineScope, stateFlowStore: StateFlowStore): ReactiveStateContext =
DefaultReactiveStateContext(scope, stateFlowStore)

private class DefaultReactiveStateContext(
override val scope: CoroutineScope,
override val stateFlowStore: StateFlowStore,
) : ReactiveStateContext
Loading

0 comments on commit 23508e6

Please sign in to comment.