Last active
July 4, 2025 12:34
-
-
Save vitalikas/7db9ee5371b3219bd5766d4109bd95ec to your computer and use it in GitHub Desktop.
generic MVI viewmodel
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| abstract class MVIViewModel<STATE, EVENT, EFFECT>( | |
| protected val savedStateHandle: SavedStateHandle? = null, | |
| protected val effectDelegate: AutoConsumableEffect<EFFECT>, | |
| ) : ViewModel(), AutoConsumableEffect<EFFECT> by effectDelegate { | |
| abstract val initialState: STATE | |
| private val _uiState by lazy { MutableStateFlow(initialState) } | |
| val uiState = _uiState.asStateFlow() | |
| fun updateUiState( | |
| updateAction: (currentState: STATE) -> STATE // or updateAction: STATE.() -> STATE as param | |
| ) { | |
| _uiState.update(updateAction) | |
| } | |
| abstract fun handleEvent(event: EVENT) | |
| } | |
| interface AutoConsumableEffect<EFFECT> { | |
| val effect: StateFlow<EFFECT?> | |
| fun postEffect(newEffect: EFFECT) | |
| fun consumeEffect() | |
| } | |
| class AutoConsumableEffectDelegate<EFFECT> : AutoConsumableEffect<EFFECT> { | |
| private val _effect = MutableStateFlow<EFFECT?>(null) | |
| override val effect: StateFlow<EFFECT?> | |
| get() = _effect.asStateFlow() | |
| override fun consumeEffect() { | |
| _effect.update { null } | |
| } | |
| override fun postEffect(newEffect: EFFECT) { | |
| _effect.update { newEffect } | |
| } | |
| } | |
| @Composable | |
| fun <EFFECT, VM: AutoConsumableEffect<EFFECT>> GenericAutoConsumingEffectHandler( | |
| viewModel: VM, | |
| onEffect: suspend (effect: EFFECT) -> Unit, | |
| ) { | |
| val lifecycleOwner = LocalLifecycleOwner.current | |
| val currentEffect by viewModel.effect.collectAsStateWithLifecycle(null) | |
| LaunchedEffect(currentEffect, lifecycleOwner) { | |
| currentEffect?.let { | |
| onEffect(it) | |
| viewModel.consumeEffect() | |
| } | |
| } | |
| } | |
| sealed interface MyScreenEvent { | |
| data object Clicked : MyScreenEvent | |
| data object Changed : MyScreenEvent | |
| } | |
| interface MyScreenEffect { | |
| data class ShowError(val message: String) : MyScreenEffect | |
| data object NavigateToScreen : MyScreenEffect | |
| } | |
| data class MyScreenUiState( | |
| val isLoading: Boolean = false, | |
| ) | |
| interface MyRepository { | |
| suspend fun makeSomething(): MyResult | |
| } | |
| sealed interface MyResult { | |
| data object Success : MyResult | |
| data class Error( | |
| val message: String? = null, | |
| val specificErrorCode: Int? = null | |
| ) : MyResult | |
| } | |
| class MyViewModel( | |
| private val myRepository: MyRepository, | |
| myEffectDelegate: AutoConsumableEffect<MyScreenEffect>, | |
| ) : MVIViewModel<MyScreenUiState, MyScreenEvent, MyScreenEffect>( | |
| effectDelegate = myEffectDelegate | |
| ) { | |
| override val initialState: MyScreenUiState | |
| get() = MyScreenUiState() | |
| override fun handleEvent(event: MyEvent) { | |
| when (event) { | |
| MyScreenEvent.Clicked -> proceedEvent() | |
| MyScreenEvent.Changed -> { //TODO: Handle this case } | |
| } | |
| } | |
| private fun proceedEvent() { | |
| viewModelScope.launch { | |
| updateUiState { | |
| it.copy(isLoading = true) | |
| } | |
| val result = myRepository.makeSomething() // suspend function | |
| when (result) { | |
| MyResult.Success -> effectDelegate.postEffect(newEffect = MyScreenEffect.NavigateToScreen) | |
| MyResult.Error -> effectDelegate.postEffect(newEffect = MyScreenEffect.ShowError(message = "An error occurred")) | |
| } | |
| updateUiState { | |
| it.copy(isLoading = false) | |
| } | |
| } | |
| } | |
| } | |
| @Composable | |
| fun MyScreenRoot( | |
| viewModel: MyViewModel = koinViewModel(), | |
| onNavigateToScreen: () -> Unit, | |
| ) { | |
| val context = LocalContext.current | |
| val uiState by viewModel.uiState.collectAsStateWithLifecycle() | |
| GenericAutoConsumingEffectHandler( | |
| viewModel = viewModel, | |
| onEffect = { effect -> | |
| when (effect) { | |
| MyScreenEffect.NavigateToScreen -> onNavigateToScreen() | |
| is MyScreenEffect.ShowError -> showError(context = context, effect = effect) | |
| } | |
| } | |
| ) | |
| MyScreen( | |
| uiState = uiState, | |
| onProcessEvent = viewModel::handleEvent | |
| ) | |
| } | |
| @Composable | |
| private fun MyScreen( | |
| uiState: MyScreenUiState, | |
| onProcessEvent: (event: MyScreenEvent) -> Unit, | |
| ) { | |
| Scaffold( | |
| topBar = { | |
| TopAppBar( | |
| title = { Text("My screen") }, | |
| colors = TopAppBarDefaults.topAppBarColors( | |
| containerColor = MaterialTheme.colorScheme.primaryContainer, | |
| titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer | |
| ) | |
| ) | |
| } | |
| ) { paddingValues -> | |
| Column( | |
| modifier = Modifier | |
| .fillMaxSize() | |
| .padding(paddingValues) | |
| .padding(16.dp), | |
| horizontalAlignment = Alignment.CenterHorizontally, | |
| verticalArrangement = Arrangement.Center | |
| ) { | |
| if (uiState.isLoading) { | |
| CircularProgressIndicator( | |
| modifier = Modifier.padding(bottom = 16.dp) | |
| ) | |
| Text("In progress...") | |
| } else { | |
| Button( | |
| onClick = { onProcessEvent(MyScreeEvent.SomeEvent) } | |
| ) { | |
| Text("Click me") | |
| } | |
| } | |
| } | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment