Using Compose Preview with HiltViewModels

Using Compose Preview with HiltViewModels

Jetpack Compose is a modern UI toolkit for Android development that allows developers to build composable user interfaces using Kotlin. One of the great features of Jetpack Compose is the ability to preview composable functions directly in the Android Studio IDE. This makes it easy for developers to iterate on UI designs and catch issues early in the development process.

When building complex UIs, it's often necessary to access data from a ViewModel. The ViewModel is a component of the Android Architecture Components library that is responsible for managing UI-related data in a lifecycle-aware way. In this blog post, we'll explore a way to render a composable under the Jetpack Compose UI framework in a preview when accessing a ViewModel.

To get started, let's create a simple ViewModel that fetches the button text:

@HiltViewModel
class MainViewModel @Inject constructor(
    val buttonService: ButtonService,
) : ViewModel() {

    fun fetchButtonTextFromServer() : StateFlow<String> {
        return buttonService.fetchButtonTextFromServer()
    }

}

class ButtonService @Inject constructor() {
    fun fetchButtonTextFromServer() : StateFlow<String> {
        return MutableStateFlow("Hello from server")
    }
}

Next, we'll create a composable that utilizes a flow of data from the ViewModel to set the button of our text. In this example, no actual network call is being made, but in a realistic example, you can imagine code in the ViewModel that would instead fetch the text for our button:

@Composable
fun CustomButton(
    onClick : () -> Unit,
) {
        val viewModel : MainViewModel = hiltViewModel()
        val buttonText = viewModel.fetchButtonTextFromServer().collectAsStateWithLifecycle().value
        MyButton(
            onClick = onClick,
            text = buttonText,
        )
}

@Composable
private fun MyButton(
    text: String,
    onClick : () -> Unit,
) {
    Button(onClick = onClick) {
        MyButtonText(text = text)
    }
}

@Composable
private fun MyButtonText(
    text: String,
) {
    Text(text = text)
}

If we try to create a @Preview for our CustomButton composable, and render it in the IDE / device, we will get this error:

@Preview
@Composable
fun Preview_CustomButton() {
    CustomButton {
        // no-op
    }
}
Caused by: java.lang.RuntimeException: Cannot create an instance of class com.joetr.themepreviewviewmodel.MainViewModel

Solution

Now we want to preview this composable function in Android Studio. By default, Jetpack Compose previews are generated using sample data that is provided by the developer. To preview the composable function using data from our ViewModel, we can create a CompositionLocal when we want to force it in preview mode.

From the docs - "CompositionLocal is a tool for passing data down through the Composition implicitly." This can be thought of as a good solution using two metrics (also from the docs):

  1. A CompositionLocal should have a good default value. If there's no default value, you must guarantee that it is exceedingly difficult for a developer to get into a situation where a value for the CompositionLocal isn't provided. Not providing a default value can cause problems and frustration when creating tests or previewing a composable that uses that CompositionLocal will always require it to be explicitly provided.

  2. Avoid CompositionLocal for concepts that aren't thought as tree-scoped or sub-hierarchy scoped.

val LocalMyButtonPreviewMode: ProvidableCompositionLocal<Boolean> = compositionLocalOf { false }

@Composable
fun CustomButton(
    onClick : () -> Unit,
) {
    if(LocalMyButtonPreviewMode.current) {
        MyButton(
            text = "Hello from preview",
            onClick = {
                // no-op
            }
        )
    } else {
        val viewModel : MainViewModel = hiltViewModel()
        val buttonText = viewModel.fetchButtonTextFromServer().collectAsStateWithLifecycle().value
        MyButton(
            onClick = onClick,
            text = buttonText,
        )
    }
}

Now, when we want to create a preview for MyButton, we can utilize this CompostionLocal to get a preview to display as expected.

@Preview
@Composable
fun Preview_MyButton() {
    CompositionLocalProvider(LocalMyButtonPreviewMode provides true) {
        CustomButton {
            // no-op
        }
    }
}

In summary, by creating a custom CompostionLocal which allows us to use a Preview instance of our Composable instead of relying on a HiltViewModel, we can render our composable under a preview using fake data. This allows us to iterate on UI designs and catch issues early in the development process.