androidandroid-jetpack-composeandroid-viewmodel

How to get preview in composable functions that depend on a view model?


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.


Solution

  • 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.