My app uses hilt and I have some work with LoadManager inside my activity that read contacts using ContentResolver and when I finish work I get the cursor that I send to my viewModel in order to process the data and do some business logic which for that I declared the following on top of my activity :
#AndroidEntryPoint
class MainActivity : ComponentActivity(), LoaderManager.LoaderCallbacks<Cursor> {
private val contactsViewModel: ContactsViewModel by viewModels()
...
such that I use it inside onLoadFinished :
override fun onLoadFinished(loader: Loader<Cursor>, cursor: Cursor?) {
contactsViewModel.updateContactsListFromCursor(cursor, loader.id)
}
Inside my viewModel I have the following code which updates the ui state of the list with the contacts to be displayed:
data class ContactsListUiState(
val contacts: MutableList<Contact>,
val searchFilter: String)
#HiltViewModel
class ContactsViewModel #Inject constructor() : ViewModel() {
private val _contactsListUiState =
MutableStateFlow(ContactsListUiState(mutableStateListOf(), ""))
val contactsListUiState: StateFlow<ContactsListUiState> = _contactsListUiState.asStateFlow()
private fun updateContactsList(filter: String) {
viewModelScope.launch(Dispatchers.IO) {
...
_contactsListUiState.update { currentState ->
currentState.copy(contacts = list, searchFilter = filter)
}
}
Finally, I am supposed to display the contacts that a LazyColumn and I pass the viewModel to my composable function using hilt following the official documentation :
#Composable
fun ContactsListScreen(
navController: NavController,
modifier: Modifier = Modifier, viewModel: ContactsViewModel = hiltViewModel()
) {
val uiState by viewModel.contactsListUiState.collectAsStateWithLifecycle()
...
But when i access uiState.contacts it is empty and my lists does not show anything and I also noticed that the contactsViewModel which I used in the activity is not the same viewModel instance that I got from hiltViewModel() inside the composable function which probably causes this problem..
Any suggestions how to share the sameViewModel between the activity and the composable functions assuming that I have to call the viewModel from the onLoadFinished function(which is not composable) where I get the cursor therefore I must have a viewModel reference inside the activity itself
Based on the docs.
The function hiltViewModel() returns an existing ViewModel or creates
a new one scoped to the current navigation graph present on the
NavController back stack. The function can optionally take a
NavBackStackEntry to scope the ViewModel to a parent back stack entry.
It turns out the factories create a new instance of the ViewModel when they are part of a Navigation Graph. But since you already found out that to make it work you have to specify the ViewModelStoreOwner, so I took an approach based my recent answer from this post, and created a CompositionLocal of the current activity since its extending ComponentActivity being it as a ViewModelStoreOwner itself.
Here's my short attempt that reproduces your issue with the possible fix.
Activity
#AndroidEntryPoint
class HiltActivityViewModelActivity : ComponentActivity() {
private val myViewModel: ActivityScopedViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
CompositionLocalProvider(LocalActivity provides this#HiltActivityViewModelActivity) {
Log.e("ActivityScopedViewModel", "Hashcode: ${myViewModel.hashCode()} : Activity Scope")
HiltActivitySampleNavHost()
}
}
}
}
ViewModel
#HiltViewModel
class ActivityScopedViewModel #Inject constructor(): ViewModel() {}
Local Activity Composition
val LocalActivity = staticCompositionLocalOf<ComponentActivity> {
error("LocalActivity is not present")
}
Simple Navigation Graph
enum class HiltSampleNavHostRoute {
DES_A, DES_B
}
#Composable
fun HiltActivitySampleNavHost(
modifier: Modifier = Modifier,
navController: NavHostController = rememberNavController(),
startDestination: String = HiltSampleNavHostRoute.DES_A.name
) {
NavHost(
modifier = modifier,
navController = navController,
startDestination = startDestination
) {
composable(HiltSampleNavHostRoute.DES_A.name) {
DestinationScreenA()
}
composable(HiltSampleNavHostRoute.DES_B.name) {
DestinationScreenB()
}
}
}
Screens
// here you can use the Local Activity as the ViewModelStoreOwner
#Composable
fun DestinationScreenA(
myViewModelParam: ActivityScopedViewModel = hiltViewModel(LocalActivity.current)
// myViewModelParam: ActivityScopedViewModel = viewModel(LocalActivity.current)
) {
Log.e("ActivityScopedViewModel", "Hashcode: ${myViewModelParam.hashCode()} : Composable Scope")
}
#Composable
fun DestinationScreenB(
modifier: Modifier = Modifier
) {}
Or better yet, like from this answer by Phil Dukhov, you can use LocalViewModelStoreOwner as the parameter when you invoke the builder.
Same NavHost
#Composable
fun HiltActivitySampleNavHost(
...
) {
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
}
NavHost(
modifier = modifier,
navController = navController,
startDestination = startDestination
) {
composable(HiltSampleNavHostRoute.DES_A.name) {
DestinationScreenA(
myViewModelParam = viewModel(viewModelStoreOwner)
)
}
...
}
}
Both logs from the activity and the composable in the nav graph shows the same hashcode
E/ActivityScopedViewModel: Hashcode: 267094635 : Activity Scope
E/ActivityScopedViewModel: Hashcode: 267094635 : Composable Scope
Also have a look at Thracian's answer. It has a very detailed explanation about ComponentActivity, and based from it I think my first proposed solution would probably work in your case.
Related
I am getting this error when trying to navigate to another screen from the view model,
kotlin.UninitializedPropertyAccessException: lateinit property _navController has not been initialized
This is my activity code,
#AndroidEntryPoint
class MainActivity : ComponentActivity() {
#Inject
lateinit var navigator: Navigator
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AssessmentAppTheme {
// A surface container using the 'background' color from the theme
Column(modifier = Modifier
.fillMaxWidth(1f)
.padding(vertical = 10.dp, horizontal = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
AssessmentApp(modifier = Modifier.padding(bottom = 40.dp))
NavigationGraph(navigator)
}
}
}
}
}
This is my navigation module,
#Module
#InstallIn(ActivityRetainedComponent::class)
class AppModule {
#Provides
fun providesNavigation() = Navigator()
}
This is my navigation class,
#ActivityRetainedScoped
class Navigator {
private lateinit var _navController: NavHostController
fun navigate(destination: NavigationDestination) {
_navController.navigate(destination.route)
}
fun setController(controller: NavHostController) {
_navController = controller
}
}
this is the navigation graph where I am remembering the navController,
#Composable
fun NavigationGraph(
navigator: Navigator
) {
val navController = rememberNavController()
navigator.setController(navController)
NavHost(navController = navController, startDestination = Routes.CLIENTS_ROUTE ) {
composable(Routes.CLIENTS_ROUTE) {
val viewModel = hiltViewModel<ClientViewModel>()
ClientScreen(viewModel = viewModel)
}
composable(Routes.ASSESSMENT_OPTIONS_ROUTE, arguments = listOf(navArgument(RouteArgs.CLIENT_ID) {type = NavType.StringType})) { backStackEntry ->
val viewModel = hiltViewModel<ClientViewModel>()
ClientAssessmentOptionScreen(viewModel = viewModel)
}
}
finally, this is one of view models trying to navigate to different screen,
#HiltViewModel
class ClientViewModel #Inject constructor(
private val repository: IClientRepository,
private val navigator: Navigator,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
// Some code here //
fun onEvent(event: ClientEvent) {
viewModelScope.launch {
when(event) {
is ClientEvent.OnClientClicked -> {
event.client.clientName?.let {
navigator.navigate(
NavigationDestination(Routes.generateAssessmentOptionsRoute(clientId = it))
)
}
}
}
}
}
}
What am I doing wrong here? and is the approach to make view models handle navigation the right one for jetpack compose applications?
Just answering it here in case someone else also stumbles upon this. I have modified my navigator class and added a shared flow. Which would be used sort of as an event emitter. Whenever we would want to navigate to another screen we can use the navigate method which would emit the route destination.
#Singleton
class Navigator {
private val _sharedFlow =
MutableSharedFlow<NavigationDestination>(extraBufferCapacity = 1)
val sharedFlow = _sharedFlow.asSharedFlow()
fun navigate(destination: NavigationDestination) {
_sharedFlow.tryEmit(destination)
}
}
Now in the NavigationGraph, I have remembered the NavController and have also added a launchedEffect coroutine, which would be listening to the navigate events from the flow. For each flow event, we will trigger the NavController to navigate to that emitted destination.
#Composable
fun NavigationGraph(
navController: NavHostController = rememberNavController(),
navigator: Navigator
) {
LaunchedEffect("navigation") {
navigator.sharedFlow.onEach {
navController.navigate(it.route)
}.launchIn(this)
}
NavHost(navController = navController, startDestination = Routes.CLIENTS_ROUTE ) {
// some code here... //
}
}
I have been using StateFlow + sealed interfaces to represent the various UI states in my Android app. In my ViewModel I have a sealed interface UiState that does this, and the various states are exposed as a StateFlow:
sealed interface UiState {
class LocationFound(val location: CurrentLocation) : UiState
object Loading : UiState
// ...
class Error(val message: String?) : UiState
}
#HiltViewModel
class MyViewModel #Inject constructor(private val getLocationUseCase: GetLocationUseCase): ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState
// ...
}
Then in a Composable, I observe the events in this manner:
#Composable
fun MyScreen(
viewModel: HomeScreenViewModel,
onLocationFound: (CurrentLocation) -> Unit,
onSnackbarButtonClick: () -> Unit
) {
// ...
LaunchedEffect(true) { viewModel.getLocation() }
when (val state = viewModel.uiState.collectAsState().value) {
is UiState.LocationFound -> {
Log.d(TAG, "MyScreen: LocationFound")
onLocationFound.invoke(state.location)
}
UiState.Loading -> LoadingScreen
// ...
}
}
In my MainActivity.kt, when onLocationFound callback is invoked, I am supposed to navigate to another destination (Screen2) in the NavGraph:
enum class Screens {
Screen1,
Screen2,
// ...
}
#AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
MyTheme {
MyNavHost(navController = navController)
}
}
}
}
#Composable
fun MyNavHost(navController: NavHostController) {
val context = LocalContext.current
NavHost(navController = navController, startDestination = Screens.Home.name) {
composable(Screens.Screen1.name) {
val viewModel = hiltViewModel<MyViewModel>()
MyScreen(viewModel = viewModel, onLocationFound = {
navController.navigate(
"${Screens.Screen2.name}/${it.locationName}/${it.latitude}/${it.longitude}"
)
}, onSnackbarButtonClick = { // ... }
)
}
// ....
composable("${Screens.Screen2.name}/{location}/{latitude}/{longitude}", arguments = listOf(
navArgument("location") { type = NavType.StringType },
navArgument("latitude") { type = NavType.StringType },
navArgument("longitude") { type = NavType.StringType }
)) {
// ...
}
}
}
But what happens is that the onLocationFound callback seems to be hit multiple times as I can see the logging that I've placed show up multiple times in Logcat, thus I navigate to the same location multiple times resulting in an annoying flickering screen. I checked that in MyViewmodel, I am definitely not setting _uiState.value = LocationFound multiple times. Curiously enough, when I wrap the invocation of the callback with LaunchedEffect(true), LocationFound gets called only two times, which is still weird but at least there's no flicker.
But still, LocationFound should only get called once. I have a feeling that recomposition or some caveat with Compose navigation is in play here but I've researched and can't find the right terminology to look for.
I have a home screen in my application that is basically content with a navigation bar
Each of the three selections of the navigation bar lead to a different screen, so the code looks like this:
#Composable
fun HomeScreen(state: HomeState, event: (HomeEvent) -> Unit) {
val navController = rememberNavController()
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
BottomNavigation { .... //add the three bottom navigation menu items
}
},
) {
NavHost(
navController = navController,
startDestination = "news",
) {
composable(route = "news") {
val newsVm: NewsViewModel = hiltViewModel()
NewsScreen(newsVm)
}
composable(route = "tickets") { NewTicketScreen() }
composable(route = "archive") { ArchiveScreen() }
}
}
}
this works correctly
this homescreen is used by the following composeable to actually draw the screen
#Composable
fun HomeScreen(
vm: HomeViewModel = hiltViewModel()
) {
val state = vm.state.value
HomeScreen(state, vm::process )
}
so HomeScreen has its own viewmodel
in this example let us take the NewsScreen which takes as an argument its own viewmodel
What this viewmodel will do is load news articles and show them to the user. But in order to not have to reload data every time the user changes the shown screen, what I would do before compose, is pass the homeViewModel as an argument to the newsViewModel.
Home would contain the data loaded up to now and expose it to its children.
and news would load data and save the loaded data in homeViewmodel
so it would go something like this
class HomeViewModel()..... {
internal val newsArticles = mutableListOf()
}
class NewsViewModel() ..... {
val parent :HomeViewModel = ????
val list = mutableStateOf<List<NewsArticle>>(listOf())
init {
val loaded = parent.newsArticles
loadData(loaded)
}
fun loadData(loaded :List<NewsArticle>) {
if (loaded.isEmpty()) {
list.value = repo.loadNews()
} else {
list.value = loaded
}
}
}
I know that I could do the above in my repository, and have it do the caching, but I also use the homeViewModel for communication between the screens , and if the user has to log in , the app uses the MainActivity's navController to start a new screen where the user will log in.
Is there a way to have a reference to the parent viewmodel from one of the children?
You can either explicitly call the viewmodel that you want to contact by injecting both viewmodel belonging to same nav graph.
Alternatively, you can share a interface among both viewmodels, ensure it is same instance and use it as communication bridge.
interface ViewModelsComBridge<T>{
fun registerCallback(onMessageReceived : (T) -> Unit)
fun onDispatchMessage(message : T)
fun unregister(onMessageReceived : (T) -> Unit)
}
and in your view models:
class ViewModelA #Inject constructor(private val bridge : ViewModelCommunicationBridge<MyData>, ...) : ViewModel(){
init {
bridge.registerCallback { //TODO something with call }
}
}
in second view model:
class ViewModelA #Inject constructor(private val bridge : ViewModelCommunicationBridge<MyData>, ...) : ViewModel(){
fun onClick(){
val myData = processMyData()
bridge.onDispatchMessage(myData)
}
}
On the other end the other viewmodel will receive this call if it is alive.
Ensure your implementation is inject correctly and it is same instance in both viewmodels.
Your can change your NewsViewModel 's viewModelStoreOwner(fragment, activity or HomeScreen's destination), not the lifecycle of news's destination.
so your data will be survive while NewsScreen changes.
#Composable
fun HomeScreen(state: HomeState, event: (HomeEvent) -> Unit) {
val navController = rememberNavController()
val newsVm: NewsViewModel = hiltViewModel() //move to here,
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
BottomNavigation { .... //add the three bottom navigation menu items
}
},
) {
NavHost(
navController = navController,
startDestination = "news",
) {
composable(route = "news") {
NewsScreen(newsVm)
}
composable(route = "tickets") { NewTicketScreen() }
composable(route = "archive") { ArchiveScreen() }
}
}
}
I'm migrating from Shared preference to data store using jetpack compose. everything works fine (data is saved and can be retreated successfully). However, whenever a Data is retrieved, the composable keeps on recomposing endlessly. I'm using MVVM architecture and below is how I have implemented data store.
Below is declared in my AppModule.kt
App module in SingletonComponent
#Provides
#Singleton
fun provideUserPreferenceRepository(#ApplicationContext context: Context):
UserPreferencesRepository = UserPreferencesRepositoryImpl(context)
Then here's my ViewModel:
#HiltViewModel
class StoredUserViewModel #Inject constructor(
private val _getUserDataUseCase: GetUserDataUseCase
): ViewModel() {
private val _state = mutableStateOf(UserState())
val state: State<UserState> = _state
fun getUser(){
_getUserDataUseCase().onEach { result ->
val name = result.name
val token = result.api_token
_state.value = UserState(user = UserPreferences(name, agentCode, token, balance))
}.launchIn(viewModelScope)
}}
Finally, Here's my Repository Implementation:
class UserPreferencesRepositoryImpl #Inject constructor(
private val context: Context
): UserPreferencesRepository {
private val Context.dataStore by preferencesDataStore(name = "user_preferences")
}
private object Keys {
val fullName = stringPreferencesKey("full_name")
val api_token = stringPreferencesKey("api_token")
}
private inline val Preferences.fullName get() = this[Keys.fullName] ?: ""
private inline val Preferences.apiToken get() = this[Keys.api_token] ?: ""
override val userPreferences: Flow<UserPreferences> = context.dataStore.data.catch{
// throws an IOException when an error is encountered when reading data
if (it is IOException) {
emit(emptyPreferences())
} else {
throw it
}
}.map { preferences ->
UserPreferences(name = preferences.fullName, api_token = preferences.apiToken)
}.distinctUntilChanged()
I don't know what causes the composable to recompose. Below Is the composable:
#Composable
fun LoginScreen(
navController: NavController,
userViewModel: StoredUserViewModel = hiltViewModel()
) {
Log.v("LOGIN_SCREEN", "CALLED!")
userViewModel.getUser()
}
If anyone can tell me where I've done wrong please enlighten me. I have tried to change the implementation in AppModule for UserPreferencesRepository but no luck.
Below is UseState.kt which is just a data class
data class UserState(
val user: UserPreferences? = null
)
Below is UserPreferences.kt
data class UserPreferences(val name: String, val api_token: String)
I also faced such problem. The solution was became to navigate with LauchedEffect in composable.
before:
if (hasFlight) {
navController.navigate(Screen.StartMovingScreen.route)
}
after:
if (hasFlight) {
LaunchedEffect(Unit) {
navController.navigate(Screen.StartMovingScreen.route)
}
}
This is expected behaviour: you're calling getUser on each recomposition.
#Composable function is a view builder, and should be side-effects free.
Instead you can use special side effect function, like LaunchedEffect, which will launch job only once, until it's removed from view tree or key argument is changed:
LaunchedEffect(Unit) {
userViewModel.getUser()
}
But this also will be re-called in case of configuration change, e.g. screen rotation. To prevent this case, you have two options:
Call getUser inside view model init: in this case it's guarantied that it's called only once.
Create some flag inside view model to prevent redundant request.
More info about Compose side effects in documentation.
I`m programing with Jetpack Compose.
I request data from net and save it into a ViewModel in a Composable,
but when I want to use the data in other Composable, the ViewModel returns null
// ViewModel:
class PartViewModel : ViewModel() {
private val mPicRepository: PicRepository = PicRepository()
private val _partsResult: MutableLiveData<PicResp> = MutableLiveData()
val partsResilt: LiveData<PicResp> get() = _partsResult
fun getPartsFromImage(id: Long) {
mPicRepository.getCloudPic(id, _partsResult)
}
}
// Composable which request data
#Composable
fun PagePhotoDetail(imageId: Long, navController: NavHostController) {
val vm: PartViewModel = viewModel()
vm.getPartsFromImage(imageId)
partsState.value?.data?.let {
Logger.d(it.parts) // this log show correct data
}
}
// Composable which use data
#Composable
fun PagePartListFromImage(navController: NavHostController) {
val vm: PartViewModel = viewModel()
Logger.d(vm.partsResilt.value) // this log cannot get data and show null
}
You are creating two different instances of your viewmodel. You need to initialise the viewmodel like val vm by viewmodels<PartViewModel>
Then pass this viewmodel as a parameter inside the Composable. You're done!
Well, if you still wish to initialise it inside a Composable, you can use val vm by viewmodel<PartViewModel>.
viewModel<> instead of viewModels<>