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() }
}
}
}
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... //
}
}
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.
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 this structure in my MainActivity:
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = ItemsScreen.route
) {
composable(
route = ItemsScreen.route
) {
ItemsScreen(
navController = navController
)
}
composable(
route = ItemDetailsScreen.route + "/{itemId}",
arguments = mutableStateListOf(
navArgument("itemId") {
type = NavType.StringType
}
)
) { backStackEntry ->
val itemId = backStackEntry.arguments?.getString("itemId") ?: ""
ItemDetailsScreen(
navController = navController,
itemId = itemId
)
}
}
In the ItemDetailsScreen a LazyColumn:
LazyColumn {
items(
items = itemsResponse.data
) { item ->
ItemCard(
item = item,
onItemClick = {
navController.navigate(ItemDetailsScreen.route + "/${item.id}")
}
)
}
}
And on item click I navigate to the ItemDetailsScreen:
fun ItemDetailsScreen(
navController: NavController,
itemId: String,
viewModel: ItemDetailsViewModel = hiltViewModel()
) {
Log.d(TAG, itemId)
}
As seen, the ViewModel object is created in the constructor. When I open the ItemDetailsScreen the log statement is triggered twice. If I comment this line:
//viewModel: ItemDetailsViewModel = hiltViewModel()
The log statement works as expected, it prints the itemId only once. How to use the ViewModel object so it can trigger the log statement only once?
Here is also the ViewModel class:
#HiltViewModel
class ItemDetailsViewModel #Inject constructor(
private val useCases: UseCases
): ViewModel() {
private val _itemState = mutableStateOf<Response<Item>>(Success(Item()))
val itemState: State<Response<Item>> = _itemState
fun getItem(id: String) {
viewModelScope.launch {
useCases.getItem(id).collect { response ->
_itemState.value = response
}
}
}
}
You need not worry about the log statement being printed twice. There's nothing wrong with the viewModel creation code. The log statment is printed twice because it is directly inside a composable function and composables recompose a lot (sometimes even in an unpredictable way). If you want to do some operation only when the composable is composed the first time, put it inside a LaunchedEffect block.
Check out the documentation to learn more about these effect handlers.
In my app I want to send info to server and after receiving successful response I want to pass info to current screen to navigate to another screen.
Here's the flow:
From UI I call viewModel to send request to server. In ViewModel I have a callback:
#HiltViewModel
class CreateAccountViewModel #Inject constructor(
private val cs: CS
) : ViewModel() {
private val _screen = mutableStateOf("")
val screen: State<String> = _screen
fun setScreen(screen: Screen) {
_screen.value = screen.route
}
private val signUpCallback = object : SignUpHandler {
override fun onSuccess(user: User?, signUpResult: SignUpResult?) {
setScreen(Screen.VerifyAccountScreen)
Log.i(Constants.TAG, "sign up success")
}
override fun onFailure(exception: Exception?) {
Log.i(Constants.TAG, "sign up failure ")
}
}
}
As you can see I have also State responsible for Screen so when response is successful I want to update the state so UI layer (Screen) knows that it should navigate to another screen. My question is: how can I observer State in
#Composable
fun CreateAccountScreen(
navController: NavController,
viewModel: CreateAccountViewModel = hiltViewModel()
) {
}
Or is there a better way to achieve that?
I think your view model should know nothing about navigation routes. Simple verificationNeeded flag will be enough in this case:
var verificationNeeded by mutableStateOf(false)
private set
private val signUpCallback = object : SignUpHandler {
override fun onSuccess(user: User?, signUpResult: SignUpResult?) {
verificationNeeded = true
Log.i(Constants.TAG, "sign up success")
}
override fun onFailure(exception: Exception?) {
Log.i(Constants.TAG, "sign up failure ")
}
}
The best practice is not sharing navController outside of the view managing the NavHost, and only pass even handlers. It may be useful when you need to test or preview your screen.
Here's how you can navigate when this flag is changed:
#Composable
fun CreateAccountScreen(
onRequestVerification: () -> Unit,
viewModel: CreateAccountViewModel = hiltViewModel(),
) {
if (viewModel.verificationNeeded) {
LaunchedEffect(Unit) {
onRequestVerification()
}
}
}
in your navigation managing view:
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.CreateAccount
) {
composable(Screen.CreateAccount) {
CreateAccountScreen(
onRequestVerification = {
navController.navigate(Screen.VerifyAccountScreen)
}
)
}
}