A Simple Way To Handle UI Actions In Jetpack Compose
There are several ways to handle UI action events in Compose, for example, lambda function, UIEventHandler interface, etc every pattern has a tradeoff but there is a way that can be pretty good but nobody talks about it.
The problem
In the UDF pattern (Unidirectional Data Flow) when a screen-level composable function is developed constantly, the number of its actions can be increased rapidly, and we may end up with a composable with 70+ lambda functions in its parameters. This is a problem!
@Composable
internal fun MainScreen(
viewModel: MainScreenViewModel,
retry: () -> Unit,
updateUsername: (username: String) -> Unit,
navigateToSecondScreen: () -> Unit,
navigateToSearch: () -> Unit,
// + 70 more
)
One of the solutions is to use an event handler class. In this solution, there is an EventHanlder
interface that intercepts all the event classes that are inherited from a specific interface for example UiEvent
.
interface UiEventHandler {
fun handelEvent(event: UiEvent)
}
interface UiEvent
data class UpdateUsernameEvent(
val username: String,
) : UiEvent
@Composable
internal fun MyScreen(eventHandler: UiEventHandler) {
// .. //
eventHandler.handelEvent(UpdateUsernameEvent("username"))
}
Or a bit simpler:
typealias OnAction = (UiAction) -> Unit
interface UiAction
data class UpdateUsernameAction(
val username: String,
) : UiAction
@Composable
internal fun MyScreen(onAction: OnAction) {
// .. //
onAction(UpdateUsernameEvent("username"))
}
For more information about the implementation please check this and this article.
Both implementations are good and solid but when we apply these solutions to our problem we end up with another problem! Not to mention the amount of boilerplate code, and the amount of the Actions/UiEvent classes per event.
Separation of concerns.
Separation of concerns is a principle used in programming to separate an application into units, with minimal overlapping between the functions of the individual units.
We have a massive feature (our main screen) with many subcomponents; each component has a long list of parameters. We want these subcomponents to be isolated. How can we ensure we won’t trigger a non-relevant event inside these subcomponents?
// With lambda functions
@Composable
fun SomeComponent(openSecondScreen: () -> Unit) {
// .. //
openSecondScreen()
}
// With UiEventHander
@Composable
fun SomeComponent(eventHander: UiEventHander) {
// .. //
eventHandler.handelEvent(/* How I can make sure I'm passing a relevant event? */)
}
Action classes with lambda functions
The idea behind this solution is pretty simple. It is similar to the interface segregation principle, we’re looking to separate non-related logic from individual components although we’re not forced to depend on it.
The interface segregation principle (ISP) states that no code should be forced to depend on methods it does not use.
With the solutions we showed earlier, we have to have a set of action classes that can be triggered everywhere. What we’re looking to achieve is to have a clear contract about the exact actions that components use, this adds more clarity to our code as well.
How it works?
We create a data class called actions class that contains lambda functions for triggering the UI events. Depending on the situation every screen or component can have its corresponding actions.
For example, we have our main screen that has many actions, here is how this solution applies to it:
@Stable
internal data class MainScreenActions(
val commonActions: CommonActions,
val childComponentActions: ChildComponentActions = ChildComponentActions(),
val secondChildComponentActions: SecondChildComponentActions = SecondChildComponentActions(),
)
@Stable
internal data class CommonActions(
val retry: () -> Unit = {},
val updateUsername: (username: String) -> Unit = {},
// + 10 acions
)
@Stable
internal data class ChildComponentActions(
val navigateToSecondScreen: () -> Unit = {},
// + 20 actions
)
@Stable
internal data class SecondChildComponentActions(
val navigateToSearch: () -> Unit = {},
// + 15 acions
)
Here is how we use it in compose:
@Composable
internal fun MainScreen(
viewModel: MainScreenViewModel,
navigateToSecondScreen: () -> Unit,
navigateToSearch: () -> Unit,
) {
val actions = MainScreenActions(
commonActions = CommonActions(
retry = viewModel::retry,
updateUsername = viewModel::updateUsername,
),
childComponentActions = ChildComponentActions(
navigateToSecondScreen = navigateToSecondScreen,
),
secondChildComponentActions = SecondChildComponentActions(
navigateToSearch = navigateToSearch,
),
)
val state by viewModel.uiState.collectAsState()
MainScreenScaffold(
actions = actions,
state = state,
)
}
@Composable
internal fun MainScreenScaffold(
actions: MainScreenActions,
state: MainScreenState,
) {
ChildComponent(
actions = actions.childComponentActions,
)
SecondChildComponent(
actions = actions.secondChildComponentActions,
commonActions = actions.commonActions,
)
}
@Composable
private fun ChildComponent(
actions: ChildComponentActions,
modifier: Modifier = Modifier,
) {
Button(
onClick = {
actions.navigateToSecondScreen()
}
)
}
@Composable
private fun SecondChildComponent(
actions: SecondChildComponentActions,
commonActions: CommonActions,
modifier: Modifier = Modifier,
) {
Button(
onClick = {
actions.navigateToSearch()
}
)
Button(
onClick = {
commonActions.updateUsername("username")
}
)
}
The example above shows how this solution is implemented at a large scale. Depending on the scale of a component it can have a simplified implementation, here is another example:
@Stable
internal data class MainScreenActions(
val retry: () -> Unit = {},
val updateUsername: (username: String) -> Unit = {},
val navigateToSearch: () -> Unit = {},
)
@Composable
internal fun MainScreen(
viewModel: MainScreenViewModel,
navigateToSearch: () -> Unit,
) {
val actions = MainScreenActions(
retry = viewModel::retry,
updateUsername = viewModel::updateUsername,
navigateToSearch = navigateToSearch,
)
val state by viewModel.uiState.collectAsState()
MainScreenScaffold(
actions = actions,
state = state,
)
}
@Composable
internal fun MainScreenScaffold(
actions: MainScreenActions,
state: MainScreenState,
) {
ChildComponent(
retry = actions.retry,
)
SecondChildComponent(
navigateToSearch = actions.navigateToSearch,
updateUsername = actions.updateUsername,
)
}
@Composable
private fun ChildComponent(
retry: () -> Unit = {},
modifier: Modifier = Modifier,
) {
Button(
onClick = {
retry()
}
)
}
@Composable
private fun SecondChildComponent(
navigateToSearch: () -> Unit = {},
updateUsername: (username: String) -> Unit = {},
modifier: Modifier = Modifier,
) {
Button(
onClick = {
navigateToSearch()
}
)
Button(
onClick = {
updateUsername("username")
}
)
}
After all, it always depends on the situation if we need to use a dedicated actions class for our subcomponents or not.
With this solution now we’re following the interface segregation principle (somehow) which helps us to clarify the corresponding actions for each component with a clear contract and without having a long list of lambda parameters in our composables or a lot of event classes.