Get your Compose callbacks under control using scopes

andersullnass

Anders Ullnæss

Posted on June 18, 2023

Get your Compose callbacks under control using scopes

Picture of the scope of a rifle
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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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) 
{
    ..
}
Enter fullscreen mode Exit fullscreen mode

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
) {
    ..
}
Enter fullscreen mode Exit fullscreen mode

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>
) {
    ..
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. The app navigates to another screen
  2. 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
    )
}
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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
    )
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

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
    )
}
Enter fullscreen mode Exit fullscreen mode

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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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() }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
andersullnass
Anders Ullnæss

Posted on June 18, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related