6

I am using Jetpack Compose and noticed that the preview is not shown. I read articles like this, but it seems my problem has a different root cause. Even I added defaults to all parameters in the compose function like this:

@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
@ExperimentalFoundationApi
@Preview
fun VolumeSettingsScreen(
    speech: SpeechHelper = SpeechHelper(), // my class that converts text to speech
    viewModel: VolumeSettingsViewModel = hiltViewModel(), // using Hilt to inject ViewModels
    navController: NavHostController = rememberNavController() // Compose Navigation component
) {
    MyAppheme {
        Box(
             ...
        )
    }
}

When I rollbacked some changes I realized that the @Preview does not support the viewModels regardless of whether they are injected with Hilt or not.

Any Idea how this could be fixed?

z.g.y
  • 5,512
  • 4
  • 10
  • 36
MeLean
  • 3,092
  • 6
  • 29
  • 43
  • Does this answer your question? [Jetpack compose preview crashes with hiltViewModel<>()](https://stackoverflow.com/questions/70053308/jetpack-compose-preview-crashes-with-hiltviewmodel) – Mobin Yardim Nov 20 '22 at 14:42
  • Thanks, but regarding this article: https://proandroiddev.com/jetpack-compose-navigation-architecture-with-viewmodels-1de467f19e1c It is not the best practice to inject the ViewModels in the Composable functions. – MeLean Nov 20 '22 at 15:03

3 Answers3

4

I managed to visualize the preview of the screen, by wrapping the ViewModels's functions into data classes, like this:

@OptIn(ExperimentalLifecycleComposeApi::class)
@Composable
@ExperimentalFoundationApi
@Preview
fun VolumeSettingsScreen(
    modifier: Modifier = Modifier,
    speechCallbacks: SpeechCallbacks = SpeechCallbacks(),
    navigationCallbacks: NavigationCallbacks = NavigationCallbacks(),
    viewModelCallbacks: VolumeSettingsScreenCallbacks = VolumeSettingsScreenCallbacks()
) {
    MyAppheme {
        Box(
             ...
        )
    }
}

I passed not the ViewModel directly in the compose but needed functions in a Data class for example, like this:

data class VolumeSettingsScreenCallbacks(
    val uiState: Flow<BaseUiState?> = flowOf(null),
    val onValueUpSelected: () -> Boolean = { false },
    val onValueDownSelected: () -> Boolean = { false },
    val doOnBoarding: (String) -> Unit = {},
    val onScreenCloseRequest: (String) -> Unit = {} 
)

I made a method that generates those callbacks in the ViewModel, like this:

@HiltViewModel
class VolumeSettingsViewModel @Inject constructor() : BaseViewModel() {

    fun createViewModelCallbacks(): VolumeSettingsScreenCallbacks =
        VolumeSettingsScreenCallbacks(
            uiState = uiState,
            onValueUpSelected = ::onValueUpSelected,
            onValueDownSelected = ::onValueDownSelected,
            doOnBoarding = ::doOnBoarding,
            onScreenCloseRequest = ::onScreenCloseRequest
        )

 ....
}

In the NavHost I hoisted the creation of the ViewModel like this:

    @Composable
    @ExperimentalFoundationApi
    fun MyAppNavHost(
        speech: SpeechHelper,
        navController: NavHostController,
        startDestination: String = HOME.route,
    ): Unit = NavHost(
        navController = navController,
        startDestination = startDestination,
    ) {
        ...
    
        composable(route = Destination.VOLUME_SETTINGS.route) {
            hiltViewModel<VolumeSettingsViewModel>().run {
                VolumeSettingsScreen(
                    modifier = keyEventModifier,
                    speechCallbacks = speech.createCallback() // my function,
                    navigation callbacks = navController.createCallbacks(), //it is mine extension function                  
                    viewModelCallbacks = createViewModelCallbacks()
                )
            }
        }
    
        ...
    }

It is a bit complicated, but it works :D. I will be glad if there are some comets for improvements.

MeLean
  • 3,092
  • 6
  • 29
  • 43
4

Have you considered having a structure where you have a Screen and the actual Content separated like this?

// data class
data class AccountData(val accountInfo: Any?)

// composable "Screen", where you define contexts, viewModels, hoisted states, etc
@Composable
fun AccountScreen(viewModel: AccountViewModel = hiltViewModel()) {

    val accountData = viewModel.accountDataState.collectAsState()

    AccountContent(accountData = accountData) {
        // click callback
    }
}

//your actual composable that hosts your child composable widget/components
@Composable
fun AccountContent(
    accountData: AccountData,
    clickCallback: () ->
) {
   ...
}

where you can have a preview for the Content like this?

@Preview
@Composable
fun AccountContentPreview() {

    // create some mock AccountData
    val mockData = AccountData(…)
    AccountContent(accountData = mockData) {
         // I'm not expecting some actual ViewModel calls here, instead I'll just manipulate the mock data
    }
}

this way, all components that aren't needed to be configured by the actual content composable are separated, taking you off from headaches configuring a preview.

Just an added note and could be off-topic, I just noticed you have a parameter like this,

speech: SpeechHelper = SpeechHelper()

you might consider utilizing compositionLocalProvider (if needed), that could clean up your parameters.

z.g.y
  • 5,512
  • 4
  • 10
  • 36
  • 1
    Thank you for the answer, In Mobin Yardim's comment, a similar approach is suggested. But I think it is a workaround, not a solution to the problem. Because the Preview of the AccountScreen still will be broken because of the ViewModel injections – MeLean Nov 20 '22 at 15:36
  • I just read the link he provided, yes its a very similar suggestion, but If I may ask, view model injections such as..? – z.g.y Nov 20 '22 at 15:38
  • I mean when you have this: AccountScreen(viewModel: AccountViewModel = hiltViewModel()), the preview is not working. – MeLean Nov 20 '22 at 15:40
  • Ohh, that's actually the purpose of separating them, if you look carefully, the `AccountScreen` is not preview-able anymore, we created another separate composable `AccountContent` where we derive the "preview", with this kind of structure you won't need to preview composable "screens" anymore, only the "content" – z.g.y Nov 20 '22 at 15:47
  • 1
    Thanks for the comment, I got it. Thank you as for the `compositionLocalProvider` will check it as well – MeLean Nov 20 '22 at 15:48
  • Could you provide an example of how `AccountContentPreview` will handle and pass user interactions to the ViewModel – MeLean Nov 20 '22 at 16:33
  • @MeLean, if you meant [interactive mode](https://developer.android.com/jetpack/compose/tooling), it depends on what you would want, personally, I wouldn't expect an actual callback/click functionalities like a repository call in a preview, you can leave them on the preview empty (same thing you'll see in the examples from the link I provided) or you can make some modifications on the mock for those "interactions" such as click events. I made a minor edit on `AccountContentPreview`, adding a comment in it. – z.g.y Nov 21 '22 at 07:20
  • 1
    Thanks for the explanation, I see that we should use callbacks to handle user interactions (e.g. clicks, gestures and etc. )sooner or later :D. – MeLean Nov 21 '22 at 07:36
1

I found a solution that enables seeing preview that is optimal during development, but not so much in production code.

Make your viewmodel param in your compose nullable:

@Composable
fun VolumeSettingsScreen(
    viewModel: VolumeSettingsViewModel? = hiltViewModel(), ...)

Then, in your preview just pass a null param:

@Preview(showBackground = true)
@Composable
fun PreviewVolumeSettingsScreen() {
   VolumeSettingsScreen(null, ....)
}
    
Red M
  • 2,609
  • 3
  • 30
  • 50