Get your Compose callbacks under control using scopes
Anders Ullnæss
Posted on June 18, 2023
Photo by Arkana Bilal on Unsplash
Dumb composables
When developing with Jetpack Compose, we want our composables to be "dumb". We want to pass them the state they need to display and callbacks for when the user interacts with them. You can read more about state and compose here:
https://developer.android.com/jetpack/compose/state
A dumb button could look something like this:
@Composable
fun DumbButton(
text: String,
onClick: () -> Unit
) {
Button(onClick = onClick) {
Text(text = text)
}
}
The button will tell the caller when it is clicked through the onClick
callback. In some part of our code we need to react to this click and do something, typically updating the state of the screen or navigating to a different screen. Maybe we even have to show a loader and call a backend and then navigate to another screen.
Such decision making usually belongs in the ViewModel.
In our setup, the screen composable is the only one that knows about and has access to the ViewModel. It is recommended not to pass the reference to the ViewModel further down to other composables. The screen is therefore often just a thin layer around another "dumb" composable which we call content.
@Composable
fun HomeScreen(
viewModel: HomeViewModel = getViewModel()
) {
HomeContent(
state = viewModel.screenState,
onDumbButtonClick = viewModel::onDumbButtonClick
)
}
@Composable
fun HomeContent(
state: ScreenState<HomeUiModel>,
onDumbButtonClick: () -> Unit
) {
DumbButton(text = "Click me", onClick = onDumbButtonClick)
}
So far so good, but this screen does not do a whole lot.
What if instead of just one dumb button, the screen has 42 different things the user can do?
@Composable
fun HomeContent(
state: ScreenState<HomeUiModel>,
onDumbButtonClick: () -> Unit,
onOtherButtonClick: () -> Unit,
onLoginClick: () -> Unit,
onRegisterClick: () -> Unit,
onCloseClick: () -> Unit,
..
onYouGetThePoint: () -> Unit)
{
..
}
The list of callbacks can quickly get out of hand for a more complicated screen.
Scopes to the rescue
What is a scope? You have probably seen them around already, with examples such as BoxScope
, RowScope
, ColumnScope
, LazyListScope
etc.
interface BoxScope {
@Stable
fun Modifier.align(alignment: Alignment): Modifier
@Stable
fun Modifier.matchParentSize(): Modifier
}
@Composable
inline fun Box(
..
content: @Composable BoxScope.() -> Unit
) {
..
}
If I am in a BoxScope
, I can use these modifiers that are not available elsewhere
Scopes are mostly just interfaces and instead of passing them to our composables as a parameter we take advantage of extension functions to give our composable access to all the functions within the scope.
Now, how can we use a scope to avoid our list of 42 callbacks?
interface HomeScreenScope {
fun onDumbButtonClick()
fun onOtherButtonClick()
fun onLoginClick()
fun onRegisterClick()
fun onCloseClick()
..
fun onYouGetThePoint()
}
@Composable
fun HomeScreenScope.HomeContent(
state: ScreenState<HomeUiModel>
) {
..
}
The long list of callbacks is gone, but the code within HomeContent
still has access to all of them through the scope.
But now we need to call HomeContent
on a class that implements the scope.
Among the 42 things the user can do on the home screen, there are two main things that could happen:
- The app navigates to another screen
- The state changes and (parts of) the screen recomposes
Typically the state changes are handled by the ViewModel and the navigation is handled by a NavHost or similar.
One way to deal with this could be to implement the interface in HomeScreen
and direct some callbacks to the ViewModel and others to the NavHost:
@Composable
fun HomeScreen(
onCloseClick: () -> Unit,
onLoginClick: () -> Unit,
onRegisterClick: () -> Unit,
viewModel: HomeViewModel = getViewModel(),
) {
val scope = object: HomeScreenScope {
override fun onDumbButtonClick() = viewModel.onDumbButtonClick()
override fun onOtherButtonClick() = viewModel.onOtherButtonClick()
override fun onLoginClick() {
viewModel.onLoginClick() // For analytics
onLoginClick() // For navigation
}
override fun onRegisterClick() = onRegisterClick()
override fun onCloseClick() = onCloseClick()
}
scope.HomeContent(
state = viewModel.screenState
)
}
But now we have a big chunk of code in our screen composable and it needs to know when we should call the ViewModel, when we should call back to the NavHost and even sometimes when we should do both, like in onLoginClick
above.
Introducing the NavigationHandler
To get the code and decision making out of the screen composable we make the ViewModel implement the scope instead:
class HomeViewModel: HomeScreenScope {
override fun onDumbButtonClick() {
// Update state
}
override fun onOtherButtonClick() {
// Update state
}
override fun onLoginClick() {
// Track login click event
// TODO: Navigate to login screen
}
override fun onRegisterClick() {
// TODO: Navigate to register screen
}
override fun onCloseClick() {
// TODO: Pop the backstack
}
}
Now our screen code gets much more concise:
@Composable
fun HomeScreen(
onCloseClick: () -> Unit,
onLoginClick: () -> Unit,
onRegisterClick: () -> Unit,
viewModel: HomeViewModel = getViewModel(),
) {
viewModel.HomeContent(
state = viewModel.screenState
)
}
But how do we deal with the navigation? The ViewModel does not know how to navigate and it has no access to the NavHost.
We introduce the NavigationHandler:
interface HomeNavigationHandler {
fun popBackStack()
fun navigateToLogin()
fun navigateToRegister()
}
We take it as input in the screen composable and pass it to our ViewModel. This example is using Koin, but the same idea should work in your dependency injection/service locator pattern of choice:
@Composable
fun HomeScreen(
navigationHandler: HomeNavigationHandler,
viewModel: HomeViewModel = getViewModel(parameters = { parametersOf(navigationHandler) }),
) {
viewModel.HomeContent(
state = viewModel.screenState
)
}
Now the ViewModel can delegate the navigation to the NavigationHandler:
class HomeViewModel(
private val navigationHandler: HomeNavigationHandler
): HomeScreenScope {
override fun onDumbButtonClick() {
// Update state
}
override fun onOtherButtonClick() {
// Update state
}
override fun onLoginClick() {
// Track login click event
navigationHandler.navigateToLogin()
}
override fun onRegisterClick() {
navigationHandler.navigateToRegister()
}
override fun onCloseClick() {
navigationHandler.popBackStack()
}
}
As a nice bonus, with the ViewModel being in charge of the decision making, whether to update the state or to navigate to another screen, we can easily test that as well:
class HomeViewModelTest {
private val navigationHandler: HomeNavigationHandler = mockk(relaxUnitFun = true)
private val viewModel = HomeViewModel(navigationHandler)
@Test
fun `Clicking on login navigates to login screen`() {
viewModel.onLoginClick()
verify { navigationHandler.navigateToLogin() }
}
}
A small note about previews
If we want to make a preview of our HomeContent
, we would also need to call it on a scope. Instead of creating a whole real ViewModel to do this, we crate an empty scope:
val emptyHomeScreenScope = object : HomeScreenScope {
override fun onDumbButtonClick() {}
override fun onOtherButtonClick() {}
override fun onLoginClick() {}
override fun onRegisterClick() {}
override fun onCloseClick() {}
}
@Preview
@Composable
fun HomeContentPreview {
emptyHomeScreenScope.HomeContent(someState)
}
Wrapping up
I want to give a shoutout to my colleague Thomas Pienaar, who came up with the idea of using this pattern, which we then improved together.
Let me know what you think of the pattern, if you have any questions or have suggestions for further improvements to it.
You can find me here in the comments or on Twitter
Posted on June 18, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.