Add UnidirectionalViewModel a variation of MVI with side effects
This commit is contained in:
parent
5f0a6437cf
commit
1d93ffb89f
4 changed files with 188 additions and 0 deletions
|
@ -6,3 +6,7 @@ android {
|
|||
namespace = "app.k9mail.core.ui.compose.common"
|
||||
resourcePrefix = "core_ui_common_"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation(projects.core.ui.compose.testing)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
]
|
||||
|
|
Loading…
Reference in a new issue