11

Problem description

I would like to have the preview of my HomeScreen composable function in my HomeScreenPrevieiw preview function. However this is not being possible to do because I am getting the following error:

java.lang.IllegalStateException: ViewModels creation is not supported in Preview
    at androidx.compose.ui.tooling.ComposeViewAdapter$FakeViewModelStoreOwner$1.getViewModelStore(ComposeViewAdapter.kt:709)
    at androidx.lifecycle.ViewModelProvider.<init>(ViewModelProvider.kt:105)
    at androidx.lifecycle.viewmodel.compose.ViewModelKt.get(ViewModel.kt:82)
    at androidx.lifecycle.viewmodel.compose.ViewModelKt.viewModel(ViewModel.kt:72)
    at com.example.crud.ui.screens.home.HomeScreenKt.HomeScreen(HomeScreen.kt:53)
    at com.example.crud.ui.screens.home.HomeScreenKt.HomeScreenPreview(HomeScreen.kt:43)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    ...

My code

This is my HomeScreen code:

@Composable
fun HomeScreen(
    viewModel: HomeViewModel = hiltViewModel(),
    navigateToDetailsAction: () -> Unit,
    openCardDetailsAction: (Int) -> Unit
) {
    val cities = viewModel.cities.observeAsState(listOf())
    Scaffold(
        topBar = { HomeAppBar() },
        floatingActionButton = { HomeFab(navigateToDetailsAction) }
    ) {
        HomeContent(cities) { id -> openCardDetailsAction(id) }
    }
}

This is the code for my preview function:

@Preview
@Composable
private fun HomeScreenPreview() {
    HomeScreen(navigateToDetailsAction = {}, openCardDetailsAction = {})
}

My view model:

@HiltViewModel
class HomeViewModel @Inject constructor(repository: CityRepository) : ViewModel() {
    val cities: LiveData<List<City>> = repository.allCities.asLiveData()
}

Repository:

@ViewModelScoped
class CityRepository @Inject constructor(appDatabase: AppDatabase) {
    private val dao by lazy { appDatabase.getCityDao() }

    val allCities by lazy { dao.getAllCities() }

    suspend fun addCity(city: City) = dao.insert(city)

    suspend fun updateCity(city: City) = dao.update(city)

    suspend fun deleteCity(city: City) = dao.delete(city)

    suspend fun getCityById(id: Int) = dao.getCityById(id)

}

AppDatabase:

@Database(entities = [City::class], version = 2, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
    abstract fun getCityDao() : CityDao
}

My failed attempt

I thought it might be a problem with the view model being passed as the default parameter of my HomeScreen and so I decided to do it this way:

@Composable
fun HomeScreen(
    navigateToDetailsAction: () -> Unit,
    openCardDetailsAction: (Int) -> Unit
) {
    val viewModel: HomeViewModel = hiltViewModel()
    val cities = viewModel.cities.observeAsState(listOf())
    Scaffold(
        topBar = { HomeAppBar() },
        floatingActionButton = { HomeFab(navigateToDetailsAction) }
    ) {
        HomeContent(cities) { id -> openCardDetailsAction(id) }
    }
}

But it still doesn't work (I keep getting the same error), and it's not good for testing as it would prevent me from testing my HomeScreen with a mocked view model.

Pierre Vieira
  • 2,252
  • 4
  • 21
  • 41
  • 1
    You could pass `cities` into `HomeScreen()` (in your second code snippet) and eliminate the dependency on the viewmodel. – CommonsWare Sep 07 '21 at 14:20
  • 1
    This is a really good idea, however it could be a problem if my `HomeScreen` had any more dependencies tied to `HomeViewModel`. Or, ideally, a screen does not need to worry about the view model that is "related" to it? – Pierre Vieira Sep 07 '21 at 14:37
  • 2
    Google's Jim Sproch has stated on a couple of occasions that you should minimize the number of composables into which you pass a `ViewModel`. More generally, `@Preview` is designed for "leaf" composables, not full screens. To translate into classic Android patterns, you can preview custom views more readily than you can preview fragments or activities. There are workarounds for this, such as having the viewmodel implement an interface, have the composable depend on the interface, and use a one-off implementation of the interface for the `@Preview`. – CommonsWare Sep 07 '21 at 14:43

2 Answers2

10

This is exactly one of the reasons why the view model is passed with a default value. In the preview, you can pass a test object:

@Preview
@Composable
private fun HomeScreenPreview() {
    val viewModel = HomeViewModel()
    // setup viewModel as you need it to be in the preview
    HomeScreen(viewModel = viewModel, navigateToDetailsAction = {}, openCardDetailsAction = {})
}

Since you have a repository, you can do the same thing you would do to test the view model.

  1. Create interface for CityRepository
interface CityRepositoryI {
    val allCities: List<City>

    suspend fun addCity(city: City)
    suspend fun updateCity(city: City)
    suspend fun deleteCity(city: City)
    suspend fun getCityById(id: Int)
}
  1. Implement it for CityRepository:
@ViewModelScoped
class CityRepository @Inject constructor(appDatabase: AppDatabase) : CityRepositoryI {
    private val dao by lazy { appDatabase.getCityDao() }

    override val allCities by lazy { dao.getAllCities() }

    override suspend fun addCity(city: City) = dao.insert(city)

    override suspend fun updateCity(city: City) = dao.update(city)

    override suspend fun deleteCity(city: City) = dao.delete(city)

    override suspend fun getCityById(id: Int) = dao.getCityById(id)
}
  1. Create FakeCityRepository for testing purposes:
class FakeCityRepository : CityRepositoryI {
    // predefined cities for testing
    val cities = listOf(
        City(1)
    ).toMutableStateList()

    override val allCities by lazy { cities }

    override suspend fun addCity(city: City) {
        cities.add(city)
    }

    override suspend fun updateCity(city: City){
        val index = cities.indexOfFirst { it.id == city.id }
        cities[index] = city
    }

    override suspend fun deleteCity(city: City) {
        cities.removeAll { it.id == city.id }
    }

    override suspend fun getCityById(id: Int) = cities.first { it.id == id }
}

So you can pass it into your view model: HomeViewModel(FakeCityRepository())

You can do the same with AppDatabase instead of a repository, it all depends on your needs. Check out more about Hilt testing

p.s. I'm not sure if this will build, since I don't have some of your classes, but you should have caught the idea.

Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
  • I use hilt and when I do like `val viewModel = hiltViewModel()` in the preview method, it still says `ViewModels creation is not supported in Preview` – Dr.jacky Oct 03 '21 at 11:40
  • 4
    @Dr.jacky As I said in my answer, from Preview you should pass `FakeCityRepository()` instead of calling `hiltViewModel` – Phil Dukhov Oct 03 '21 at 12:10
4

Hi like @Philip Dukhov has explained in his answer is correct and ideally should be done this way.

But I would like to suggest a workaround cause it requires a lot of setups like fakes and manually creating intermediate objects.

You can get your preview working on the emulator by using a custom run configuration and using a specific Activity as PreviewActivity with @AndroidEntryPoint Annotation.

You can follow a detailed guide with screen shot and internals from the blog I have published from here

Or simply you can

enter image description here

enter image description here

enter image description here

Activity Needs to have

@AndroidEntryPoint
class HiltPreviewActivity : AppCompatActivity() {
....
}

you need to manually copy-paste preview composable into setContent{..} of the HiltPreviewActivity.

Run from the toolbar, not from preview shortcut, check guide for mode details.

enter image description here

Chetan Gupta
  • 1,477
  • 1
  • 13
  • 22