Taming Jetpack

A Deep Dive into Compose Previews with ViewModels

Jetpack Compose being a modern UI Toolkit brought a lot of improvements with its declarative approach to UI development. If you are familiar with Jetpack compose, then you have definitely encountered a few problems when it comes to working with previews.

Have you ever asked yourself why the preview window suddenly breaks when passing viewModels to your composable functions?

Well... Simply put this way, compose previews are not run in the context of an activity or a fragment and don't have access to the ViewModelProvider or lifecycle owners.

So how do we go about making our previews work with viewModels?

We can do that by having a separate composable that holds our screen's content.

I have prepared the following app to illustrate this.

It's a simple counter app where you can either increase, decrease or reset the digit in the card.

Here are the code snippets for the event class and the viewModel

sealed class CounterEvent {
    object Increase : CounterEvent()
    object Decrease : CounterEvent()
    object Reset : CounterEvent()
}
class CounterViewModel : ViewModel() {

    var counter by mutableStateOf(0)
        private set

    fun onEvent(event: CounterEvent) {
        when (event) {
            CounterEvent.Increase -> {
                counter++
            }
            CounterEvent.Decrease -> {
                counter--
            }
            CounterEvent.Reset -> {
                counter = 0
            }
        }
    }

}

The example below shows how we can separate our screen's content with a separate composable that only receives the state value instead of the ViewModel.

@Composable
fun CounterScreen(
    viewModel: CounterViewModel = viewModel()
) {
    CounterScreenContent(state = viewModel.counter, onEvent = viewModel::onEvent)
}

@Composable
fun CounterScreenContent(
    state: Int,
    onEvent: (CounterEvent) -> Unit
) {
    Box(modifier = Modifier.padding(24.dp)) {
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(horizontal = 24.dp, vertical = 12.dp),
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Card(
                modifier = Modifier
                    .fillMaxWidth()
            ) {
                Box(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(24.dp),
                    contentAlignment = Alignment.Center
                ) {
                    Text(text = state.toString(), fontSize = 24.sp, fontWeight = FontWeight.Bold)
                }
            }
            Spacer(modifier = Modifier.height(23.dp))
            FilledTonalButton(
                modifier = Modifier.align(Alignment.End),
                onClick = { onEvent(CounterEvent.Reset) }
            ) {
                Text(text = "Reset")
            }
        }
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .align(Alignment.BottomCenter),
            horizontalArrangement = Arrangement.SpaceBetween
        ) {
            Button(onClick = { onEvent(CounterEvent.Increase) }) {
                Text(text = "Increase")
            }
            Button(onClick = { onEvent(CounterEvent.Decrease) }) {
                Text(text = "Decrease")
            }
        }
    }
}

@Preview(showBackground = true)
@Composable
fun CounterScreenPreview() {
    ComposePreviewsTheme {
        CounterScreenContent(state = 0, onEvent = { })
    }
}

There are so many ways to go around this, however, I found this approach to be straightforward and easy to work with.

That's all folks.