
Simplifies building Model-View-Intent architecture by managing ViewModel states, events, and effects. Supports state restoration, event handling, and integrates seamlessly with Compose UI components.
[[TOC]]
@Parcelize
data class ChoiceViewState(
val randomNumber: Int
) : ViewState, Parcelable
sealed interface ChoiceViewEvent : ViewEvent {
data class OnShowDetailsClicked(val id: String) : ChoiceViewEvent
object OnDrawNumberClicked : ChoiceViewEvent
object OnShowToastClicked : ChoiceViewEvent
}
sealed interface ChoiceNavigationEffect : NavigationEffect {
data class NavigateToDetails(val id: String) : ChoiceNavigationEffect
}
sealed interface ChoiceViewEffect : ViewEffect {
data class ShowToast(val message: String) : ChoiceViewEffect
}class ChoiceViewModel(
savedStateHandle: SavedStateHandle,
) : BaseMviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect>(
ChoiceViewState(0), savedStateHandle
) {
override fun onEvent(event: ChoiceViewEvent) {
when (event) {
is OnShowDetailsClicked -> {
dispatchNavigationEffect(NavigateToDetails(event.id))
}
OnDrawNumberClicked -> {
viewState = viewState.copy(
randomNumber = Random.nextInt(0, 100)
)
}
OnShowToastClicked -> {
dispatchViewEffect(ChoiceViewEffect.ShowToast("Toast message!"))
}
}
}
}@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = koinViewModel<ChoiceViewModel>(),
onNavigateToDetails: (String) -> Unit
) {
NavigationEffect(viewModel) { effect ->
when (effect) {
is NavigateToDetails -> {
onNavigateToDetails(effect.id)
}
}
}
ViewEffect(viewModel) { effect ->
when (effect) {
is ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
}
// Option A
ViewState(viewModel) {
Text(
text = "${viewState.randomNumber}",
style = TextStyle.Default.copy(
fontSize = 72.sp
)
)
}
// Option B (less indents!)
val viewState by viewState(viewModel)
Text(
text = "${viewState.randomNumber}",
style = TextStyle.Default.copy(
fontSize = 72.sp
)
)
}@Preview
@Preview(showSystemUi = true, heightDp = 800)
@Composable
fun ChoiceScreenPreview() {
ChoiceScreen(
viewModel = ChoiceViewState(randomNumber = 16).toViewModel(), // uses TestMviViewModel under the hood
onNavigateToDetails = {}
)
}ViewState marker interface.ViewEvent marker interface.NavigationEffect marker interface.ViewEffect marker interface.@Parcelize
data class ChoiceViewState(
val randomNumber: Int
) : ViewState, Parcelable
sealed interface ChoiceViewEvent : ViewEvent {
data class OnShowDetailsClicked(val id: String) : ChoiceViewEvent
object OnDrawNumberClicked : ChoiceViewEvent
object OnShowToastClicked : ChoiceViewEvent
}
sealed interface ChoiceNavigationEffect : NavigationEffect {
data class NavigateToDetails(val id: String) : ChoiceNavigationEffect
}
sealed interface ChoiceViewEffect : ViewEffect {
data class ShowToast(val message: String) : ChoiceViewEffect
}ℹ Your view state doesn't have to implement
Parcelableinterface if you don't intend to store the view state inSavedStateHandle.
ℹ You don't have to define empty classes, e.g. if you don't need
view effectsimply don't define it.
ℹ
ViewState,ViewEvent,NavigationEffect,ViewEffectare just marker interfaces. Theoretically we would be just fine without them but:
- They open possibility to create extensions methods which are scoped to the particular parts of MVI contract:
- Absolutely life-saver when dealing with
@Preview@Preview(showSystemUi = true, heightDp = 800) @Composable fun ChoiceScreenPreview() { ChoiceScreen( viewModel = ChoiceViewState(randomNumber = 16).toViewModel(), // uses TestMviViewModel under the hood onNavigateToDetails = {} ) }- Very useful when creating instrumented tests when view-model is required by Fragment/Composable.
- They prevent various common mistakes like when the generic parameters are specified in a wrong order while defining view-model.
The only requirement is to implement MviViewModel interface, but 99% of the time it would be easier to inherit from BaseMviViewModel:
class ChoiceViewModel(
savedStateHandle: SavedStateHandle,
) : BaseMviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect>(
ChoiceViewState(0), savedStateHandle
) {
override fun onEvent(event: ChoiceViewEvent) {
}
}
// or
class ChoiceViewModel : BaseMviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect>() {
override val viewStates = streamData()
.map { data -> ChoiceViewState(data) }
.toViewStates(ChoiceViewState(0), viewModelScope)
override fun onEvent(event: ChoiceViewEvent) {
}
}The are two constructors available:
initialViewState - in the MVI (and Compose) the initial state of the view is always required.init block (e.g. using launch(start = CoroutineStart.UNDISPATCHED) {})savedStateHandle (optional) - if you pass SavedStateHandle to the BaseMviViewModel it will automatically save/restore the view state when activity is killed/recreated.val viewStates: StateFlow property.
If you don't do this an exception will be thrown on runtime when the viewStates is accessed for the first time.Extending BaseMviViewModel is not mandatory - it is enough to implement the MviViewModel. This is very useful if you don't want to manage the ViewState "manually" inside the view-model,
but rather fetch it from domain.
class ChoiceViewModel(streamData: StreamUserChoiceDataUseCase) : MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect>, ViewModel() {
override val viewStates: StateFlow<ChoiceViewState> = streamData()
.map { data -> ChoiceViewState(data) }
.toViewStates(ChoiceViewState(0), viewModelScope)
override val viewEffects: StateFlow<ConsumableEvent<ChoiceViewEffect>?> = MutableStateFlow<ConsumableEvent<ChoiceViewEffect>?>(null)
override val navigationEffects: StateFlow<ConsumableEvent<ChoiceNavigationEffect>?> = MutableStateFlow<ConsumableEvent<ChoiceNavigationEffect>?>(null)
override fun onEvent(event: ChoiceViewEvent) { }
}💡 There are 3 extension methods provided that can convert external flows to corresponding
StateFlow-sof theMviViewModelinterface:
toViewStates(initialState: T, scope: CoroutineScope),toViewEffects(scope: CoroutineScope),toNavigationEffects(scope: CoroutineScope).
Special caution is required while defining and assigning fields at the same time, e.g.
override val viewEffects: StateFlow<ConsumableEvent<ChoiceViewEffect>?> = MutableStateFlow<ConsumableEvent<ChoiceViewEffect>?>(null)⚠ You should explicitly type all MVI fields to StateFlow<...> - otherwise you will expose the assigned object (usually MutableStateFlow).
Alternatively you may use backing fields to avoid this issue:
private val _viewEffects = MutableStateFlow<ConsumableEvent<ChoiceViewEffect>?>(null)
override val viewEffects: StateFlow<ConsumableEvent<ChoiceViewEffect>?> = _viewEffectNote: you will be still able to mutate the view state even if you choose the first option (defining and assigning at the same time) - the dispatchViewEffect, dispatchNavigationEffect and val viewState
extension methods check the real type of the flow and perform necessary casting if required.
If you extend BaseMviViewModel OR your custom view-model assigns MutableStateFlow to val viewStates: StateFlow<VS> you may use viewState extension method to update the view state:
class ChoiceViewModel ... {
...
private fun drawNumber() {
viewState = viewState.copy(
randomNumber = Random.nextInt(0, 100)
)
}
...
}If you extend BaseMviViewModel OR your custom view-model assigns MutableStateFlow to val viewEffects: StateFlow<ConsumableEvent<VE>?> you may use dispatchViewEffect extension method:
class ChoiceViewModel ... {
...
private fun showToast() {
dispatchViewEffect(ChoiceViewEffect.ShowToast("Toast message!"))
}
...
}⚠ Most of the time you should not use view effects at all - even displaying the
AlertDialogsshould generally be managed with the view state.
If you extend BaseMviViewModel OR your custom view-model assigns MutableStateFlow to val navigationEffects: StateFlow<ConsumableEvent<NE>?> you may use dispatchNavigationEffect extension method:
class ChoiceViewModel ... {
...
private fun navigateToCardDetails() {
dispatchNavigationEffect(NavigateToDetails(event.id))
}
...
}@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = viewModel<ChoiceViewModel>(),
)⚠ Generally you should type the
viewModelparameter to theMviViewModel<...>interface. Otherwise providing preview may be very hard especially if your view-model has many dependencies (e.g. tons of use cases).If you really don't intend to use preview you may simplify the view-model injection (still not recommended as makes testing harder):
@Composable fun ChoiceScreen( viewModel: ChoiceViewModel = viewModel(), )
You may provide parameters to view-models during injection:
class DetailsViewModelFactory(private val id: String) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T = DetailsViewModel(id) as T
}
@Composable
fun DetailsScreen(
id: String,
viewModel: MviViewModel<DetailsViewState, ViewEvent, NavigationEffect, ViewEffect> = viewModel(factory = DetailsViewModelFactory(id)),
) {
}First you need to declare view-models in Koin module.
val appUiModule = module {
viewModelOf(::ChoiceViewModel)
viewModel { parameters -> DetailsViewModel(parameters.get()) } // view-model with parameter
}The injection is straightforward.
// view-model without parameters (beside SavedStateHandle)
@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = koinViewModel<ChoiceViewModel>(),
onNavigateToDetails: (String) -> Unit
)
// view-model with parameter
@Composable
fun DetailsScreen(
id: String,
viewModel: MviViewModel<DetailsViewState, ViewEvent, NavigationEffect, ViewEffect> = koinViewModel<DetailsViewModel> { parametersOf(id) }
)There are two equivalent methods of handling view state changes.
First option is to use ViewState composable. Inside the passed content lambda there is a viewState property available which holds the latest view state.
@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = koinViewModel<ChoiceViewModel>(),
onNavigateToDetails: (String) -> Unit
) {
ViewState(viewModel) {
Text(
text = "${viewState.randomNumber}",
style = TextStyle.Default.copy(
fontSize = 72.sp
)
)
}
...
}ℹ Although the
viewStatefield is typed to<VS : ViewState>it is a delegate pointing to a<State<VS : ViewState>>under the hood. Thanks to this the whole view is not recomposed when theviewStatechanges - only the parts reading fromviewStateare recomposed.
The second option is to use by viewState(viewModel) delegation:
@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = koinViewModel<ChoiceViewModel>(),
onNavigateToDetails: (String) -> Unit
) {
val viewState by viewState(viewModel)
Text(
text = "${viewState.randomNumber}",
style = TextStyle.Default.copy(
fontSize = 72.sp
)
)
}ℹ Although the
viewStatevariable is typed to<VS : ViewState>it is a delegate pointing to a<State<VS : ViewState>>under the hood. Thanks to this the whole view is not recomposed when theviewStatechanges - only the parts reading fromviewStateare recomposed.
Within the scope of ViewState composable you should always use the dispatchViewEvent method, which protects against sending events from the
view to the ViewModel when the view is not in the required state. By default, the minimum view state required for propagating view events
is set to Lifecycle.State.RESUMED. View events are ignored if the view is visible but paused.
To modify this behaviour, you can specify mininum view state for view events propagation. For example, if you pass Lifecycle.State.STARTED as minActiveStateForViewEventsPropagation, view events will be disregarded as long as the screen is not visible.
@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = koinViewModel<ChoiceViewModel>(),
onNavigateToDetails: (String) -> Unit
) {
...
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { dispatchViewEvent(OnShowToastClicked) }
) {
Text(text = "Show toast")
}
...
}By default both the ViewState and by viewState(viewModel) subscribes to the viewModel.viewStates flow when view enters Lifecycle.State.STARTED
state and cancels the subscription when view is stopped.
In some rare scenarios you may want to keep the subscription alive even if the view is not visible (not in Lifecycle.State.STARTED state),
especially if the viewModel.viewStates is backed by a cold flow that should keep working even if app is in background.
You may specify the minimum lifecycle state at which the subscription is active using minActiveState param:
ViewState(
viewModel = viewModel,
minActiveState = Lifecycle.State.CREATED
) { effect ->
}val viewState by viewState(viewModel = viewModel, minActiveState = Lifecycle.State.CREATED)ℹ Although using
Lifecycle.State.CREATEDasminActiveStatekeeps the subscription active the Compose suspends recomposition when view is stopped. Thanks to this the app is not wasting resources on invisible updates while the view model may keep its work.
Use NavigationEffect composable method to handle navigation effects.
@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = koinViewModel<ChoiceViewModel>(),
onNavigateToDetails: (String) -> Unit
) {
NavigationEffect(viewModel) { effect ->
when (effect) {
is NavigateToDetails -> {
onNavigateToDetails(effect.id)
}
}
}
...
}ℹ Each navigation effect is provided only once. It won't be repeated even if the view is recreated.
By default the NavigationEffect handles the navigation effects only if the view is at least in the Lifecycle.State.RESUMED state.
Navigation effects are ignored if the view is visible but paused. The last navigation effect dispatched by the view model is handled
once the view is resumed.
To modify this behaviour, you can specify the minimum required view state. For example, if you pass Lifecycle.State.STARTED, all navigation effects will be ignored while the current screen is not visible. When the user returns, only the last navigation effect triggered by the view-model during that time will be processed.
NavigationEffect(
viewModel = viewModel,
minActiveState = Lifecycle.State.STARTED
) { effect ->
}⚠ In Compose it is impossible to handle navigation effects when the view is not started even if you pass
Lifecycle.State.CREATEDas minimum view state. All the recompositions are suspended when the view is in stopped.
Use ViewEffect composable method to handle view effects.
@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = koinViewModel<ChoiceViewModel>(),
onNavigateToDetails: (String) -> Unit
) {
ViewEffect(viewModel) { effect ->
when (effect) {
is ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
}
...
}ℹ Each view effect is provided only once. It won't be repeated even if the view is recreated.
By default the ViewEffect handles the view effects only if the view is in the Lifecycle.State.RESUMED state.
View effects are ignored if the view is visible but paused. The last view effect dispatched by the view model is handled
once the view is resumed.
To modify this behaviour, you can specify the minimum required view state. For example, if you pass Lifecycle.State.STARTED, all view effects will be ignored while the current screen is not visible. When the user returns, only the last view effect triggered by the view-model during that time will be processed.
ViewEffect(
viewModel = viewModel,
minActiveState = Lifecycle.State.STARTED
) { effect ->
}⚠ In Compose it is impossible to handle view effects when the view is not started even if you pass
Lifecycle.State.CREATEDas minimum view state. All the recompositions are suspended when the view is in stopped.
[[TOC]]
@Parcelize
data class ChoiceViewState(
val randomNumber: Int
) : ViewState, Parcelable
sealed interface ChoiceViewEvent : ViewEvent {
data class OnShowDetailsClicked(val id: String) : ChoiceViewEvent
object OnDrawNumberClicked : ChoiceViewEvent
object OnShowToastClicked : ChoiceViewEvent
}
sealed interface ChoiceNavigationEffect : NavigationEffect {
data class NavigateToDetails(val id: String) : ChoiceNavigationEffect
}
sealed interface ChoiceViewEffect : ViewEffect {
data class ShowToast(val message: String) : ChoiceViewEffect
}class ChoiceViewModel(
savedStateHandle: SavedStateHandle,
) : BaseMviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect>(
ChoiceViewState(0), savedStateHandle
) {
override fun onEvent(event: ChoiceViewEvent) {
when (event) {
is OnShowDetailsClicked -> {
dispatchNavigationEffect(NavigateToDetails(event.id))
}
OnDrawNumberClicked -> {
viewState = viewState.copy(
randomNumber = Random.nextInt(0, 100)
)
}
OnShowToastClicked -> {
dispatchViewEffect(ChoiceViewEffect.ShowToast("Toast message!"))
}
}
}
}@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = koinViewModel<ChoiceViewModel>(),
onNavigateToDetails: (String) -> Unit
) {
NavigationEffect(viewModel) { effect ->
when (effect) {
is NavigateToDetails -> {
onNavigateToDetails(effect.id)
}
}
}
ViewEffect(viewModel) { effect ->
when (effect) {
is ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
}
// Option A
ViewState(viewModel) {
Text(
text = "${viewState.randomNumber}",
style = TextStyle.Default.copy(
fontSize = 72.sp
)
)
}
// Option B (less indents!)
val viewState by viewState(viewModel)
Text(
text = "${viewState.randomNumber}",
style = TextStyle.Default.copy(
fontSize = 72.sp
)
)
}@Preview
@Preview(showSystemUi = true, heightDp = 800)
@Composable
fun ChoiceScreenPreview() {
ChoiceScreen(
viewModel = ChoiceViewState(randomNumber = 16).toViewModel(), // uses TestMviViewModel under the hood
onNavigateToDetails = {}
)
}ViewState marker interface.ViewEvent marker interface.NavigationEffect marker interface.ViewEffect marker interface.@Parcelize
data class ChoiceViewState(
val randomNumber: Int
) : ViewState, Parcelable
sealed interface ChoiceViewEvent : ViewEvent {
data class OnShowDetailsClicked(val id: String) : ChoiceViewEvent
object OnDrawNumberClicked : ChoiceViewEvent
object OnShowToastClicked : ChoiceViewEvent
}
sealed interface ChoiceNavigationEffect : NavigationEffect {
data class NavigateToDetails(val id: String) : ChoiceNavigationEffect
}
sealed interface ChoiceViewEffect : ViewEffect {
data class ShowToast(val message: String) : ChoiceViewEffect
}ℹ Your view state doesn't have to implement
Parcelableinterface if you don't intend to store the view state inSavedStateHandle.
ℹ You don't have to define empty classes, e.g. if you don't need
view effectsimply don't define it.
ℹ
ViewState,ViewEvent,NavigationEffect,ViewEffectare just marker interfaces. Theoretically we would be just fine without them but:
- They open possibility to create extensions methods which are scoped to the particular parts of MVI contract:
- Absolutely life-saver when dealing with
@Preview@Preview(showSystemUi = true, heightDp = 800) @Composable fun ChoiceScreenPreview() { ChoiceScreen( viewModel = ChoiceViewState(randomNumber = 16).toViewModel(), // uses TestMviViewModel under the hood onNavigateToDetails = {} ) }- Very useful when creating instrumented tests when view-model is required by Fragment/Composable.
- They prevent various common mistakes like when the generic parameters are specified in a wrong order while defining view-model.
The only requirement is to implement MviViewModel interface, but 99% of the time it would be easier to inherit from BaseMviViewModel:
class ChoiceViewModel(
savedStateHandle: SavedStateHandle,
) : BaseMviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect>(
ChoiceViewState(0), savedStateHandle
) {
override fun onEvent(event: ChoiceViewEvent) {
}
}
// or
class ChoiceViewModel : BaseMviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect>() {
override val viewStates = streamData()
.map { data -> ChoiceViewState(data) }
.toViewStates(ChoiceViewState(0), viewModelScope)
override fun onEvent(event: ChoiceViewEvent) {
}
}The are two constructors available:
initialViewState - in the MVI (and Compose) the initial state of the view is always required.init block (e.g. using launch(start = CoroutineStart.UNDISPATCHED) {})savedStateHandle (optional) - if you pass SavedStateHandle to the BaseMviViewModel it will automatically save/restore the view state when activity is killed/recreated.val viewStates: StateFlow property.
If you don't do this an exception will be thrown on runtime when the viewStates is accessed for the first time.Extending BaseMviViewModel is not mandatory - it is enough to implement the MviViewModel. This is very useful if you don't want to manage the ViewState "manually" inside the view-model,
but rather fetch it from domain.
class ChoiceViewModel(streamData: StreamUserChoiceDataUseCase) : MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect>, ViewModel() {
override val viewStates: StateFlow<ChoiceViewState> = streamData()
.map { data -> ChoiceViewState(data) }
.toViewStates(ChoiceViewState(0), viewModelScope)
override val viewEffects: StateFlow<ConsumableEvent<ChoiceViewEffect>?> = MutableStateFlow<ConsumableEvent<ChoiceViewEffect>?>(null)
override val navigationEffects: StateFlow<ConsumableEvent<ChoiceNavigationEffect>?> = MutableStateFlow<ConsumableEvent<ChoiceNavigationEffect>?>(null)
override fun onEvent(event: ChoiceViewEvent) { }
}💡 There are 3 extension methods provided that can convert external flows to corresponding
StateFlow-sof theMviViewModelinterface:
toViewStates(initialState: T, scope: CoroutineScope),toViewEffects(scope: CoroutineScope),toNavigationEffects(scope: CoroutineScope).
Special caution is required while defining and assigning fields at the same time, e.g.
override val viewEffects: StateFlow<ConsumableEvent<ChoiceViewEffect>?> = MutableStateFlow<ConsumableEvent<ChoiceViewEffect>?>(null)⚠ You should explicitly type all MVI fields to StateFlow<...> - otherwise you will expose the assigned object (usually MutableStateFlow).
Alternatively you may use backing fields to avoid this issue:
private val _viewEffects = MutableStateFlow<ConsumableEvent<ChoiceViewEffect>?>(null)
override val viewEffects: StateFlow<ConsumableEvent<ChoiceViewEffect>?> = _viewEffectNote: you will be still able to mutate the view state even if you choose the first option (defining and assigning at the same time) - the dispatchViewEffect, dispatchNavigationEffect and val viewState
extension methods check the real type of the flow and perform necessary casting if required.
If you extend BaseMviViewModel OR your custom view-model assigns MutableStateFlow to val viewStates: StateFlow<VS> you may use viewState extension method to update the view state:
class ChoiceViewModel ... {
...
private fun drawNumber() {
viewState = viewState.copy(
randomNumber = Random.nextInt(0, 100)
)
}
...
}If you extend BaseMviViewModel OR your custom view-model assigns MutableStateFlow to val viewEffects: StateFlow<ConsumableEvent<VE>?> you may use dispatchViewEffect extension method:
class ChoiceViewModel ... {
...
private fun showToast() {
dispatchViewEffect(ChoiceViewEffect.ShowToast("Toast message!"))
}
...
}⚠ Most of the time you should not use view effects at all - even displaying the
AlertDialogsshould generally be managed with the view state.
If you extend BaseMviViewModel OR your custom view-model assigns MutableStateFlow to val navigationEffects: StateFlow<ConsumableEvent<NE>?> you may use dispatchNavigationEffect extension method:
class ChoiceViewModel ... {
...
private fun navigateToCardDetails() {
dispatchNavigationEffect(NavigateToDetails(event.id))
}
...
}@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = viewModel<ChoiceViewModel>(),
)⚠ Generally you should type the
viewModelparameter to theMviViewModel<...>interface. Otherwise providing preview may be very hard especially if your view-model has many dependencies (e.g. tons of use cases).If you really don't intend to use preview you may simplify the view-model injection (still not recommended as makes testing harder):
@Composable fun ChoiceScreen( viewModel: ChoiceViewModel = viewModel(), )
You may provide parameters to view-models during injection:
class DetailsViewModelFactory(private val id: String) : ViewModelProvider.NewInstanceFactory() {
override fun <T : ViewModel> create(modelClass: Class<T>): T = DetailsViewModel(id) as T
}
@Composable
fun DetailsScreen(
id: String,
viewModel: MviViewModel<DetailsViewState, ViewEvent, NavigationEffect, ViewEffect> = viewModel(factory = DetailsViewModelFactory(id)),
) {
}First you need to declare view-models in Koin module.
val appUiModule = module {
viewModelOf(::ChoiceViewModel)
viewModel { parameters -> DetailsViewModel(parameters.get()) } // view-model with parameter
}The injection is straightforward.
// view-model without parameters (beside SavedStateHandle)
@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = koinViewModel<ChoiceViewModel>(),
onNavigateToDetails: (String) -> Unit
)
// view-model with parameter
@Composable
fun DetailsScreen(
id: String,
viewModel: MviViewModel<DetailsViewState, ViewEvent, NavigationEffect, ViewEffect> = koinViewModel<DetailsViewModel> { parametersOf(id) }
)There are two equivalent methods of handling view state changes.
First option is to use ViewState composable. Inside the passed content lambda there is a viewState property available which holds the latest view state.
@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = koinViewModel<ChoiceViewModel>(),
onNavigateToDetails: (String) -> Unit
) {
ViewState(viewModel) {
Text(
text = "${viewState.randomNumber}",
style = TextStyle.Default.copy(
fontSize = 72.sp
)
)
}
...
}ℹ Although the
viewStatefield is typed to<VS : ViewState>it is a delegate pointing to a<State<VS : ViewState>>under the hood. Thanks to this the whole view is not recomposed when theviewStatechanges - only the parts reading fromviewStateare recomposed.
The second option is to use by viewState(viewModel) delegation:
@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = koinViewModel<ChoiceViewModel>(),
onNavigateToDetails: (String) -> Unit
) {
val viewState by viewState(viewModel)
Text(
text = "${viewState.randomNumber}",
style = TextStyle.Default.copy(
fontSize = 72.sp
)
)
}ℹ Although the
viewStatevariable is typed to<VS : ViewState>it is a delegate pointing to a<State<VS : ViewState>>under the hood. Thanks to this the whole view is not recomposed when theviewStatechanges - only the parts reading fromviewStateare recomposed.
Within the scope of ViewState composable you should always use the dispatchViewEvent method, which protects against sending events from the
view to the ViewModel when the view is not in the required state. By default, the minimum view state required for propagating view events
is set to Lifecycle.State.RESUMED. View events are ignored if the view is visible but paused.
To modify this behaviour, you can specify mininum view state for view events propagation. For example, if you pass Lifecycle.State.STARTED as minActiveStateForViewEventsPropagation, view events will be disregarded as long as the screen is not visible.
@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = koinViewModel<ChoiceViewModel>(),
onNavigateToDetails: (String) -> Unit
) {
...
Button(
modifier = Modifier.fillMaxWidth(),
onClick = { dispatchViewEvent(OnShowToastClicked) }
) {
Text(text = "Show toast")
}
...
}By default both the ViewState and by viewState(viewModel) subscribes to the viewModel.viewStates flow when view enters Lifecycle.State.STARTED
state and cancels the subscription when view is stopped.
In some rare scenarios you may want to keep the subscription alive even if the view is not visible (not in Lifecycle.State.STARTED state),
especially if the viewModel.viewStates is backed by a cold flow that should keep working even if app is in background.
You may specify the minimum lifecycle state at which the subscription is active using minActiveState param:
ViewState(
viewModel = viewModel,
minActiveState = Lifecycle.State.CREATED
) { effect ->
}val viewState by viewState(viewModel = viewModel, minActiveState = Lifecycle.State.CREATED)ℹ Although using
Lifecycle.State.CREATEDasminActiveStatekeeps the subscription active the Compose suspends recomposition when view is stopped. Thanks to this the app is not wasting resources on invisible updates while the view model may keep its work.
Use NavigationEffect composable method to handle navigation effects.
@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = koinViewModel<ChoiceViewModel>(),
onNavigateToDetails: (String) -> Unit
) {
NavigationEffect(viewModel) { effect ->
when (effect) {
is NavigateToDetails -> {
onNavigateToDetails(effect.id)
}
}
}
...
}ℹ Each navigation effect is provided only once. It won't be repeated even if the view is recreated.
By default the NavigationEffect handles the navigation effects only if the view is at least in the Lifecycle.State.RESUMED state.
Navigation effects are ignored if the view is visible but paused. The last navigation effect dispatched by the view model is handled
once the view is resumed.
To modify this behaviour, you can specify the minimum required view state. For example, if you pass Lifecycle.State.STARTED, all navigation effects will be ignored while the current screen is not visible. When the user returns, only the last navigation effect triggered by the view-model during that time will be processed.
NavigationEffect(
viewModel = viewModel,
minActiveState = Lifecycle.State.STARTED
) { effect ->
}⚠ In Compose it is impossible to handle navigation effects when the view is not started even if you pass
Lifecycle.State.CREATEDas minimum view state. All the recompositions are suspended when the view is in stopped.
Use ViewEffect composable method to handle view effects.
@Composable
fun ChoiceScreen(
viewModel: MviViewModel<ChoiceViewState, ChoiceViewEvent, ChoiceNavigationEffect, ChoiceViewEffect> = koinViewModel<ChoiceViewModel>(),
onNavigateToDetails: (String) -> Unit
) {
ViewEffect(viewModel) { effect ->
when (effect) {
is ShowToast -> Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
}
...
}ℹ Each view effect is provided only once. It won't be repeated even if the view is recreated.
By default the ViewEffect handles the view effects only if the view is in the Lifecycle.State.RESUMED state.
View effects are ignored if the view is visible but paused. The last view effect dispatched by the view model is handled
once the view is resumed.
To modify this behaviour, you can specify the minimum required view state. For example, if you pass Lifecycle.State.STARTED, all view effects will be ignored while the current screen is not visible. When the user returns, only the last view effect triggered by the view-model during that time will be processed.
ViewEffect(
viewModel = viewModel,
minActiveState = Lifecycle.State.STARTED
) { effect ->
}⚠ In Compose it is impossible to handle view effects when the view is not started even if you pass
Lifecycle.State.CREATEDas minimum view state. All the recompositions are suspended when the view is in stopped.