In this post I will show you a simple way to implement the Android MVI ( Model -> View -> Intent ) app architecture.
I first started with MVVM ( Model -> View -> ViewModel ) architecture. This was easy to understand but as some apps can quickly evolve, the coding and could increase and you could create a lot of unneccessary StateFlows. For small projects, in my opinion, MVVM may be the way to go because the setup if fast. If the project has quite of bit of moving parts of communication and States between Composables / Navigation Menus / etc, then MVI may be a better fit.
In my research along with trial and error on starting to learn MVI, I came across quite a few posts that explained it. The explanation of the theory made sense to me but I had issues simply figuring out the proper implementation.
Here I will try to present a simple example of the implementation approach I create when I learned it. The example is a simple form for first and last name. When you type in the first and last name then press the button to submit, there is a 3 second counter delay ( simulating a network call, database operation, etc ) then the Text element above the form will be populated with the names entered.
Simple enough.
I also wanted to test another method so I created a button in the form to change the background of the root element to a random color. This is just for a quick visual to ensure operation works without having to look into the logcat.
Below is the project structure I will show you how to create

First we will create the ViewModelFactory
class ViewModelFactory() : ViewModelProvider.Factory {
//------------to create a ViewModel. This will ensure same ViewModel is used
//val factory = ViewModelFactory()
//val viewModel = ViewModelProvider(this, factory)[ViewModel::class.java]//by viewModels<ViewModel>()
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return if(modelClass.isAssignableFrom(com.itgeek25.samplemvi.viewmodel.ViewModel::class.java)){
com.itgeek25.samplemvi.viewmodel.ViewModel() as T
} else {
throw IllegalArgumentException("ViewModel Not Found!!")
}
}
}
The idea to allows create a seperate ViewModelFactory for your project is in case you need to pass a variable to your ViewModel for use in the init { } function. Also this ensures there is only one instance of the ViewModel used in the application.
Now we will create the DataState in the Data.kt file. This is where you will hold your application states. It will be a single state to reference instead of, in MVVM, potentially multiple.
data class DataState(
val isBackgroundChanging : Boolean = false,
val backgroundColor : Color = Color(
Random.nextFloat(),
Random.nextFloat(),
Random.nextFloat(),
1.0f
),
val isUserUpdating : Boolean = false,
val UserData : User = User("John, Doe"),
val isError : Boolean = false
)
data class User(
var first: String = "",
var last: String = ""
)
Now we will create the ItemIntent. This is where we hold reference to the functions for the actions we want.
sealed interface ItemIntent {
data class updateText(val user: User) : ItemIntent
data class changeBackground(val color : Color) : ItemIntent
data class onError(val error: String) : ItemIntent
}
Now we will add the ViewModel to put all this together
class ViewModel() : ViewModel(){
private val _mvistate : MutableStateFlow<DataState> = MutableStateFlow<DataState>(DataState())
val mvistate : StateFlow<DataState> = _mvistate.asStateFlow()
init{
_mvistate.update { it.copy(UserData = User()) } // defaults to John Doe
}
fun handleEvents(event : ItemIntent) {
when (event) {
is ItemIntent.updateText -> { updateText(event) }
is ItemIntent.changeBackground -> { updateBackground(event) }
is ItemIntent.onError -> { onError() }
}
}
private fun onError() {
}
private fun updateBackground(event: ItemIntent.changeBackground) {
viewModelScope.launch {
_mvistate.update { _mvistate.value.copy(isBackgroundChanging = true) }
delay(1000L) // simulated processing
_mvistate.update { _mvistate.value.copy(backgroundColor = event.color) }
_mvistate.update { _mvistate.value.copy(isBackgroundChanging = false) }
}
}
private fun updateText(event: ItemIntent.updateText) {
viewModelScope.launch {
_mvistate.update { _mvistate.value.copy(isUserUpdating = true) }
delay(3000L) // simulated processing
_mvistate.update { _mvistate.value.copy(UserData = event.user) }
_mvistate.update { _mvistate.value.copy(isUserUpdating = false) }
}
}
}
BONUS
As a treat, I created a simple function for the animated loading circle.
This is a overlay with a 0.5f alpha that centers the loading circle in the middle of the screen after the buttons are pressed for either action.
Lets create it in AnimatedCircleLoading.kt.
@Composable
fun AnimatedCircleLoading(visible: Boolean) {
AnimatedVisibility(
visible = visible,
enter = fadeIn(),
exit = fadeOut()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = Color(0.0f, 0f, 0f, 0.5f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
Now lets create the UI and put the logic together in the MainActivity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
SampleMVITheme {
val factory = ViewModelFactory()
val viewModel = ViewModelProvider(
this,
factory
)[ViewModel::class.java]
val state = viewModel.mvistate.collectAsStateWithLifecycle()
HomeScreen(state.value, viewModel::handleEvents)
}
}
}
}
@Composable
fun HomeScreen(
state: DataState,
handleEvents: (ItemIntent) -> Unit
) {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
val first = remember {
mutableStateOf("")
}
val last = remember {
mutableStateOf("")
}
Box(modifier = Modifier
.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
modifier = Modifier
.padding(innerPadding)
.fillMaxSize()
.background(color = state.backgroundColor)
) {
Row {
Text(text = "First: ${state.UserData.first}\nLast: ${state.UserData.last}")
}
Row {
TextField(value = first.value, onValueChange = { first.value = it })
}
Row {
TextField(value = last.value, onValueChange = { last.value = it })
}
Row {
Button(onClick = {
handleEvents(
ItemIntent.updateText(
User(
first = first.value,
last = last.value
)
)
)
first.value = "" // reset inpup box
last.value = "" // reset input box
}) {
Text(text = "Set Name")
}
Button(onClick = {
handleEvents(
ItemIntent.changeBackground(
Color(
Random.nextFloat(),
Random.nextFloat(),
Random.nextFloat(),
1.0f
)
)
)
}) {
Text(text = "Change Background")
}
}
}
//if either action can been triggered, then show loading circle
AnimatedCircleLoading(if(state.isUserUpdating || state.isBackgroundChanging) true else false)
}
}
}
Hope this helps.

Leave a Reply