From 1d93ffb89f065f8b7cb99345899956d05b9fa204 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Wolf-Martell=20Montwe=CC=81?= Date: Thu, 11 May 2023 10:54:23 +0200 Subject: [PATCH] Add UnidirectionalViewModel a variation of MVI with side effects --- core/ui/compose/common/build.gradle.kts | 4 + .../common/mvi/UnidirectionalViewModel.kt | 104 ++++++++++++++++++ .../mvi/UnidirectionalViewModelKtTest.kt | 78 +++++++++++++ gradle/libs.versions.toml | 2 + 4 files changed, 188 insertions(+) create mode 100644 core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModel.kt create mode 100644 core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModelKtTest.kt diff --git a/core/ui/compose/common/build.gradle.kts b/core/ui/compose/common/build.gradle.kts index 20f1ad10d..d1b76a4dd 100644 --- a/core/ui/compose/common/build.gradle.kts +++ b/core/ui/compose/common/build.gradle.kts @@ -6,3 +6,7 @@ android { namespace = "app.k9mail.core.ui.compose.common" resourcePrefix = "core_ui_common_" } + +dependencies { + testImplementation(projects.core.ui.compose.testing) +} diff --git a/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModel.kt b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModel.kt new file mode 100644 index 000000000..f79840def --- /dev/null +++ b/core/ui/compose/common/src/main/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModel.kt @@ -0,0 +1,104 @@ +package app.k9mail.core.ui.compose.common.mvi + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest + +/** + * Interface for a unidirectional view model with side-effects ([EFFECT]). It has a [STATE] and can handle [EVENT]'s. + * + * @param STATE The type that represents the state of the ViewModel. For example, the UI state of a screen can be + * represented as a state. + * @param EVENT The type that represents user actions that can occur and should be handled by the ViewModel. For + * example, a button click can be represented as an event. + * @param EFFECT The type that represents side-effects that can occur in response to the state changes. For example, + * a navigation event can be represented as an effect. + */ +interface UnidirectionalViewModel { + /** + * The current [STATE] of the view model. + */ + val state: StateFlow + + /** + * The side-effects ([EFFECT]) produced by the view model. + */ + val effect: SharedFlow + + /** + * Handles an [EVENT] and updates the [STATE] of the view model. + * + * @param event The [EVENT] to handle. + */ + fun event(event: EVENT) +} + +/** + * Data class representing a state and a dispatch function, used for destructuring in [observe]. + */ +data class StateDispatch( + val state: State, + val dispatch: (EVENT) -> Unit, +) + +/** + * Composable function that observes a UnidirectionalViewModel and handles its side effects. + * + * Example usage: + * ``` + * @Composable + * fun MyScreen( + * onNavigateNext: () -> Unit, + * onNavigateBack: () -> Unit, + * viewModel: MyUnidirectionalViewModel, + * ) { + * val (state, dispatch) = viewModel.observe { effect -> + * when (effect) { + * MyEffect.OnBackPressed -> onNavigateBack() + * MyEffect.OnNextPressed -> onNavigateNext() + * } + * } + * + * MyContent( + * onNextClick = { + * dispatch(MyEvent.OnNext) + * }, + * onBackClick = { + * dispatch(MyEvent.OnBack) + * }, + * state = state.value, + * ) + * } + * ``` + * + * @param STATE The type that represents the state of the ViewModel. + * @param EVENT The type that represents user actions that can occur and should be handled by the ViewModel. + * @param EFFECT The type that represents side-effects that can occur in response to the state changes. + * + * @param handleEffect A function to handle side effects ([EFFECT]). + * + * @return A [StateDispatch] containing the state and a dispatch function. + */ +@Composable +inline fun UnidirectionalViewModel.observe( + crossinline handleEffect: (EFFECT) -> Unit, +): StateDispatch { + val collectedState = state.collectAsStateWithLifecycle() + + val dispatch: (EVENT) -> Unit = { event(it) } + + LaunchedEffect(key1 = effect) { + effect.collectLatest { + handleEffect(it) + } + } + + return StateDispatch( + state = collectedState, + dispatch = dispatch, + ) +} diff --git a/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModelKtTest.kt b/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModelKtTest.kt new file mode 100644 index 000000000..7d039b5a3 --- /dev/null +++ b/core/ui/compose/common/src/test/kotlin/app/k9mail/core/ui/compose/common/mvi/UnidirectionalViewModelKtTest.kt @@ -0,0 +1,78 @@ +package app.k9mail.core.ui.compose.common.mvi + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import app.k9mail.core.ui.compose.testing.ComposeTest +import app.k9mail.core.ui.compose.testing.MainDispatcherRule +import app.k9mail.core.ui.compose.testing.setContent +import assertk.assertThat +import assertk.assertions.isEqualTo +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class UnidirectionalViewModelKtTest : ComposeTest() { + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `observe should emit state changes, allow event dispatch and expose effects`() = runTest { + val viewModel = TestViewModel() + val effects = mutableListOf() + lateinit var stateDispatch: StateDispatch + + setContent { + stateDispatch = viewModel.observe { effect -> + effects.add(effect) + } + } + + val (state, dispatch) = stateDispatch + + // Initial state + assertThat(state.value.data).isEqualTo("TestState: Initial") + + // Dispatch an event + dispatch(TestEvent("Event 1")) + + assertThat(state.value.data).isEqualTo("TestState: Event 1") + assertThat(effects.last().result).isEqualTo("TestEffect: Event 1") + + // Dispatch another event + dispatch(TestEvent("Event 2")) + + assertThat(state.value.data).isEqualTo("TestState: Event 2") + assertThat(effects.last().result).isEqualTo("TestEffect: Event 2") + } + + private data class TestState(val data: String) + private data class TestEvent(val action: String) + private data class TestEffect(val result: String) + + private class TestViewModel( + initialState: TestState = TestState("TestState: Initial"), + ) : ViewModel(), UnidirectionalViewModel { + + private val _state = MutableStateFlow(initialState) + override val state: StateFlow = _state.asStateFlow() + + private val _effect = MutableSharedFlow() + override val effect: SharedFlow = _effect.asSharedFlow() + + override fun event(event: TestEvent) { + _state.update { it.copy(data = "TestState: ${event.action}") } + viewModelScope.launch { + _effect.emit(TestEffect(result = "TestEffect: ${event.action}")) + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 59d7f59bc..0cf3bd989 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -87,6 +87,7 @@ androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling androidx-compose-ui-test-manifest = { module = "androidx.compose.ui:ui-test-manifest" } androidx-compose-ui-test-junit4 = { module = "androidx.compose.ui:ui-test-junit4" } androidx-compose-activity = { module = "androidx.activity:activity-compose", version.ref = "androidxActivity" } +androidx-compose-lifecycle-runtime = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidxLifecycle" } androidx-compose-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" } androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidxComposeMaterial" } androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended", version.ref = "androidxComposeMaterial" } @@ -166,6 +167,7 @@ shared-jvm-android-app = [ shared-jvm-android-compose = [ "androidx-compose-foundation", "androidx-compose-ui-tooling-preview", + "androidx-compose-lifecycle-runtime", "androidx-compose-lifecycle-viewmodel", "androidx-compose-navigation", ]