I started noticing something in my app the other day and its wildly inconsistent. Sometimes it happens and sometimes it doesn't.
I am using the Navigation Component to handle navigation in the app and I started noticing that sometimes, when popping the backstack via the action bar back button or the device back button, it returns to a fragment that is no longer the starting destination (or at least shouldn't be).
In my case the app starts in MainFragment and once authenticated moves to DashboardFragment. This is a pretty common scenario.
Navigation in the app is pretty flat. most of the time its only 1 level deep so nearly all views are accessible from the dashboard.
The app starts at a login view as many do and then to a dashboard where the session will remain as the "start destination". To accomplish this, its done in the nav_graph using popUpTo and popUpToInclusive.
<fragment
android:id="#+id/mainFragment"
android:name="com.example.view.fragments.MainFragment"
android:label="Welcome">
<action
android:id="#+id/action_mainFragment_to_dashboardFragment"
app:destination="#id/dashboardFragment"
app:popUpTo="#id/mainFragment"
app:popUpToInclusive="true"/>
</fragment>
<fragment
android:id="#+id/dashboardFragment"
android:name="com.example.view.fragments.dashboard.DashboardFragment"
android:label="#string/dashboard_header" >
<action
android:id="#+id/action_dashboardFragment_to_notificationsFragment"
app:destination="#id/notificationsFragment" />
</fragment>
When the user successfully authenticates and its time to go to the dashboard, I use NavController.navigate() to send them there.
findNavController().navigate(
MainFragmentDirections.actionMainFragmentToDashboardFragment()
)
// This should have the same result and it does appear to be affected by the same issue
// findNavController().navigate(R.id.action_mainFragment_to_dashboardFragment)
I have an action bar with a back arrow and a navigation drawer. In the main activity I need to define the AppBarConfiguration and override onSupportNavigateUp()
lateinit var appBarConfiguration: AppBarConfiguration
...
override fun onCreate(savedInstanceState: Bundle?) {
Timber.d("onCreate()")
super.onCreate(savedInstanceState)
_binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// There is 2 different drawer menu's respectfully.
appBarConfiguration = AppBarConfiguration(
setOf(
R.id.mainFragment,
R.id.dashboardFragment
), binding.drawerLayout
)
setSupportActionBar(binding.toolbar)
setupActionBarWithNavController(navController, appBarConfiguration)
}
...
override fun onSupportNavigateUp(): Boolean {
Timber.d("-> onSupportNavigateUp()")
val breadcrumb = navController
.backStack
.map { it.destination }
.filterNot { it is NavGraph }
.joinToString(" > ") { it.displayName.split('/')[1] }
Timber.d("Backstack: $breadcrumb")
Timber.d("Previous backstack entry: ${navController.previousBackStackEntry?.destination?.displayName}")
return navController.navigateUp(appBarConfiguration) || super.onSupportNavigateUp()
}
...
The logs look like this when we step back and it is working correctly
D/MainActivity: -> onSupportNavigateUp()
D/MainActivity: Backstack: dashboardFragment > testingFragment
D/MainActivity: Previous backstack entry: com.example:id/dashboardFragment
D/DashboardFragment: -> onCreateView()
D/BaseFragment: -> onCreateView()
D/DashboardFragment: -> onViewCreated()
I also noticed when using the hamburger in the action bar that it also calls onSupportNavigateUp()
D/MainActivity: -> onSupportNavigateUp()
D/MainActivity: pendingAction: false
D/MainActivity: Backstack: dashboardFragment
D/MainActivity: Previous backstack entry: null
When I use the drawer to navigate to a destination I do see this in the logs and im not sure where/why this is returned or if it has any importance
I/NavController: Ignoring popBackStack to destination com.example:id/mainFragment as it was not found on the current back stack
Now, when its NOT working correctly, this is what the logs look like
D/MainActivity: -> onSupportNavigateUp()
D/MainActivity: Backstack: mainFragment > testingFragment
D/MainActivity: Previous backstack entry: com.example:id/mainFragment
D/MainFragment: -> onCreateView()
D/BaseFragment: -> onCreateView()
D/MainFragment: -> onViewCreated()
This really feels like the popUpTo and popUpToInclusive properties are not being applied (sometimes) when performing the navigation from main fragment to dashboard fragment. It's also suspicious that even though the dashboard fragment is not set as the new starting destination but also its missing from the backstack. Assuming the properties were NOT applied I would expected to see the breadcrumb Backstack: mainFragment > dashboardFragment > testingFragment
Any help would be greatly appreciated!
While there may certainly be a better procedure to follow (as outlined in the Principles of Navigation in the previous comments) the overhead of the change introduces a plethora of new errors and the scope is too large at this time.
Its still unknown why popUpTo and popUpToInclusive are not reliable via XML when navigating (even using NavDirections) however, thus far, passing the NavOptions while navigating seems to resolve the issue.
findNavController().navigate(
MainFragmentDirections.actionMainFragmentToDashboardFragment(),
NavOptions.Builder().setPopUpTo(R.id.mainFragment, true).build()
)
Thus far the issue has yet to arise again.
As it says in Ian's first link:
The back stack always has the start destination of the app at the bottom of the stack.
Your start destination is the "home" one in a navigation graph, the one identified with the little house icon. Set with the startDestination attribute in the XML. When the Navigation library creates a backstack, it always has that destination at the bottom. And it will always be there, even if you try to avoid it with the poUpTo attributes.
That's why if there's a fragment you consider your "home" one, like your dashboard, that needs to explicitly be your startDestination. It's the last thing a user will see if they back out of your app. If you have something like a login or welcome screen as the start destination, they'll back out to that.
Which is why you need to set the "home" fragment as the start destination, and then handle any extra navigation to a login or welcome screen from there. It's just how the Navigation stuff is designed to work. If you try and work around it (I did!) you'll run into other problems, and a lot of the nice features like automatic backstack recreation might not work properly
Related
Good day. So I've been working around with NavComponent of Jetpack for Android
I've thought that management of BackStack of fragments had to be implemented there already, well in fact it is there but I have faced an issue.
Here is my structure:
I have and entry Activity
I have a NavHost in the activity
I have Bottom Navigation bar in the Activity
For each Bottom Item I am using separate Fragments to navigate through.
Here is the code for the navigation.
bottomNavigationView.setOnNavigationItemSelectedListener {
when (it.itemId) {
R.id.navigation_home -> {
navController.apply {
navigate(R.id.navigation_home)
}
true
}
R.id.navigation_dashboard -> {
navController.apply {
navigate(R.id.dashboardFragment)
}
true
}
R.id.navigation_notifications -> {
true
}
else -> {
false
}
}
}
Never mind the last item.
So the issue is next.
If I try to switch between home and dashboard multiple times, when I press back then the stack surely will start popping all the items included there. So if I move like 6 times it will take me 12 attempts to actually exit the app.
Currently I couldn't find any source where for example the navigate() method will accept some sort of argument to cash my fragments instead of recreating it each time and adding to the BackStack.
So what kind of approach would you suggest?
If I to manage the BackStack manually on each back button pressed, what's the purpose of NavController at all? Just for creating and FORWARD navigation?
I think I'm missing some source in Android's official docs.
Thank you beforehand.
P.S.
using navController.popBackStack() before calling navigate() surely isn't the correct choice.
According to the documentation here :
NavigationUI can also handle bottom navigation. When a user selects a menu item, the NavController calls onNavDestinationSelected() and automatically updates the selected item in the bottom navigation bar.
to do so you have to give your bottom navigation items an ids as same as the corresponding destination in your navigation graph , and then tie you bottom view to the controller like this :
NavHostFragment navHostFragment = supportFragmentManager.findFragmentById(R.id.nav_host_fragment);
NavController navController = navHostFragment.getNavController();
BottomNavigationView bottomNav = findViewById(R.id.bottom_nav);
NavigationUI.setupWithNavController(bottomNav, navController);
Note : from my personal experience , when the startDestination in the graph , that start by default is not currently in back stack (In my case it was the landing page which i pop it out when going to home fragment) then the app act with weird behavior like this . so make sure the start destination is existed in your back stack on should work fine .
I have 2 navigation files, and in my Activity, 2 fragments. One of the navigations is always shown inside one of the fragments, but I show the other one only when I need it.
The way they're drawn is the always showing fragment is inside a relativeLayout, and the other fragment is inside the same relativeLayout with it's visibility set as gone. When I need the second navigation, I set the visibility to visible and when I don't need it, I set it to gone again.Visually this works well, but what I want to accomplish is that when I don't want the second navigation, I want to completely kill it and redraw it the next time I need it.
What I've done so far was to get a hold of the NavHostFragment used to start the navigation, and when I dont need it anymore, call popBackStack() on it's navController, but it doesn't work:
val navHost: NavHostFragment? = null
fun createSecondNav() {
navHostLogin = NavHostFragment.create(R.navigation.navigation_second)
theFragment.visibility = View.VISIBLE
supportFragmentManager.beginTransaction()
.replace(R.id.theFragment, navHostLogin!!)
.commit()
}
fun killSecondNav() {
theFragment.visibility = View.GONE
navHostLogin?.navController?.popBackStack() // returns false
navHostLogin = null
}
So how can I completely kill the fragments created by the second navHost?
NavController maintains it's own back-stack, independent form the FragmentManager back-stack.
And popBackStack() without arguments only pops that back-stack once:
Attempts to pop the controller's back stack. Analogous to when the user presses the system Back button when the associated navigation host has focus.
While popBackStack(int destinationId, boolean inclusive) reads:
Attempts to pop the controller's back stack back to a specific destination.
destinationId int: The topmost destination to retain
inclusive boolean: Whether the given destination should also be popped.
So this should be:
navController.popBackStack(R.id.startDestination, true)
I'd wonder why even using two NavController, because one can set the graph at run-time with setGraph(NavGraph graph, Bundle startDestinationArgs):
Sets the navigation graph to the specified graph.
Any current navigation graph data (including back stack) will be replaced.
I have 2 fragment call CreateRoomFragment and DisplayPhotoFragment,the navigation graph is look like this:
<navigation>
<fragment
android:id="#+id/createRoomFragment"
android:name="package.room.CreateRoomFragment"
android:label="Create a room"
tools:layout="#layout/fragment_create_room">
<action
android:id="#+id/action_createRoomFragment_to_roomFragment"
app:destination="#id/roomFragment" />
<action
android:id="#+id/action_createRoomFragment_to_displayPhotoFragment"
app:destination="#id/displayPhotoFragment" />
</fragment>
<fragment
android:id="#+id/displayPhotoFragment"
android:name="package.fragment.DisplayPhotoFragment"
android:label="fragment_display_photo"
tools:layout="#layout/fragment_display_photo" >
<argument android:name="bitmap"
app:argType="android.graphics.Bitmap"/>
</fragment>
So when I wanna to move from CreateRoomFragment to DisplayPhotoFragment,I use the do as below:
NavDirections action = CreateRoomFragmentDirections.actionCreateRoomFragmentToDisplayPhotoFragment(selectedPhoto);
Navigation.findNavController(view).navigate(action);
Doing this,I can navigate to DisplayPhotoFragment.
But when I press back button of the device and also the Back arrow from the toolbar,it cant go back to CreateRoomFragment.
I tried this,but still unable to back to previous fragment:
requireActivity().getOnBackPressedDispatcher().addCallback(getViewLifecycleOwner(),
new OnBackPressedCallback(true) {
#Override
public void handleOnBackPressed() {
navController.navigateUp(); //I tried this
navController.popBackStack(R.id.createRoomFragment,false); //and also this
}
});
Main Problem now:
By using the code above,the screen didnt go back to previous Fragment(CreateRoomFragment).It still stuck in DisplayPhotoFragment,but at the same time,an API method in CreateRoomFragment onViewCreated section is being called.
What causing this? and how can I solve this problem?
I had the same problem. For me the issue was that I was using a LiveData boolean to decide when to go to the next fragment. When I then navigated back/up the boolean was still true so it would automatically navigate forward again.
Android maintains a back stack that contains the destinations you've visited. The first destination of your app is placed on the stack when the user opens the app. Each call to the navigate() method puts another destination on top of the stack. Tapping Up or Back calls the NavController.navigateUp() and NavController.popBackStack() methods, respectively, to remove (or pop) the top destination off of the stack.
NavController.popBackStack() returns a boolean indicating whether it successfully popped back to another destination. The most common case when this returns false is when you manually pop the start destination of your graph.
When the method returns false, NavController.getCurrentDestination() returns null. You are responsible for either navigating to a new destination or handling the pop by calling finish() on your Activity.
When navigating using an action, you can optionally pop additional destinations off of the back stack by using popUpTo and popUpToInclusive parameter of the action.
class MyFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val onBackPressedCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
if (true == conditionForCustomAction) {
CustomActionHere()
} else NavHostFragment.findNavController(this#MyFragment).navigateUp();
}
}
requireActivity().onBackPressedDispatcher.addCallback(
this, onBackPressedCallback
)
...
}
The best solution for handling navigation using live data is to use the SingleLiveEvent.
You can always use this class which is an extension of MutableLiveData.
https://gist.githubusercontent.com/cdmunoz/ebe5c4104dadc2a461f512ea1ca71495/raw/a17f76754f86a4c0b1a6b43f5c6e6d179535e627/SingleLiveEvent.kt
For a detail run down of this check:
https://proandroiddev.com/singleliveevent-to-help-you-work-with-livedata-and-events-5ac519989c70
Had a similar issue. We still have multiple activities with nav component.
So imagine activity A -> activity B, activity B has its own nav and fragments. When the initial fragment tries to pop the back stack there is nowhere to pop back to and the nav controller does not know to finish the activity. So one solution I found was to do
if (!findNavController().popBackStack()) activity?.finish()
If nav controller can not pop back it will finish activity.
You can use MutableSharedFlow instead on MutableLiveData if you want to observe the Event only once.
in your viewModel:
private val _events = MutableSharedFlow<Event>()
val events = _events.asSharedFlow() // read-only public view
suspend fun postEvent() {
_events.emit(event) // suspends until subscribers receive it
}
In your Activity/Fragment class:
lifecycleScope.launchWhenStarted {
viewModel.events.collect {
}
}
viewModel.postEvent()
This will prevent observing data continuously when going back to fragment.
I'm not really sure what I'm doing wrong here. Simple setup, single activity with fragments controlled by a bottom bar inside, so far so good. The start fragment has a button inside which should navigate to another fragment.
Here's the onclicklistener for the button inside my fragment class:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
add_step_fab.setOnClickListener {
Navigation.findNavController(view).navigate(R.id.fragmentAtoB)
}
}
Here's my action in the navigation.xml:
<fragment android:id="#+id/navigation_main" android:name="com.test.test.fragments.AFragment"
android:label="Fragment A" tools:layout="#layout/fragment_a">
<action android:id="#+id/fragmentAtoB"
app:destination="#id/fragmentB" app:enterAnim="#anim/nav_default_enter_anim"
app:exitAnim="#anim/nav_default_exit_anim" app:popEnterAnim="#anim/nav_default_pop_enter_anim"
app:popExitAnim="#anim/nav_default_pop_exit_anim"/>
</fragment>
The navigation works, but I get the standard
java.lang.IllegalArgumentException: navigation destination com.test.test:id/fragmentAtoB is unknown to this NavController
error when I click on the button after rotating my screen, split screening the app, or sometimes seemingly randomly. Looks like it has something to do with the configuration change?
EDIT:
I also tried using
val clickListener: View.OnClickListener = Navigation.createNavigateOnClickListener(R.id.fragmentAtoB)
add_step_fab.setOnClickListener(clickListener)
with the same issue as above.
Checking if i'm actually in the same fragment, as some suggested for users who are having the same exception (but for unrelated reasons), such as:
if (controller.currentDestination?.id == R.id.navigation_main) {
controller.navigate(R.id.fragmentAtoB)
}
also didn't help.
I also experienced this recently after months of an app working correctly. I originally had added navigation to an app that was utilizing a "MainActivity" class and then had other activities that would be launched from a menu (as opposed to having the Navigation component navigate between fragments off of a single Activity).
So the app is organized such that the MainActivity is the "hub", and choices from menu options cause other activities to be launched, and when the user returns from them they are back at the MainActivity - so a pure "hub (MainActivity) and spoke (5 other activities)" model.
Within MainActivity.onCreate() is called a setupNavigation() method which originally looked like:
private void setupNavigation() {
Toolbar toolbar = findViewById(R.id.toolbar);
setSupportActionBar(toolbar);
drawerLayout = findViewById(R.id.drawer_layout);
// This class implements the listener interface for the NavigationView, handler is below.
NavigationView navigationView = findViewById(R.id.nav_view);
navController = Navigation.findNavController(this, R.id.nav_host_fragment);
NavigationUI.setupActionBarWithNavController(this, navController, drawerLayout);
NavigationUI.setupWithNavController(navigationView, navController);
// MainActivity implements the listener for navigation events
navigationView.setNavigationItemSelectedListener(this);
}
As I mentioned, this was working just fine even with configuration changes until recently. Even just changing the configuration from portrait to landscape on the MainActivity would cause an exception:
ComponentInfo{com.reddragon.intouch/com.reddragon.intouch.ui.MainActivity}: java.lang.IllegalStateException: You must call setGraph() before calling getGraph()
I found I had to add this line:
navController.setGraph(R.navigation.nav_host);
Right after the Naviation.findNavController() call, and then it handled configuration changes just fine.
After a configuration change, the fragment or activity goes out of scope temporarily and then executes an onResume() override. If you have anything which is initialized in onCreate(), or onViewCreated(), then those variables may have gone out of scope and are now null values. So, I usually have a method called RestoreAll() which is responsible for instantiating all of the objects used by the code, and I call this method from onResume() so that every time there is a configuration change, all the local objects are re-instantiated.
There is a problem with nav controller backstack after changed config. Try this:
https://stackoverflow.com/a/59987336/5433148
I have Two fragment. SecondFragment and ThirdFragment. Actually I use the Navigation Component for passing value between fragments. Like this:
SecondFragment:
val action = SecondFragmentDirections.action_secondFragment_to_thirdFragment().setValue(1)
Navigation.findNavController(it).navigate(action)
Here is how I read the value from the ThirdFragment:
arguments?.let {
val args = ThirdFragmentArgs.fromBundle(it)
thirdTextView.text = args.value.toString()
}
It's work fine. Now my stack is look like this:
ThirdFragment
SecondFragment
There is any option for pass value from the opened ThirdFragment to the previous SecondFragment with the new Navigation Component? (When ThirdFragment is finishing)
I know about onActivityResult, but If Nav.Component serve better solution than I want use that.
Thank you!
It's a bit late for this answer but someone may find it useful. In the updated versions of the navigation component library it is now possible to pass data while navigating back.
Suppose the stack is like this
FragmentA --> FragmentB.
We are currently now in FragmentB and we want to pass data when we go back to FragmentA.
Inside FragmentAwe can create an observer with a key:
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
val navController = findNavController()
// Instead of String any types of data can be used
navController.currentBackStackEntry?.savedStateHandle?.getLiveData<String>("key")
?.observe(viewLifecycleOwner) {
}
}
Then inside FragmentB if we change its value by accessing previous back stack entry it will be propagated to FragmentA and observer will be notified.
val navController = findNavController()
navController.previousBackStackEntry?.savedStateHandle?.set("key", "value that needs to be passed")
navController.popBackStack()
Just came across setFragmentResult(), pretty easy to use. The docs on this are here.
If you are navigating: Fragment A -> Fragment B -> Fragment A
Add this to fragment A:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setFragmentResultListener("requestKey") { requestKey, bundle ->
shouldUpdate = bundle.getBoolean("bundleKey")
}
}
Then in fragment B add this line of code:
setFragmentResult("requestKey", bundleOf("bundleKey" to "value to pass back"))
// navigate back toFragment A
When you navigate back to fragment A the listener will trigger and you'll be able to get the data in the bundle out.
What you are asking for is an anti-pattern. You should either
navigate to the second fragment again with the new values you would like to set
use the third fragment ins a separate activity and start it with startActivityForResult()
use a ViewModel or some kind of singleton pattern to hold on to your data (make sure you clear the data after you no longer need it)
these are some of the patterns that came to my mind. Hope it helps.
As described here:
When navigating using an action, you can optionally pop additional destinations off of the back stack. For example, if your app has an initial login flow, once a user has logged in, you should pop all of the login-related destinations off of the back stack so that the Back button doesn't take users back into the login flow.
To pop destinations when navigating from one destination to another, add an app:popUpTo attribute to the associated element. app:popUpTo tells the Navigation library to pop some destinations off of the back stack as part of the call to navigate(). The attribute value is the ID of the most recent destination that should remain on the stack.
<fragment
android:id="#+id/c"
android:name="com.example.myapplication.C"
android:label="fragment_c"
tools:layout="#layout/fragment_c">
<action
android:id="#+id/action_c_to_a"
app:destination="#id/a"
app:popUpTo="#+id/a"
app:popUpToInclusive="true"/>
</fragment>