Get your Compose callbacks under control using scopes
Tags: android, jetpackcompose, kotlin
Published: 2023-06-18 15:26:16



![Picture of the scope of a rifle](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nmwlv5yv1u5qhyf4apo9.jpg)
Photo by <a href="https://unsplash.com/@zkila?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Arkana Bilal</a> on <a href="https://unsplash.com/photos/i4TuH8rRz9s?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText">Unsplash</a>
  
## 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:

```kotlin
@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.

```kotlin
@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?

```kotlin
@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.

```kotlin
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?

```kotlin
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: 
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:

```kotlin
@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:
```kotlin
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: 
```kotlin
@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:
```kotlin
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: 
```kotlin
@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:
```kotlin
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:

```kotlin
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: 

```kotlin
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](https://twitter.com/andersullnass)