Handling UI Events in Jetpack Compose: A Clean Approach
While working on the backend of the application is very important, how we handle what the user sees is crucial to the success of our work. With this in mind, we spend a lot of time thinking about what the user will do, how to handle UI events, and how to structure our code accordingly. In this article, we will be looking at a clean approach for handling UI events in Jetpack Compose. This is an approach I found from Phillip Lackner, and I will be expanding upon it and explaining how to use it.
First, what are UI events? Simply put, UI events are actions that should be handled in the UI layer, either by the UI or by the ViewModel. These include button clicks, entering text into the text field, displaying error messages, or navigating to another screen.
Edit: Before you continue reading, what I am talking about here is not a UI event, but actually a User Event. If you want to learn more about UI Events or User Events, check here.
UI Event Handling Structure
How we are going to handle UI events is quite simple. We are going to have a UiEvent interface, a ViewModel and some Composables. The user interacts with the UI (Composable), and which then triggers a function in the ViewModel. Let’s look at the clean way for handling these UI events.
The Clean way is going to have three components:
- UiEvent — The UiEvent is a
sealed interface
and in here, we are going to define everything a user can do or what will happen on a single screen. - ViewModel — In the ViewModel, instead of having an individual function for each user interaction, we are going to have a single function that has a single parameter:
onEvent(event: UiEvent)
. This takes in aUiEvent
being passed to it and handles it accordingly. - Composables — The UI layer. Here, we will call the
onEvent
function and pass inUiEvents
.
Let’s go through the code to understand this better.
sealed interface UiEvent {
data object InteractionOne: UiEvent
data class InteractionTwo(val value: String): UiEvent
}
In our UiEvent
, we are defining every possible event that can happen on the screen. We use data objects
and data classes
to define the event types to represent our UiEvents. Data classes are used for when we need to pass data from the Ui to the ViewModel, such as text input.
We use a sealed interface
for two reasons. First, once a module with a sealed interface is compiled, no new implementations can be created. This means that the classes that we defined are fixed, nothing else can be added later. Secondly, using a sealed interface
with a when
statement, you can cover all possible defined UiEvents types.
Find out more about sealed interfaces
here.
class EventViewModel(
private val onNavigate: () -> Unit
): ViewModel() {
fun onEvent(event: UiEvent) {
when (event) {
UiEvent.InteractionOne -> {
println("This is event one")
}
is UiEvent.InteractionTwo -> {
println("This is event two and its value: ${event.value}")
}
}
}
}
In the EventViewModel
, we only have a single function named onEvent
. This takes in the parameter of UiEvent
. Because it is a sealed interface
, combining it with a when
ensures that we can cover all possible UiEvent
types. Depending on the type, different functions will be called.
@Composable
fun HomeScreen(
state: EventUiState,
onEvent: (UiEvent) -> Unit,
) {
Column(modifier = Modifier.fillMaxWidth()) {
Button(onClick = { onEvent(UiEvent.InteractionOne }) {
Text("Print InteractionOne")
}
Button(onClick = { onEvent(UiEvent.InteractionTwo("Hello"))}) {
Text("Print InteractionTwo")
}
}
}
In the HomeScreen
Composable, we only have to pass in one function. Since onEvent
takes in a UiEvent
as a parameter, every interaction on the screen will use the same function, just with different UiEvents
.
If we run the code, and click on the buttons, we will see our functions are working and printing as expected.
Benefits
This may look like some extra steps, but let’s take a look at the benefits.
- Decouple the Layers
- Simplifies Composables
- Scalability and Maintainability
We will dive into each of these further.
Decouple the Layers
The purpose of using this style is to further separate our layers. Using a RadioButton as an example, we could call functions directly from the ViewModel in the UI as shown below and it will work just fine.
Button(onClick = { viewModel.printInteractionOne() }) {
Text("Print InteractionOne")
}
However, in this example, the UI has a direct interaction into the ViewModel. Generally when we are designing the structure of our projects, we are doing our best to separate the layers as much as possible. By using a UiEvent
, the UI is not reliant on what the ViewModel does during that interaction.
Simplifies Composables
In the example from above, instead of passing in the two functions into the composable, we only passed one. Without the onEvent
function, we would have to pass in each lambda function individually, like so.
@Composable
fun EventDemoButtons(
onInteractionOneClick: () -> Unit,
onInteractionTwoClick: (String) -> Unit,
) {
Button(onClick = { onInteractionOneClick() }) {
Text("Print InteractionOne")
}
Button(onClick = { onInteractionTwoClick("Hello") }) {
Text("Print InteractionTwo")
}
}
EventDemoButons(
onInteractionOneClick = { viewModel.onInteractionOneClick() },
onInteractionTwoClick = { viewModel.onInteractionTwoClick(it) }
)
Again, this will work, but this is a simple feature with two buttons. As your features become more complex, the number of UI events grows. Passing in each function can lead to confusion for which function does what. Simplifying it by passing in a single function is the cleaner approach.
@Composable
fun EventDemoButtons(
onEvent: (UiEvent) -> Unit
) {
Button(onClick = { onEvent(UiEvent.InteractionOne) }) {
Text("Print InteractionOne")
}
Button(onClick = { onEvent(UiEvent.InteractionTwo("Hello")) }) {
Text("Print InteractionTwo")
}
}
Here is how you can call the DemoButtonScreen
.
EventDemoButtons(onEvent = {
viewModel.onEvent(it)
})
Scalability and Maintainability
As you are working on a feature, there are always moments where new functions are requested, interactions to be modified, or other cases. Using a centralized event handling method will make it much easier to handle these changes. For example, if we need to add a new interaction, InteractionThree
, we simply add it to the UiEvent
interface and then update the onEvent
with the new interaction.
sealed interface UiEvent {
data object InteractionOne: UiEvent
data class InteractionTwo(val value: String): UiEvent
data object InteractionThree: UiEvent
}
fun onEvent(event: UiEvent) {
when (event) {
UiEvent.InteractionOne -> {
Log.d("UiEvent", "This is event one")
}
is UiEvent.InteractionTwo -> {
Log.d("UiEvent", "This is event two and its value: ${event.value}")
}
UiEvent.InteractionThree -> {
Log.d("UiEvent", "This is event three")
}
}
}
Examples
We have seen how to implement a clean approach to UI event handling, but what are some practical examples? Let’s implement a basic screen containing a TextField
and a Button
.
@Composable
fun HomeScreen(
state: EventUiState,
onEvent: (UiEvent) -> Unit,
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.weight(0.5f))
TextField(modifier = Modifier.fillMaxWidth(),
value = state.text, onValueChange = {
onEvent(UiEvent.OnValueChange(it))
})
Button(modifier = Modifier.fillMaxWidth(), onClick = { onEvent(UiEvent.Save) }) {
Text(
text = "Save"
)
}
Spacer(modifier = Modifier.weight(0.5f))
}
}
sealed interface UiEvent {
data class OnValueChange(val value: String): UiEvent
data object Save: UiEvent
}
when (event) {
is UiEvent.OnValueChange -> {
_state.update {
it.copy(
text = event.value
)
}
}
UiEvent.Save -> {
onNavigate()
}
}
}
Here, we are going to update the state in our ViewModel using the UiEvent.OnValueChange()
in our TextField and passing in the new value. Doing this approach, the UI doesn’t know what happens to the data, it is completely separated from what goes on in the UI.
Adding to the Example
Whoops, we don’t have any way of handling an error in code. What if the TextField is empty? Let’s implement some error handling. We can add a private function to the ViewModel to check if the text is empty.
private fun checkForErrors(): Boolean {
if (_state.value.text.isBlank()) {
_state.update {
it.copy(
errorMessage = "Text can't be blank."
)
}
}
return _state.value.errorMessage == null
}
Now we can modify how we handle the UiEvent.Save
in the onEvent
.
UiEvent.Save -> {
if (checkForErrors()) {
onNavigate()
}
}
Great, so now we can display an error message if the textfield is blank. We should also set our state’s errorMessage
back to null to ensure that the user can save later. We can add an event named ClearError
to the UiEvent
.
sealed interface UiEvent {
data class OnValueChange(val value: String): UiEvent
data object Save: UiEvent
data object ClearError: UiEvent
}
Add the event handling to the onEvent
.
UiEvent.ClearError -> {
_state.update {
it.copy(
errorMessage = null
)
}
}
And finally, we can add the logic for displaying the error message and calling the UiEvent.ClearError
from the Composable. Again, we are simply passing in the event to our onEvent function in the UI, that’s it.
val context = LocalContext.current
LaunchedEffect(key1 = state.errorMessage) {
if (state.errorMessage != null) {
Toast.makeText(context, state.errorMessage, Toast.LENGTH_SHORT).show()
onEvent(UiEvent.ClearError)
}
}
Conclusion
That’s it! We have taken a look at how to handle UI events in a cleaner way by creating a sealed interface where we define all events, implementing an onEvent
function that takes an event as a parameter, and finally passing in only the onEvent
function to our Composables.
How do you handle UI events? Let me know what you thought of this!