Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 843
Pierre Vieira Avatar asked Oct 15 '22 20:10

Pierre Vieira


2 Answers

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.

like image 143
Philip Dukhov Avatar answered Oct 22 '22 12:10

Philip Dukhov


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

like image 45
Chetan Gupta Avatar answered Oct 22 '22 11:10

Chetan Gupta