Skip to content

Instantly share code, notes, and snippets.

@vitalikas
Last active July 4, 2025 12:34
Show Gist options
  • Select an option

  • Save vitalikas/7db9ee5371b3219bd5766d4109bd95ec to your computer and use it in GitHub Desktop.

Select an option

Save vitalikas/7db9ee5371b3219bd5766d4109bd95ec to your computer and use it in GitHub Desktop.
generic MVI viewmodel
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