This is the setup:
View-based Android UI
Using androidx.navigation library (tested with versions 2.4.1 and 2.5.0-beta01)
Activity, consisting of a bottom bar and a NavHostFragment
What's special is that the navigation structure is defined by the server. The top-level destinations are available during Activity.onCreate(). Other detail screens (=children of the top-level destinations) are only known when performing more server calls while navigating down in the hierarchy.
This is an excerpt of the body of Activity.onCreate():
val navController = findNavController(R.id.navigationHostFragment)
val topLevelDestinations = getTopLevelDestinations()
// some additional destinations are defined statically in navigation.xml (e.g. settings)
val staticNavGraph = navInflater.inflate(R.navigation.navigation)
graph = staticNavGraph.apply {
setStartDestination(topLevelDestinations.first())
addDynamicDestinations(staticNavGraph, topLevelDestinations)
}
This code works initially. The NavGraph contains the top-level destinations.
Some top-level destinations offer navigation to children elements. These destinations are added to the NavGraph in a just-in-time manner, i.e. just before navigate() is called.
When the user navigated to a detail screen, the app process is killed and the app is re-opened, then onCreate() is called again and the app crashes during setGraph()/graph = with the following error:
java.lang.IllegalStateException: Restoring the Navigation back stack failed: destination -1178236840 cannot be found from the current destination Destination(0x1a356ec2) ...
at androidx.navigation.NavController.onGraphCreated(NavController.kt:1128)
at androidx.navigation.NavController.setGraph(NavController.kt:1086)
at androidx.navigation.NavController.setGraph(NavController.kt:100)
To solve this I'd be fine with either of these options:
Find a way, the entire NavGraph is saved persistently and restored
Prevent NavController from trying to recover the last destination, but show the initial start destination again.
Regarding 2. I tried calling navController.popBackStack(startDestination, false) and navController.clearBackStack(startDestination) before setGraph() is called but this doesn't seem to have the desired effect.
Related
How can I have a navGraph without a startDestination?
I have a BottomSheet fragment container. When some events are triggered I want to expand this BottomSheet and load a fragment to its fragment container. Until one of those events are triggered I don't want the fragment container to have any fragment loaded. I want it to be empty.
But if don't supply a valid startDestination to the navGraph the app crashes with the exception:
IllegalStateException: no start destination defined
Is this possible? What would be the best way to handle this?
EDIT
I know I can supply a startDestination programmatically. But this is not a graceful approach. It introduces clutter code that needs to be executed when the first fragment load needs to happen and it gets worse when that fragment needs arguments.
The goal is to skip supplying a startDestination altogether.
First solution:
Create empty fragment and set it as startDestination in graph
Second solution:
Wait until event triggered and open BottomSheet with startDestination argument. Read argument of BottomSheet and change start destination
navGraph.setStartDestination(R.id.fragment2)
Set a default startDestination in your navgraph. For me, for example, it is the destination I usually want to start with (R.id.trips_fragment)
Then, in my activity onCreate:
private fun setupMainNavigationGraph() {
val navController = findNavHostFragment().navController
val mainNavigationGraph = navController.navInflater.inflate(R.navigation.main_graph)
mainNavigationGraph.setStartDestination(intent.getIntExtra(START_DESTINATION_INTENT_EXTRA, R.id.trips_fragment))
navController.graph = mainNavigationGraph
}
This way, if I want to use a custom start destination, I pass the start destination ID as an Intent to the activity, and the graph is loaded with the overridden start destination.
Note, the line here
intent.getIntExtra(START_DESTINATION_INTENT_EXTRA, R.id.trips_fragment)
R.id.trips_fragment is the default value.
I use this for integration tests to override the start destination.
I need to check if an nav graph has deeplink and NavGraph provides hasDeeplink() method that I want to use. But I need this information outside activity or fragment. Is there any way to init NavGraph with navigation id resource outside fragment/activity?
I tried some options to get NavGraph instance but every ends with an exception.
Thanks
When using Jetpack Navigation, we can use popUpTo and popInclusive to clear the stack. But how do I clear the stack when I don't know what destination to popUpto?
Example
Say I have 3 main destinations that should have a clear stack when we arrive at them. And each main destination has its own flow of screens (which can be accessed directly with a deeplink).
Assuming the navigation flows downwards here:
Start app:
- Main Dest 1 (nav to dest 2)
- Dest 2 (nav to dest 3)
- Dest 3 (nav to main dest 2)
--clear--
- (with clear stack)
Main Dest 2 (nav to dest 4)
- Dest 4 (nav to main dest 3)
--clear-->
- (with clear stack)
Main Dest 3
- Back button should close the app here
Since the navigation is very dynamic, I cannot guarantee the root destination. Even though I could, for example, pop up to "Main Dest 2", I cannot know that it's on the stack since I might have gone directly to "dest 4" from a URL.
I there a way of knowing which destination is the lowest so I can pop up to it and clear the stack?
As far as I know there is no easy and beautiful way to get what you're trying to achieve.
There are a few possible solutions though.
Use some sort of a base fragment
You could have a base-destination which is always at the bottom of your stack, let's call it baseDest.
In your actions which need to clear the stack you could then always use
<action
android:id="..."
app:destination="#id/mainDestX"
app:popUpTo="#+id/baseDest"
app:popUpToInclusive="false" />
This will always keep your baseDest at the bottom of your stack allowing you to always popUpTo it.
To achieve your behaviour on a backpress in one of your main destinations you could either:
Override the onBackPressed-method in all your main dests to close the app. (Could be easily done if all your main destionations extend from the same class)
When your baseDest fragment is loaded because of an backpress close the app programmatically.
Rethink your app navigation
Maybe you should rethink how the navigation in your app works.
Rather than having some sort of possibly-cyclic graph structure it's easier to deal with tree-like navigation structures (which are also also more intuitively understood by users). Of course this is not always an option, but you should keep this in mind and consider restructuring your navigation.
Find a solution programmatically
Manually implement all your actions and navigations and use the methods provided in NavController to manually remove all entries in your stack before navigating to one of your main destinations.
One possible approch here to do this is to retrieve your nav controller by using the findNavController method in your fragment.
I have not tested any of the following methods, but you could try to:
Execute yourNavController.getGraph().clear() followed by a yourNavController.navigate(yourDest)
At the beginning of your application before any fragment is loaded use saved = yourNavController.saveState() and on navigation to one of your mainDestinations use yourNavController.restoreState(saved), again followed by a yourNavController.navigate(yourDest)
Create a new NavGraph and use yourNavController.setGraph(createdGraph)
I couldn't find any official solution to the problem, but here's an approach that works for me:
private val mBackStackField by lazy {
val field = NavController::class.java.getDeclaredField("mBackStack")
field.isAccessible = true
field
}
fun popToRoot(navController: NavController) {
val arrayDeque = mBackStackField.get(navController) as java.util.ArrayDeque<NavBackStackEntry>
val graph = arrayDeque.first.destination as NavGraph
val rootDestinationId = graph.startDestination
val navOptions = NavOptions.Builder()
.setPopUpTo(rootDestinationId, false)
.build()
navController.navigate(rootDestinationId, null, navOptions)
}
The root of your graph is always on the back stack, so you can always popUpTo that destination.
So just make sure your root <navigation> element has an android:id associated with it:
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="#+id/nav_graph"
app:startDestination="#+id/main_dest_1">
<!-- The rest of your graph -->
</navigation>
Then whenever you want to pop the entire graph, just popUpTo="#id/nav_graph".
Of course, as per the Principles of Navigation, you should not be popping the whole stack off when you go to Main Dest 2, Main Dest 3, etc. as it is extremely important from a UX perspective that users know when the back button will exit the app - that start destination is exactly that sign post for users to know when that'll happen and prevents accidental closure of the app when users expect to return to the first screen before exiting your app.
You can add a destination change listener in root or base fragment and listen for navigation changes.
NavController.OnDestinationChangedListener { controller, destination, arguments ->
// react on change or
//you can check destination.id or destination.label and act based on tha
)}
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've been following the docs from Navigation Architecture Component to understand how this new navigation system works.
To go/back from one screen to another you need a component which implements NavHost interface.
The NavHost is an empty view whereupon destinations are swapped in and
out as a user navigates through your app.
But, it seems that currently only Fragments implement NavHost
The Navigation Architecture Component’s default NavHost implementation is NavHostFragment.
So, my questions are:
Even if I have a very simple screen which can be implemented with an Activity, in order to work with this new navigation system, a Fragment needs to be hosted containing the actual view?
Will Activity implement NavHost interface in a near future?
--UPDATED--
Based on ianhanniballake's answer, I understand that every activity contains its own navigation graph. But if I want to go from one activity to another using the nav component (replacing "old" startActivity call), I can use activity destinations. What is activity destinations is not clear to me because the docs for migration don't go into any detail:
Separate Activities can then be linked by adding activity destinations to the navigation graph, replacing existing usages of startActivity() throughout the code base.
Is there any benefit on using ActivityNavigator instead of startActivity?
What is the proper way to go from activities when using the nav component?
The navigation graph only exists within a single activity. As per the Migrate to Navigation guide, <activity> destinations can be used to start an Activity from within the navigation graph, but once that second activity is started, it is totally separate from the original navigation graph (it could have its own graph or just be a simple activity).
You can add an Activity destination to your navigation graph via the visual editor (by hitting the + button and then selecting an activity in your project) or by manually adding the XML:
<activity
android:id="#+id/secondActivity"
android:name="com.example.SecondActivity" />
Then, you can navigate to that activity (i.e., start the activity) by using it just like any other destination:
Navigation.findNavController(view).navigate(R.id.secondActivity);
I managed to navigate from one activity to another without hosting a Fragment by using ActivityNavigator.
ActivityNavigator(this)
.createDestination()
.setIntent(Intent(this, SecondActivity::class.java))
.navigate(null, null)
I also managed to navigate from one activity to another without hosting a Fragment by using ActivityNavigator.
Kotlin:
val activityNavigator = ActivityNavigator( context!!)
activityNavigator.navigate(
activityNavigator.createDestination().setIntent(
Intent(
context!!,
SecondActivity::class.java
)
), null, null, null
)
Java:
ActivityNavigator activityNavigator = new ActivityNavigator(this);
activityNavigator.navigate(activityNavigator.createDestination().setIntent(new Intent(this, SecondActivity.class)), null, null, null);
nav_graph.xml
<fragment android:id="#+id/fragment"
android:name="com.codingwithmitch.navigationcomponentsexample.SampleFragment"
android:label="fragment_sample"
tools:layout="#layout/fragment_sample">
<action
android:id="#+id/action_confirmationFragment_to_secondActivity"
app:destination="#id/secondActivity" />
</fragment>
<activity
android:id="#+id/secondActivity"
android:name="com.codingwithmitch.navigationcomponentsexample.SecondActivity"
android:label="activity_second"
tools:layout="#layout/activity_second" />
Kotlin:
lateinit var navController: NavController
navController = Navigation.findNavController(view)
navController!!.navigate(R.id.action_confirmationFragment_to_secondActivity)