How to share a viewmodel between NavGraph components (only) - android

I would like to share a viewmodel between many composables. Just like how we share a viewmodel between fragments within an Activity.
But when I try this
setContent {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
navigation(startDestination = "username", route = "login") {
// FIXME: I get an error here
val viewModel: LoginViewModel = viewModel()
composable("username") { ... }
composable("password") { ... }
composable("registration") { ... }
}
}
}
I get an error
#Composable invocations can only happen from the context of a #Composable function
Need
The viewmodel should only be active in the NavGraph Scope.
When I go to a different route and come back I should initialize a new viewmodel (this is why I'm calling it in the NavGraph)
Almost similar solution
Answer by Philip Dukhov for the question How to share a viewmodel between two or more Jetpack composables inside a Compose NavGraph?
But in this approach the viewmodel stays in the scope of the activity that launched it and so is never garbage collected.

Solution 1
(copied from the docs)
The Navigation back stack stores a NavBackStackEntry not only for each individual destination, but also for each parent navigation graph that contains the individual destination. This allows you to retrieve a NavBackStackEntry that is scoped to a navigation graph. A navigation graph-scoped NavBackStackEntry provides a way to create a ViewModel that's scoped to a navigation graph, enabling you to share UI-related data between the graph's destinations. Any ViewModel objects created in this way live until the associated NavHost and its ViewModelStore are cleared or until the navigation graph is popped from the back stack.
This means we can use the NavBackStackEntry to get the scope of the navigation graph we are in and use that as the ViewModelStoreOwner to get the viewmodel for that scope.
Add this in every composable to get the BackStackEntry for login and then use that as the ViewModelStoreOwner to get the viewmodel.
val loginBackStackEntry = remember { navController.getBackStackEntry("login") }
val loginViewModel: LoginViewModel = viewModel(loginBackStackEntry)
So the final code changes to
setContent {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "home") {
navigation(startDestination = "username", route = "login") {
composable("username") {
val loginBackStackEntry = remember { navController.getBackStackEntry("login") }
val loginViewModel: LoginViewModel = viewModel(loginBackStackEntry)
...
}
composable("password") {
val loginBackStackEntry = remember { navController.getBackStackEntry("login") }
val loginViewModel: LoginViewModel = viewModel(loginBackStackEntry)
...
}
composable("registration") {
val loginBackStackEntry = remember { navController.getBackStackEntry("login") }
val loginViewModel: LoginViewModel = viewModel(loginBackStackEntry)
...
}
}
}
}
Solution 2
Copied from ianhanniballake answer
This can also be achieved using an extension
Get the current scope and get or create the viewmodel for that scope
#Composable
fun <reified VM : ViewModel> NavBackStackEntry.parentViewModel(
navController: NavController
): VM {
// First, get the parent of the current destination
// This always exists since every destination in your graph has a parent
val parentId = destination.parent!!.id
// Now get the NavBackStackEntry associated with the parent
val parentBackStackEntry = navController.getBackStackEntry(parentId)
// And since we can't use viewModel(), we use ViewModelProvider directly
// to get the ViewModel instance, using the lifecycle-viewmodel-ktx extension
return ViewModelProvider(parentBackStackEntry).get()
}
Then simply use this extension inside your navigation graph
navigate(secondNestedRoute, startDestination = nestedStartRoute) {
composable(route) {
val loginViewModel: LoginViewModel = it.parentViewModel(navController)
}
}

Related

Pass argument from navHost (Compose) to ViewModel with Dagger-Hilt

I have this on my NavHost:
composable(
ScreenRoutes.AstronautDetailsScreen.route,
arguments = listOf(navArgument("astronautId") { type = NavType.IntType })
) { backStackEntry ->
backStackEntry.arguments?.let {
AstronautDetailScreen(astronautDetailsViewModel)
}
}
I want the viewModel to receive the astronautId so it can communicate with the useCase and send a GET request with this dinamic id.
In the viewModel I've got this:
#HiltViewModel
class AstronautDetailsViewModel #Inject constructor(
astronautDetailsUseCase: AstronautDetailsUseCase,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val argument = checkNotNull(savedStateHandle.get<Int>("astronautId"))
init {
viewModelScope.launch {
_astronautDetails.value = astronautDetailsUseCase(argument)
}
}
The screen that I go to is this:
#Composable
fun AstronautDetailScreen(astronautDetailsViewModel: AstronautDetailsViewModel) {
val astronautDetails by astronautDetailsViewModel.astronautDetails.observeAsState(AstronautDetails())
But it always says that argument is null and crashes.
Can someone tell me please what could I'm be doing incorrectly?
In your NavHost, you are passing viewModel object to AstronautDetailScreen instead of injecting it by hiltViewModel().
You can change your composable function to inject the viewModel by hilt. In that way, your arguments will be available in SavedStateHandle of your view model class. You can change your composable function like below:
AstronautDetailScreen(
astronautDetailsViewModel: AstronautDetailsViewModel = hiltViewModel()
) {
// Your rest of the code here
}
Or, if you want to pass your view model instance from NavHost instead of default value of the composable function, Then you can modify your NavHost part like below:
composable(
ScreenRoutes.AstronautDetailsScreen.route,
arguments = listOf(navArgument("astronautId") { type = NavType.IntType })
) { backStackEntry ->
backStackEntry.arguments?.let {
val astronautDetailsViewModel: AstronautDetailsViewModel = hiltViewModel()
AstronautDetailScreen(astronautDetailsViewModel)
}
}

How to inject ViewModel into Activity using Hilt?

I have a ViewModel that I'm already injecting into a Composable. Now I want to inject the same instance of that ViewModel into my Activity. For example:
In AccountScreen.kt
#Composable
fun AccountScreen(accountViewModel: AccountViewModel = hiltViewModel()) {
...
}
and my Activity class:
class MainActivity : ComponentActivity() {
#Inject
lateinit var accountViewModel: AccountViewModel
}
should have the same instance of AccountViewModel.
I know using #Inject in the Activity as in the example above doesn't work. Hilt's documentation suggests using ViewModelProvider or by viewModels() instead, both of which give me a new instance of AccountViewModel, but I need the same instance as what's in the AccountScreen Composable.
I'm assuming AccountScreen is part of a NavGraph, since you mentioned you need same instance of the view model, you can consider specifying the ViewModelStoreOwner when you inject your ViewModel in your AccountScreen, so MainActivity and AccountScreen will share same instance of it.
#Composable
fun MyNavHost(
...
) {
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
}
NavHost(
modifier = modifier,
navController = navController,
startDestination = startDestination
) {
composable(<Destination>) {
AccountScreen(accountViewModel: AccountViewModel = hiltViewModel(viewModelStoreOwner)) {
...
}
}
...
}
}
I ended up solving this by getting the parent Activity's ViewModel in my child Composable (AccountScreen in this case) like so:
val composeView = LocalView.current
val activityViewModel = composeView.findViewTreeViewModelStoreOwner()?.let {
hiltViewModel<MyViewModel>(it)
}
Within my MainActivity I'm getting the ViewModel the standard way
private val accountViewModel: AccountViewModel by viewModels()
Thanks to #z.g.y for providing a helpful suggestion that led me to this solution.

How is better to inject a ViewModel object when using Jetpack Compose?

I need to use navigation, and I also need in each screen to use an instance of SharedViewModel. Here is what I tried.
class MainActivity : ComponentActivity() {
private lateinit var navController: NavHostController
private val viewModel: SharedViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
navController = rememberNavController()
NavGraph(
navController = navController,
sharedViewModel = sharedViewModel
)
}
}
}
As you can see, I pass the navController and the sharedViewModel to the NavGraph.
fun NavGraph(
navController: NavHostController,
sharedViewModel: SharedViewModel
) {
NavHost(
navController = navController,
startDestination = HomeScreen.route
) {
composable(
route = HomeScreen.route
) {
HomeScreen(
sharedViewModel = sharedViewModel
)
}
composable(
route = ProfileScreen.route
) {
ProfileScreen(
sharedViewModel = sharedViewModel
)
}
}
}
To be able to use the SharedViewModel in each screen, I pass an instance to each composable function. This operation works fine. However, I read that we can inject in each composable an instance of the view model directly using:
fun HomeScreen(
viewModel: SharedViewModel = hiltViewModel()
) {
//...
}
Which approach is better? Is it better to pass an instance of SharedViewModel to all composable functions as in the first approach? Or it is better to inject it directly as in the second?
fun HomeScreen(
viewModel: SharedViewModel = hiltViewModel()
) {
//...
}
With this approach The instance is not really shared (if you do not pass the argument from calling point since it can be omitted because you mentioned its default value) . You are using default value argument for viewModel: SharedViewModel So its optional to pass it to the Composable method . if you do not pass it and when it runs it will get initialized by Hilt In that Composable Scope Only So not a shared one.
you can check this by logging the ViewModel's instance
You can obviously pass it from the calling point but since its a default named_argument its easy to miss to pass it ..
What you can do is just remove the initialization i.e hiltViewModel() from method argument . Then it will be mandatory and you have to pass it while calling the method. Because having a optional parameter doesn't really make sense in this case.
There is an another way of doing it if you do not want to pass it ..
We can make hilt to create ViewModel with Activity's context ..
#Composable
fun mainActivity() = LocalContext.current as MainActivity
fun HomeScreen(viewModel: SharedViewModel = hiltViewModel(mainActivity())) {
}
This way also you will get same instance of VM hence a shared one . In this case this composable is kind of restricted to a Single Activity . So u gotta watch out for it if u use this in some other Activity it will crash with cast exception for MainActivity . But in Single Activity architecture it will be fine or u can just further add the checks for Activity i guess.

How can I have different states with different viewmodels?

I am making an app where the user first need to login to be able to get alot of different data from a backend. (many endpoints)
So I have one viewmodel for the login, and I have alot of viewmodels for all the other data.
The other viewmodels require the token from the first viewmodel to be able to get data from the backend.
I don't know how I can do this.
I was thinking that I can have my login screen in a kind of state manager which will direct the UI to the correct screen like this
#ExperimentalComposeUiApi
#Composable
fun LoginState(vm: AuthViewModel, nc: NavController) {
val token by vm.token.collectAsState()
when (token) {
is Resource.Loading -> {
LoadingScreen()
}
is Resource.Success -> {
Scaffold(vm = vm)
}
is Resource.Error -> {
LoginScreen(vm = vm)
}
}
}
But then I would have to create the viewmodels inside the Scaffold which is a composable function, and that is not possible.
Another thought was to use Hilt to do some kind of magic dependency injection, and then put all the viewmodels into a ViewModelManager in the MainActivity and then inject the Token into the repositories of each viewmodel when login is successfull.
#AndroidEntryPoint
class MainActivity : ComponentActivity() {
private val authViewModel: AuthViewModel by viewModels()
private val userViewModel: UserViewModel by viewModels()
private val venueViewModel: VenueViewModel by viewModels()
private val eventViewModel: EventViewModel by viewModels()
private val viewModelManager = ViewModelManager(
userViewModel = userViewModel,
authViewModel = authViewModel,
venueViewModel = venueViewModel,
eventViewModel = eventViewModel,
)
#ExperimentalMaterialApi
#ExperimentalComposeUiApi
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MoroAdminTheme {
// A surface container using the 'background' color from the theme
Surface(color = MaterialTheme.colors.background) {
ScaffoldExample(viewModelManager)
}
}
}
}
}
However I have no idea how to do this or if it is even possible - or a good solution.
Problem: you want to share a value (token) to all of your view model
your token retrieved in AuthViewModel and need to share it to the other viewModels
you can make your data in the other viewModels changes when the token changes
by using datastore Preferences see implementation
Datastore preferences provides you with a flow of values whenever the value changes
Create a DatastoreManager Class
val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
#Singleton
class DatastoreManager #Inject constructor(#ApplicationContext context: Context) {
private val dataStore = context.dataStore
val dataFlow = dataStore.data
.map { preferences ->
val token = preferences[PreferencesKeys.TOKEN]
}
suspend fun updateToken(token: String) {
dataStore.edit { preferences ->
preferences[PreferencesKeys.TOKEN] = token
}
}
private object PreferencesKeys {
val TOKEN = preferencesKey<String>("token")
}
}
In AuthViewModel
Inject the DatastoreManager and set the token after login
datastore.updateToken(newToken)
In other ViewModels
Inject the DatastoreManager and use it's value
//this is a flow of tokens and will recive the token when you set it
val token = datastore.token
// if you are not familiar with flows and using only LiveData
val token = datastore.token.asLiveData()
// use the token to get the data from backend
val data = token.map {
// this code will trigger every time the token changes
yourGetDataFromBackendFunction(it)
}
But then I would have to create the viewmodels inside the Scaffold which is a composable function, and that is not possible.
This is not true. You don't have to create view models in your Activity.
In any composable you can use viewModel()
Returns an existing ViewModel or creates a new one in the given owner (usually, a fragment or an activity), defaulting to the owner provided by LocalViewModelStoreOwner.
So you don't need any ViewModelManager. Inside any composable you can use viewModel() with the corresponding class. In your case you're using Hilt, you should use hiltViewModel() instead: it'll also initialize your injections.
#Composable
fun AuthScreen(viewModel: AuthViewModel = hiltViewModel()) {
}
Or like this:
#Composable
fun VenueScreen() {
val viewModel: VenueViewModel = hiltViewModel()
}
First approach will allow you to easily test your screen with mock view model, without passing any arguments in your production code.
Check out more about view models in view models documentation and hilt documentation
As to your token question, you can pass it with injections. I don't think that your view model really needs the token, probably you should have some network manager which will use the token to make requests. And this network manager should use injection of some token provider.

Cannot create an instance of viewmodel while using Jetpack Compose navigation

I am using navigation component for jetpack compose in my app like this:
#Composable
fun FoodiumNavigation() {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.Main.route,
) {
composable(Screen.Main.route) {
MainScreen(navController)
}
...
}
}
And I am getting viewmodel in my MainScreen composable like this:
#Composable
fun MainScreen(navController: NavController) {
val mainViewModel: MainViewModel = viewModel()
...
}
which is giving me a runtime exception as Cannot create an instance of class com.package.main.MainViewModel.
Here, I am stating that this only happens while using navigation component, i.e. everything was working fine and mainViewModel was successfully instantiated before using navigation component in my app.
The MainViewModel is like this:
#ExperimentalCoroutinesApi
#HiltViewModel
class MainViewModel #Inject constructor(private val postRepository: PostRepository) :
ViewModel() {
private val _postsLiveDataState = MutableLiveData<UiState<List<Post>>>()
val postLiveState: LiveData<UiState<List<Post>>> = _postsLiveDataState
init {
getPostsState()
}
private fun getPostsState() {
viewModelScope.launch {
postRepository.getAllPosts()
.onStart { _postsLiveDataState.value = UiState(loading = true) }
.map { resource -> UiState.fromResource(resource) }
.collect { state -> _postsLiveDataState.value = state }
}
}
}
If your #HiltViewModel is scoped to the navigation graph use hiltNavGraphViewModel() instead of viewModel() to initialize. For more reference android documentaion
Update
hiltNavGraphViewModel() is now deprecated, use hiltViewModel() instead
Thanks to Narek Hayrapetyan for the reminder
hiltNavGraphViewModel is deprecated, should be used hiltViewModel() instead
also add dependency androidx.hilt:hilt-navigation-compose:1.0.0-alpha03
You should add this
implementation("androidx.hilt:hilt-navigation-compose:1.0.0")
then you can use this code for create instance of your viewmodel
val viewModel: YourViewModelClass= hiltViewModel()
You can use viewModel() as well, but check that owning Activity or Fragment has been annotated with #AndroidEntryPoint.

Categories

Resources