Last Week
The landing page after a user logs in is complete. A simple drawer is also implemented.
Tools used:
What does it mean in English?
A full-fledged app on a device (e.g., phone, laptop, etc) is a miracle cornucopia of complex software components: somebody needs to refresh the screen (60 times a second!), somebody needs to monitor user interaction (e.g., tap, drag, etc), somebody needs to talk to the internet and fetch information, somebody needs to save stuff to local storage, so on and so forth. You get the idea.
Model-View-Intent (MVI) pattern is a way of organizing the various areas of responsibilities inside an app. Think of it as a collection of carefully-designed best practices. You technically don’t need it (or any other similar design patterns) to make a working app, but it would behoove you to use one.
The “Model” in MVI contains interactions with “sources of truth.” It can be a database, a website, an API, local storage, and so forth.
The “View” in MVI is the interface presented to the user.
The “Intent” in MVI is the intent as a the result of user action (e.g., tapping on a button).
The ViewModel, though not mentioned in the name “MVI”, is an integral component in an Android application. It is responsible for connecting the View to the Model (hence the name).
The Reducer, which is also not mentioned in the name “MVI”, is an important component as well. Reducers take in the intent (e.g., user intends to increment the number on the screen) and the current state (e.g., screen is currently displaying the number 1) and produces a new state (screen should display the number 2).
Nerdy Details
How did I implement the Model-View-Intent (MVI) pattern? I used a lot of inspiration from the excellent MVI template. Below I provide explanations of important components.
We first create three interfaces for use later. ViewEvent
will contain events initiated by the user. ViewState
will contain the state of the screen. ViewSideEffect will contain code that handles any asynchronous side effects.
interface ViewEvent
interface ViewState
interface ViewSideEffect
We then create a BaseViewModel
for all ViewModels to inherit from. We want the inherited ViewModels to include all three interfaces we defined earlier. We make the class abstract to prevent it from being instantiated directly.
Inside the class, we note a few things:
subscribeToEvents()
is called whenever a new ViewModel is created. This function launches a collector in a scope bound to the current ViewModel that collects events as they emit from the app.
Convenient functions are supplied for ease of use within ViewModels to setEvent()
, setState()
, and setEffect()
from inside ViewModels. We will see them in action later. These functions also protect the underlying states, events, and effects from being changed, emitted, and sent by unexpected sources.
abstract class BaseViewModel<Event : ViewEvent, UiState : ViewState, Effect : ViewSideEffect> :
ViewModel() {
abstract fun setInitialState(): UiState
abstract fun handleEvent(event: Event)
private val initialState: UiState by lazy { setInitialState() }
private val _viewState: MutableState<UiState> = mutableStateOf(initialState)
val viewState: State<UiState> = _viewState
private val _event: MutableSharedFlow<Event> = MutableSharedFlow()
private val _effect: Channel<Effect> = Channel()
val effect = _effect.receiveAsFlow()
init {
subscribeToEvents()
}
private fun subscribeToEvents() {
viewModelScope.launch {
_event.collect {
handleEvent(it)
}
}
}
fun setEvent(event: Event) {
viewModelScope.launch {
_event.emit(event)
}
}
protected fun setState(reducer: UiState.() -> UiState) {
val newState = viewState.value.reducer()
_viewState.value = newState
}
protected fun setEffect(builder: () -> Effect) {
val effectValue = builder()
viewModelScope.launch {
_effect.send(effectValue)
}
}
}
Now we are ready to make our first screen using this model.
Before we make the ViewModel, we will need to make a Contract that the ViewModel must use. The Contract populates the ViewEvent
, ViewState
, and ViewSideEffect
we defined as interfaces earlier. In this example, we demonstrate how to update the ViewState when a user edits the email TextField. We have an event data class UserChangesEmailField
that takes in a string value (which will later be used for the user input value). We have a state userEmail
that will be used to store the email address. Since we don’t have a side effect on the screen, we leave the Effect
class empty.
class UserLoginContract {
sealed class Event : ViewEvent {
data class UserChangesEmailField(val newValue: String) : Event()
}
data class State(
val userEmail: String,
) : ViewState
sealed class Effect : ViewSideEffect {
}
}
With the Contract at hand, we are now ready to make the ViewModel
. For the ViewModel, we extend the BaseViewModel
so the work we did earlier in the abstract class is made available to us here. Note that we do not directly handle the instantiation of this ViewModel. We rely on the Koin dependency injection library for it. When Koin instantiates the class, it would also inject the userRepository
on our behalf.
Inside the ViewModel
class we are required to provide actual implementations of setInitialState()
and handleEvent()
. The latter is where we define the handling of events triggered as a result of user intent. In our case, the only event we need to deal with is when the user types in the email TextField. We simply call onEmailFieldChange()
when it happens. Thanks to the work we did in our BaseViewModel
earlier, we can very conveniently set the state inside onEmailFieldChange()
by using the setState { copy(userEmail = newValue) }
syntax.
class UserLoginViewModel(
private val userRepository: UserRepository
) : BaseViewModel<UserLoginContract.Event, UserLoginContract.State, UserLoginContract.Effect>() {
override fun setInitialState() = UserLoginContract.State(
userEmail = "",
)
override fun handleEvent(event: UserLoginContract.Event) {
when (event) {
is UserLoginContract.Event.UserChangesEmailField -> onEmailFieldChange(event.newValue)
}
}
private fun onEmailFieldChange(newValue: String) {
setState { copy(userEmail = newValue) }
}
}
So far we have defined the procedures to undertake when a user triggers the event. The last step is defining when the event gets triggered inside our composable. This composable takes in state
, effectFlow
, and onEventSent
from the ViewModel. For the TextField’s onValueChange
parameter we simply pass in the function { onEventSent(UserChangesEmailField(it)) }
. This means when the value changes in the TextField, the event UserChangesEmailField()
will be triggered and it contains the value it has changed to. The ViewModel then sees the published event (recall that in the BaseViewModel we have the subscribeToEvents()
function in the init{}
block) and calls the function that handles the event. In our case the state gets updated to contain the new value.
@Composable
fun UserLoginView(
state: UserLoginContract.State,
effectFlow: Flow<UserLoginContract.Effect>?,
onEventSent: (event: UserLoginContract.Event) -> Unit,
) {
TextField(
value = state.userEmail,
onValueChange = { onEventSent(UserChangesEmailField(it)) },
}
Lastly, we cannot forget to ask Koin to instantiate our ViewModel and then pass the needed parameters into our View.
@Composable
fun UserLoginDestination(navController: NavController) {
val viewModel = koinViewModel<UserLoginViewModel>()
UserLoginView(
state = viewModel.viewState.value,
effectFlow = viewModel.effect,
onEventSent = { event -> viewModel.setEvent(event) },
)
}
Viola. We now have a simple Model-View-Intent (MVI) pattern set up!
Next Week
- Contemplate philosophically while sipping on a mojito.