Passing events from Composable functions to MVI ViewModels

Robin Wu
3 min readJul 31, 2023

--

When creating interactive Jetpack compose UI backed by ViewModels, we usually pass in lambda for UI to communicate to its ViewModel. For example:

@Composable
fun FooField(
label: String,
keyboardType: KeyboardType = KeyboardType.Text,
imeAction: ImeAction = ImeAction.Next,
isError: Boolean = false,
onTextInput: (String) -> Unit
) {
// Here goes other UI logic
OutlinedTextField(
modifier = Modifier.fillMaxWidth(),
value = text,
onValueChange = {
onTextInput.invoke(it)
}
// Other UI logic
}

When creating a new FooField:

FooField(
label = "Account number",
onTextChanged = {
viewModel.setText(it)
},
keyboardType = KeyboardType.Number,
isError = state.hasAccountError,
)

This is ok for simple composable functions, but when we need a more complex screen(like a composite of multiple composable functions) backed by ViewModels, the composable function can get really messy with many lambdas:

@Composable
fun MoreComplexComposableFunction(
label: String,
keyboardType: KeyboardType = KeyboardType.Text,
imeAction: ImeAction = ImeAction.Next,
isError: Boolean = false,
onAccountNumberChanged: (String) -> Unit,
onCompanyCodeChanged: (String) -> Unit,
onDateOfBirthChanged: (String) -> Unit,
onVehiclePlateNumberChanged: (String) -> Unit,
onSubmit: () -> Unit,
) {

}

The good news is, if your app is using MVI alike architecture (more about MVI: https://medium.com/swlh/mvi-architecture-with-android-fcde123e3c4a), you can take advantage of it to optimize the messy lambdas.

MVI generally leverages Sealed Class/Interface and there should be a public function in the ViewModel that takes in a subclass of the Sealed Class/Interface:

sealed class UIEvent {
data class AccountNumberChanged(val accountNumber: String): UIEvent()
data class CompanyCodeChanged(val companyCode: String): UIEvent()
data class DateOfBirthChanged(val dob: String): UIEvent()
data class VehiclePlateNumberChanged(val vehiclePlateNumber: String): UIEvent()
object Submit: UIEvent()
}
class someViewModel: ViewModel(){

fun dispatchUiEvents(event: UiEvent){
...
}
}

Then we can just have one lambda in the compose function:

@Composable
fun ResetUserTextInputField(
label: String,
keyboardType: KeyboardType = KeyboardType.Text,
imeAction: ImeAction = ImeAction.Next,
isError: Boolean = false,
onUIEvent: (UIEvent) -> Unit,
) {

}

And in order to reuse ResetUserTextInputField, we can cast UIActions into different subclasses of UIActions depending on the type of widget, see how different ResetUserTextField cast UiEvent into its subclasses:

@Composable
fun ResetUser(
onEvent: (UIEvent) -> Unit,
) {
val state = userViewModel.uiState.value
val context = LocalContext.current
val localFocus = LocalFocusManager.current
Column(
modifier = Modifier
.fillMaxSize()
.padding(8.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
ResetUserTextField(
label = "Account number",
onUIAction = {
onEvent(it as UIEvent.AccountNumberChanged)
},
keyboardType = KeyboardType.Number,
isError = state.hasAccountError,
onNext = {
localFocus.moveFocus(FocusDirection.Down)
},
onDone = {}
)
ResetUserTextField(
label = "Company Code",
onUIAction = {
onEvent(it as UIEvent.CompanyCodeChanged)
},
keyboardType = KeyboardType.NumberPassword,
isError = state.hasConfirmAccountError,
onNext = {
localFocus.moveFocus(FocusDirection.Down)
},
onDone = {}
)
ResetUserTextField(
label = "Date of Birth",
onUIAction = {
onEvent(it as UIEvent.DateOfBirthChanged)
},
isError = state.hasCodeError,
onNext = {
localFocus.moveFocus(FocusDirection.Down)
},
onDone = {}
)
ResetUserTextField(
label = "Vehicle Plate Number",
onUIAction = {
onEvent(it as UIEvent.VehiclePlateNumberChanged)
},
isError = state.hasNameError,
imeAction = ImeAction.Done,
onNext = {},
onDone = {
localFocus.clearFocus()
}
)
Button(
onClick = {
onEvent(it as UIEvent.Submit)
},
) {
Text("Submit")
}
}
}

This way our ResetUser composable function is much cleaner :). But keep in mind this is not a secret remedy.

By passing UI Events lambdas into composable functions it makes your ViewModel logic coupled with composable functions, so keep that in mind before using this approach!

--

--

No responses yet