Pierre Vieira
Pierre Vieira

Reputation: 3300

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.

Upvotes: 14

Views: 9089

Answers (4)

Daniel Hawkins
Daniel Hawkins

Reputation: 67

I ended up creating my own viewModel() function which then checks for a CompositionLocal to see if it is in a preview.

Not super happy having to have this be in production code but can always comment when building production. If anyone can think of how this can be done at compile time that would be amazing. Alternatively I would love if we could instead do this with Hilt itself but I could not figure out how

// replaces the viewModel() or hiltViewModel() call
@Composable
inline fun <reified T : ViewModel> myViewModel() : T {
    if (LocalIsPreview.current) {
        when (T::class) {
            ApiTokenSettingsViewModel::class -> {
                return ApiTokenSettingsViewModelStub() as T
            }
            else -> {
                throw IllegalArgumentException("Unknown ViewModel type " + T::class)
            }
        }
    } else {
        return hiltViewModel()
    }
}

val LocalIsPreview = compositionLocalOf { false }

This now means my settings page can use the ApiTokenSettingsView() without needing to prop drill the view model

@Composable
fun SettingsPage() {
    Column {
        ApiTokenSettingsView()
    }
}

@Preview(showBackground = true)
@Composable
fun SettingsPagePreview() {
    CompositionLocalProvider(LocalIsPreview provides true) {
        SettingsPage()
    }
}

The code that uses the view then retrieves it using myViewModel()

@Composable
fun ApiTokenSettingsView(
    onTokenSaved: () -> Unit = {Unit},
) {
    var viewModel: ApiTokenSettingsViewModel =  myViewModel()
...
}

The view model i then have as abstract class of ViewModel()

abstract class ApiTokenSettingsViewModel : ViewModel() {
    abstract fun saveApiToken(apiToken: String)
}

@HiltViewModel
class ApiTokenSettingsViewModelImpl @Inject constructor(
    private val tokenManager: TokenManager
) : ApiTokenSettingsViewModel() {

    override fun saveApiToken(apiToken: String) {
        viewModelScope.launch {
            tokenManager.storeToken(apiToken);
        }
    }
}

//The one the preview uses
class ApiTokenSettingsViewModelStub : ApiTokenSettingsViewModel() {
    override fun saveApiToken(apiToken: String) {
        TODO("Not yet implemented")
    }
}

Upvotes: 0

jmgonet
jmgonet

Reputation: 1261

Other answers are perfectly usable, but this is my take on this subject. If your project is organized similar to mine, then you may find it useful.

This is my context:

  • The ViewModel:
    • It describes the state of the user interface in a specific class, the UiState, presented to the @Composables as a StateFlow<UiState>, so that recomposition is triggered when the state changes.
    • The composables call methods in the ViewModel to notify of user actions. All (or most) of these methods return Unit (void), but perform some kind of operation that probably ends in a change of state, which will trigger recomposition.
    • The composable needing an instance of the ViewModel can request it from a dedicated Factory, which is a static method present somewhere in the project (typically as a companion object directly in the ViewModel class). The factory can obtain instances of services via its easy access to Application, which holds the Container.
  • The container
    • It's a class made accessible via the Application, so it is possible to access it from the Activity or the factory of the ViewModel.
    • It provides instances of services, already configured and ready to use, with all their dependencies resolved.
  • The services
    • They're described as interfaces / implementation pairs.
    • ViewModel only depends on interfaces.

This kind of architecture is promoted in the «Android Basics with Compose» tutorial from Google (see the AppContainer class in inventory or mars photos examples). It is fairly classical, so (I hope) there is a high probability that you have structured your application in a variation of this manner.

Application / Activity / Container architecture

The composable expresses its dependency to ViewModel in a very specific way - a default value of an optional parameter. Then propagates the UiState or part of it to other composables:

@Composable
fun MyComposable(
    modifier: Modifier = Modifier,
    viewModel: SiCausViewModel = viewModel(factory = SiCausViewModel.Factory)) {

    val uiState by viewModel.uiState.collectAsState()

    NiceCard(
        onClick = { viewModel.userAction1() },
        param = uiState.param1,
        modifier = modifier.xxx)
    SmartCard(
        onSelect = { selection -> viewModel.userAction2(selection)},
        param = uiState.param2,
        modifier = modifier.yyy)
}

Previewing NiceCard and SmartCard is easy - you just provide fixed values for the value parameters and empty stubs for the method parameters.

The question is how to preview MyComposable. Initializing a MyViewModel is not easy:

  • The view model is dependent on other services, so you need to mock them.
  • Plus, you need to mock the data returned by the services in a way that puts UiState in the state that you want to preview.

My solution is to extend MyViewModel into a dummy MyPreviewModel:

  • Extend MyViewModel
  • Constructor has only one parameter - a UiState instance, exposed as a StateFlow via a get accessor.
  • Use mockk (or Mockito, as you prefer) to create empty mocks of the dependency services.
  • Override all methods so they do nothing.
class MyPreviewModel(private val _uiState: UiState): MyViewModel(
    service1 = mockk<Service1>(),
    service2 = mockk<Service2>()) {

    override val uiState get() = MutableStateFlow(_uiState).asStateFlow()

    override fun userAction1() {
        // do nothing
    }

    override fun userAction2(selection: Int) {
        // do nothing
    }
}

This solution has three drawbacks, or additional requirements that have an impact outside MyPreviewModel:

  • The ancestor, MyViewModel, has to be an open class, and all methods have to be open fun as well, including open val uiState. This may even raise a «_Calling non-final function in constructor_» inspection warning if you call any method in the init section.
  • You have to include mockk as a complete dependency, not only for tests.

If you accept the drawbacks, the preview code is simple to follow, and has no dependency with mock data :

@Preview
@Composable
fun MyComposablePreview() {
    MyComposable(
        modifier = Modifier.mmm,
        viewModel = MyPreviewModel(
            MyViewModel.UiState(
                param1 = xxx,
                param2 = yyy)))
}

You can add more previews of the same component, varying the value of UiState to see your components in all its meaningful states.

Upvotes: 0

Chetan Gupta
Chetan Gupta

Reputation: 1647

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

Upvotes: 4

Phil Dukhov
Phil Dukhov

Reputation: 87774

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.

Upvotes: 12

Related Questions