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!

Sign up to discover human stories that deepen your understanding of the world.

Free

Distraction-free reading. No ads.

Organize your knowledge with lists and highlights.

Tell your story. Find your audience.

Membership

Read member-only stories

Support writers you read most

Earn money for your writing

Listen to audio narrations

Read offline with the Medium app

--

--

No responses yet