Reputation: 3300
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)
...
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
}
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
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
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:
ViewModel
:
UiState
, presented to the @Composables
as a StateFlow<UiState>
, so that recomposition is triggered when the state changes.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.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
.Application
, so it is possible to access it from the Activity
or the factory of the ViewModel
.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.
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:
UiState
in the state that you want to preview.My solution is to extend MyViewModel
into a dummy MyPreviewModel
:
MyViewModel
UiState
instance, exposed as a StateFlow
via a get
accessor.mockk
(or Mockito, as you prefer) to create empty mocks of the dependency services.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
:
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.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
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
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.
Upvotes: 4
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.
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)
}
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)
}
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