Add UnidirectionalViewModel a variation of MVI with side effects

This commit is contained in:
Wolf-Martell Montwé 2023-05-11 10:54:23 +02:00
parent 5f0a6437cf
commit 1d93ffb89f
No known key found for this signature in database
GPG key ID: 6D45B21512ACBF72
4 changed files with 188 additions and 0 deletions

View file

@ -6,3 +6,7 @@ android {
namespace = "app.k9mail.core.ui.compose.common"
resourcePrefix = "core_ui_common_"
}
dependencies {
testImplementation(projects.core.ui.compose.testing)
}

View file

@ -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<STATE, EVENT, EFFECT> {
/**
* The current [STATE] of the view model.
*/
val state: StateFlow<STATE>
/**
* The side-effects ([EFFECT]) produced by the view model.
*/
val effect: SharedFlow<EFFECT>
/**
* 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<STATE, EVENT>(
val state: 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<MyState, MyEvent, MyEffect>,
* ) {
* 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 <reified STATE, EVENT, EFFECT> UnidirectionalViewModel<STATE, EVENT, EFFECT>.observe(
crossinline handleEffect: (EFFECT) -> Unit,
): StateDispatch<STATE, EVENT> {
val collectedState = state.collectAsStateWithLifecycle()
val dispatch: (EVENT) -> Unit = { event(it) }
LaunchedEffect(key1 = effect) {
effect.collectLatest {
handleEffect(it)
}
}
return StateDispatch(
state = collectedState,
dispatch = dispatch,
)
}

View file

@ -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<TestEffect>()
lateinit var stateDispatch: StateDispatch<TestState, TestEvent>
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<TestState, TestEvent, TestEffect> {
private val _state = MutableStateFlow(initialState)
override val state: StateFlow<TestState> = _state.asStateFlow()
private val _effect = MutableSharedFlow<TestEffect>()
override val effect: SharedFlow<TestEffect> = _effect.asSharedFlow()
override fun event(event: TestEvent) {
_state.update { it.copy(data = "TestState: ${event.action}") }
viewModelScope.launch {
_effect.emit(TestEffect(result = "TestEffect: ${event.action}"))
}
}
}
}

View file

@ -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",
]