In my application i am using Jetpack Navigation with BottomNavigationView. I have like 4 fragments:Home,Search,Notifications,Profile and when i am in Home fragment, i click again home item in bottom navigation view, it re-creates the fragment. I searched, but mainly answers were for those who did not use jetpack navigation.
(by the way, i only want fragment not being re-created when i am on that fragment already, if i am not in that fragment, it is okay to be re-created)
Below is my setup:
val navHostFragment = supportFragmentManager.findFragmentById(R.id.fragmentContainerView_mainActivity) as NavHostFragment
navController = navHostFragment.navController
binding.bottomNavView.setupWithNavController(navController)
I read the source code of Google. I saw that, It always creates new fragment.
You have a bottom navigation like the app that I'm building. :))
For me, I didn't use menu item for bottom navigation view. I added a custom view for it.
(I have MainActivity, MainViewModel for managing the action bar, bottom navigation view .)
And then in custom view, when the customer click on item, I will check the page that they want to open is the same with the current page or not. If they are the same, I will not open it. Like this:
fun openHomePage() {
if (pageID.value != R.id.nav_home) {
pageID.postValue(R.id.nav_home)
}
}
pageID stores the id of current page:
var pageID = MutableLiveData<Int>()
private set
In MainActivity:
mainViewModel.pageID.observe(this, Observer {
val currentPageId = findNavController(R.id.nav_host_fragment).currentDestination?.id
if (it != 0 && it != currentPageId) {
drawerLayout.close()
navigatePageWithId(it)
}
})
This is a bug that has been around for a while and Google has not provided the official way to deal with it. More info is that because there is only one stack that swap in and out the fragment, you can read more from the SO's post
Android JetPack navigation with multiple stack
But you're using kotlin, you can refer this Github's repo where they provided a workaround for this situation
I ended up using code below.(Considering there is not best solution, it works for me as i want)
currentFragmentIndex is the integer value declared in above scope which shows the fragment we are currently in.
binding.bottomNavView.setOnNavigationItemSelectedListener {
when (it.itemId) {
R.id.homeFragment -> {
if (currentFragmentIndex == 0) {
false
} else {
currentFragmentIndex = 0
navController.navigate(R.id.homeFragment)
true
}
}
R.id.searchFragment -> {
if (currentFragmentIndex == 1) {
false
} else {
currentFragmentIndex = 1
navController.navigate(R.id.searchFragment)
true
}
}
R.id.notificationsFragment -> {
if (currentFragmentIndex == 2) {
false
} else {
currentFragmentIndex = 2
navController.navigate(R.id.notificationsFragment)
true
}
}
R.id.myProfileFragment -> {
if (currentFragmentIndex == 3) {
false
} else {
currentFragmentIndex = 3
navController.navigate(R.id.myProfileFragment)
true
}
}
else -> false
}
}
This is the right way to prevent fragments from recreation while using bottom navigation via Jetpack Navigation
binding.bottomNavView.setOnNavigationItemReselectedListener {
// Do nothing to ignore the reselection
}
Related
In my MainActivity I have BottomNavigation. My activity is connected with MainViewModel. When app starts I fetch data from firebase. Until the data is downloaded, app displays ProgressBar and BottomNavigation is hide (view.visibility = GONE). When data has been downloaded I hide ProgressBar and show BottomNavigation with the app's content. It works great.
In another part of the app user can open gallery and choose photo. The problem is when activity with photo to choose has been closed, MutableStateFlow is triggered and bottomNavigation displays again but it should be hide in that specific part(fragment) of the app.
Why my MutableStateFlow is triggered although I don't send to it anything when user come back from gallery activity?
MainActivity (onStart):
private val mainSharedViewModel by viewModel<MainSharedViewModel>()
override fun onStart() {
super.onStart()
lifecycle.addObserver(mainSharedViewModel)
val bottomNavigationView = findViewById<BottomNavigationView>(R.id.bottomNavigationView)
val navHostFragment: FragmentContainerView = findViewById(R.id.bottomNavHostFragment)
bottomNavController = navHostFragment.findNavController()
bottomNavigationView.apply {
visibility = View.GONE
setupWithNavController(navHostFragment.findNavController())
}
//the fragment from wchich I open GalleryActivity is hide (else ->)
navHostFragment.findNavController().addOnDestinationChangedListener { _, destination, _ ->
when (destination.id) {
R.id.mainFragment,
R.id.profileFragment,
R.id.homeFragment -> bottomNavigationView.visibility = View.VISIBLE
else -> bottomNavigationView.visibility = View.GONE
}
}
mainSharedViewModel.viewModelScope.launch {
mainSharedViewModel.state.userDataLoadingState.collect {
if (it == UserDataLoading.LOADED) {
bottomNavigationView.visibility = View.VISIBLE
} else {
bottomNavigationView.visibility = View.GONE
}
}
}
}
ViewModel:
class MainSharedViewState {
val userDataLoadingState = MutableStateFlow(UserDataLoading.LOADING) }
enum class UserDataLoading {
LOADING, UNKNOWN_ERROR, NO_CONNECTION_ERROR, LOADED }
When you come back from the gallery, the stateflow value is still set as Loaded, as the Viewmodel has not been cleared (and the activity was set to Stopped, not destroyed. It is still in the back stack.) This is why the bottomNavigationView is visible when you come back.
Although your architecture/solution is not how I would have done it, in your circumstances I guess you could change the value of the MutableStateFlow when the activity's onStop is called. Either that or use a MutableSharedFlow instead with a replayCount of 0 so that there is no value collected (although then, the bottomNavigationView will still be set as Visible if it is visible by default in XML.)
SOLVED:
I've created
val userDataLoadingState = MutableSharedFlow<UserDataLoading>(replay = 0)
and when my ViewModel is created I set
state.userDataLoadingState.emit(UserDataLoading.LOADING)
and I collect data in Activity
lifecycleScope.launch {
mainSharedViewModel.state.userDataLoadingState.collect {
if (it == UserDataLoading.LOADED) {
bottomNavigationView.visibility = View.VISIBLE
} else {
bottomNavigationView.visibility = View.GONE
}
}
}
Now it works great. I don't know why it didn't work before.
I have created a composable called ResolveAuth. ResolveAuth is the first screen when user opens the app after Splash. All it does is check whether an email is present in Datastore or not. If yes redirect to main screen and if not then redirect to tutorial screen
Here is my composable and viewmodel code
#Composable
fun ResolveAuth(resolveAuthViewModel: ResolveAuthViewModel, navController: NavController) {
Scaffold(content = {
ProgressBar()
when {
resolveAuthViewModel.userEmail.value != "" -> {
navController.navigate(Screen.Main.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
resolveAuthViewModel.userEmail.value == "" -> {
navController.navigate(Screen.Tutorial.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
}
})
}
#HiltViewModel
class ResolveAuthViewModel #Inject constructor(
private val dataStoreManager: DataStoreManager): ViewModel(){
val userEmail = MutableLiveData<String>()
init {
viewModelScope.launch{
val job = async {dataStoreManager.email.first()}
val email = job.await()
if(email != ""){
userEmail.value = email
}
}
}
}
But I keep getting an exception saying
java.lang.IllegalStateException: You cannot access the NavBackStackEntry's ViewModels until it is added to the NavController's back stack (i.e., the Lifecycle of the NavBackStackEntry reaches the CREATED state).
I am using below jetpack lib for navigation
implementation("androidx.navigation:navigation-compose:2.4.0-rc01")
There is no issue in my Main and Tutorial screen as I tried to run them separately and it works fine.
Easily resolvable, just add this when call to a Side-Effect instead.
LaunchedEffect(Unit){
while(!isNavStackReady) // Hold execution while the NavStack populates.
delay(16) // Keeps the resources free for other threads.
when {
resolveAuthViewModel.userEmail.value != "" -> {
navController.navigate(Screen.Main.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
resolveAuthViewModel.userEmail.value == "" -> {
navController.navigate(Screen.Tutorial.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
}
}
Here, the call to navigate is made only after the currentBackStackEntry has been completely filled, so it yields no error. The original error occurred since you were calling navigate before the concerned composable was even made available to the nav stack.
As for how to update the isNavStackReady variable to reflect the correct state of the navStack, it is fairly simple. Create the variable at a top-level declaration, such that only the required components may access it. May as well throw it inside a viewModel if you please. Set the default value of the var to false, for obvious reasons. Here's the update mechanism.
#Composable
fun StartDestination(){
isNavStackReady = true
}
That's it, that's really it. If you could successfully navigate to your start destination that you define in the nav graph, it means the navStack has likely been populated well. Hence, you just update this variable here, and the LaunchedEffect block up there will respond to this update, and the while loop that's been holding execution off, will finally break. It will then call the navigate on the appropriate destination route. Remember, however, that the isNavStackReady variable, for this mechanism to work, needs to be a state-holder, i.e., initialised with mutableStateOf(false). Using delegates, of course, is completely fine (personally encouraged).
Now, all this is fine, but actually, it's not quite the right implementation. You see, this entire thing is taken care of completely internally by the navigation APIs for us, but it breaks because we are trying to do its job, and we suck at it.
We are creating an intermediate route to land on, at the start of the app, and from there, immediately navigating to another screen based on calculations. So, all we want is to open the app at a desired page, that is, start the navigator on a desired page when it is first created. We have a handy parameter called startDestination, just for that.
Hence, the ideal, simple, beautiful solution would be to just
startDestination = when {
resolveAuthViewModel.userEmail.value != "" -> {
navController.navigate(Screen.Main.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
resolveAuthViewModel.userEmail.value == "" -> {
navController.navigate(Screen.Tutorial.route) {
popUpTo(0)
}
resolveAuthViewModel.userEmail.value = null
}
}
in your NavBuilder's arguments. Tiniest silliest logical flaw, that so many people couldn't get. It's intriguing to think how the human mind works...
Happy New Year,
I'm using JetPack Compose with composable NavHost.
I have a scenario where I need a Launch screen that connects bluetooth device so I've set it as my starting route in NavHost.
After connection is done I want to enter Home screen and never get back to that Launch screen.
So after connection is done Launch screen I'm doing this:
navController.graph.setStartDestination(newHomeRoute)
navController.navigate(newHomeRoute) {
popUpTo(0)
launchSingleTop = true
}
That doesn't work as I get a constant loop going back to LaunchScreen and forward to Home.
So maybe I should do this in some other way?
I managed to do this with extension like this:
fun NavHostController.navigateAndReplaceStartRoute(newHomeRoute: String) {
popBackStack(graph.startDestinationId, true)
graph.setStartDestination(newHomeRoute)
navigate(newHomeRoute)
}
I had this exact problem.
Sharing my code here.
SHORT ANSWER,
navHostController.popBackStack("routeOfLaunchingScreen", true)
navHostController.navigate("newHomeRoute")
The true denotes that pop back stack till and including the given route.
Once the back stack is popped as required, we navigate to the new screen.
Hope this solves your issue. :)
LONG ANSWER (copy-paste solution)
MyNavActions.
class MyNavActions(navHostController: NavHostController) {
val navigateTo = { navBackStackEntry: NavBackStackEntry, route: String ->
if (navBackStackEntry.lifecycleIsResumed()) {
navHostController.navigate(route)
}
}
val navigateUp = { navBackStackEntry: NavBackStackEntry ->
if (navBackStackEntry.lifecycleIsResumed()) {
navHostController.navigateUp()
}
}
val popBackStackAndNavigate =
{ navBackStackEntry: NavBackStackEntry, route: String?, popUpTo: String, inclusive: Boolean ->
if (navBackStackEntry.lifecycleIsResumed()) {
navHostController.popBackStack(popUpTo, inclusive)
route?.let {
navHostController.navigate(route)
}
}
}
}
}
/**
* If the lifecycle is not resumed it means this NavBackStackEntry already processed a nav event.
*
* This is used to de-duplicate navigation events.
*/
private fun NavBackStackEntry.lifecycleIsResumed() =
this.lifecycle.currentState == Lifecycle.State.RESUMED
Usage
val myNavActions = remember(navHostController) {
MyNavActions(navHostController)
}
Pop back stack till given route and navigate
chcNavActions.popBackStackAndNavigate(
navBackStackEntry,
routeToPopUpTo,
routeToNavigateTo,
true, // inclusive flag - boolean denoting if the specified route `routeToPopUpTo` should also be popped
)
Back Navigation
chcNavActions.navigateUp(navBackStackEntry)
Simple navigation
chcNavActions.navigateTo(navBackStackEntry, route)
You can use this extension:
fun NavHostController.navigateAndClean(route: String) {
navigate(route = route) {
popUpTo(graph.startDestinationId) { inclusive = true }
}
graph.setStartDestination(route)
}
I am showing the Cast button as an Options menu item that is inflated from an activity, but I noticed that when the activity has a child fragment and the child fragment does not have an options menu item by itself, the chrome cast introduction overlay works correctly. However when the fragment has its own options menu, the Cast introduction overlay does not work correctly, it either shows in the top left corner or shows up in the correct position but does not highlight the cast icon.
Here is the code to initialize the Overlay
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
loadCastButton(menu)
return super.onCreateOptionsMenu(menu)
}
private fun loadCastButton(menu: Menu?) {
menuInflater.inflate(R.menu.menu_cast, menu)
CastButtonFactory.setUpMediaRouteButton(applicationContext, menu, R.id.cast_menu_item)
val mediaRoutebutton = menu?.findItem(R.id.cast_menu_item)?.actionView as? MediaRouteButton
mediaRoutebutton?.dialogFactory = CastDialogFactory()
handleCastTutorial(menu)
}
private fun handleCastTutorial(menu: Menu?) {
val castButton = menu?.findItem(R.id.cast_menu_item)
if (castButton == null) {
return
}
castViewModel.isCastingAvailable.observe(this) {
if (it == true && castButton.isVisible) {
//Show cast tutorial
castViewModel.setCastTutorialShown(true)
IntroductoryOverlay.Builder(this, castButton)
.setTitleText(R.string.cast_tutorial_title)
.setSingleTime()
.build()
.show()
}
}
}
When you are showing Cast buttons in fragments and activities, menus are inflated everywhere, with Cast buttons initialized in one of the fragments or activities and then immediately hidden again. My recommended solution is delaying the cast tutorial with a minor amount of delay, and then checking for visibility and window attach status again:
if (!castViewModel.getCastTutorialShown()) {
binding.root.postDelayed(200L) {
// Check if it is still visible.
if (castButton.isVisible && castButton.actionView.isAttachedToWindow && !castViewModel.getCastTutorialShown()) {
castViewModel.setCastTutorialShown(true)
IntroductoryOverlay.Builder(this, castButton)
.setTitleText(R.string.cast_tutorial_title)
.setSingleTime()
.build()
.show()
}
}
}
I need to display custom AlertDialog, but only when there are no more fragments after calling NavController.navigateUp(). My current code does something similar, but there is a bug to it:
override fun onBackPressed() {
if (navController.navigateUp()) {
return
}
showQuitDialog()
}
This somewhat works, but if I cancel the AlertDialog and don't quit the app, navController already navigated up to NavGraph root, which is not the fragment in which I was when AlertDialog appeared. So, if I try to use any navigation action from that fragment, I get error:
java.lang.IllegalArgumentException: navigation destination
com.example.test/actionNavigateToNextFragment is unknown to this NavController
This could be resolved, if I had access to mBackStack field in NavController, but it's package private, and I can't use reflection in my current project. But if it was public, I would use it the following way:
override fun onBackPressed() {
// 2 because first one is NavGraph, second one is last fragment on the stack
if(navController.mBackStack.size > 2) {
navController.navigateUp()
return
}
showQuitDialog()
}
Is there a way to do this without reflection?
You can compare the ID of the start destination with the ID of the current destination. Something like:
override fun onBackPressed() = when {
navController.graph.startDestination == navController.currentDestination?.id -> showQuitDialog()
else -> super.onBackPressed()
}
Hope it helps.
Try this for any destination (you can find your destination id in the navigation graph):
private fun isDesiredDestination(): Boolean {
return with(navController) {
currentDestination == graph[R.id.yourDestinationId]
}
}
Comparing IDs of destination may not be the best solution because you may have multiple screens with the same ID but different arguments on the backstack.
Here's an alternative:
val isRootScreen = navController.previousBackStackEntry == null
if you want this is a button so to speak you can have this
yourbutton.setOnClickListener {
val currentFragment = navController.currentDestination?.id
when (navController.graph.startDestination == currentFragment) {
true -> { //Go here}
// Go to the app home
else -> onBackPressed()
}
}