I am using the latest versions of navigation and compose on Android, and i am getting a bug where i can't pop the starting destination of the navigation.
The problem is, if i have 3 destinations (A,B,C) and go from A-> B -> C, i can't pop A from the backstack , but B is popped when i call popUpTo(B) inclusive = true, causing the back button to go back to A.
My code:
NavHost
setContent {
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = "route_to_a"
) {
composable("route_to_a") {
LoginScreen(navController)
}
composable("route_to_b") {
RegisterScreen(navController)
}
composable("route_to_c") {
HomeScreen(navController = navController)
}
}
}
Navigations
- A to B
Button(onClick = { navController.navigate("route_to_b")}) {}
- B to C
Button(onClick = {
navController.navigate("route_to_c") {
popUpTo("route_to_b") {
inclusive = true
}
}
}) {}
I want to create a flow where neither A and B are on the backstack after getting on C. But for some reason i can't remove A from the backstack...how can i do it?
Replace,
popUpTo("route_to_b") {
inclusive = true
}
with this,
popUpTo("route_to_a") {
inclusive = true
}
From Docs,
// Pop everything up to and including the "home" destination off
// the back stack before navigating to the "friends" destination
navController.navigate("friends") {
popUpTo("home") { inclusive = true }
}
Related
By default in Android, when using the BottomNavigationView, navigation by pressing on item looks like this:
A -> B -> C
Back button: C -> A
But when using setOnItemSelectedListener, the navigation breaks and when you click on the back button it looks completely different:
A -> B -> C
Back button: C -> B -> A
How can I fix this and make it so that when the back button is clicked, the navigation always leads to the first (startDestination) item?
My code:
bottomNavigationView.setOnItemSelectedListener {
when(it.itemId) {
R.id.homeFragment -> { navController.navigate(R.id.homeFragment) }
R.id.favoriteFragment -> { navController.navigate(R.id.favoriteFragment) }
R.id.profileFragment -> { navController.navigate(R.id.profileFragment) }
}
true
}
menu.setOnNavigationItemSelectedListener {
when(it.itemId){
R.id.homeFragment -> {
val graph = navController.graph
graph.startDestination = R.id.homeFragment
navController.graph = graph
}
}
}
But that's the best menu.setupWithNavController(navController)
That is the code you looking for:
bottomNavigationView.setOnItemSelectedListener { item ->
NavigationUI.onNavDestinationSelected(item, navController)
navController.popBackStack(item.itemId, inclusive = false)
true
}
This will delete all bottom navigation item's backtrack.
We have implemented bottom navigation as described here:
https://developer.android.com/guide/navigation/navigation-ui#bottom_navigation
https://medium.com/androiddevelopers/navigation-multiple-back-stacks-6c67ba41952f
We are using navigation version 2.4.1, which supports multiple backstacks out of the box. This saves fragment state so that in navigating from main fragment A -> B -> C -> B using the bottomnav, state of fragment B is saved and restored upon return. This is as intended and much requested behaviour.
However, for one of the fragments in our bottomnav menu, I would like the possibility to NOT save the state. This is due to some confusing behaviour when navigating using talkback. Is there a way in the navigation framework to set a flag to not save state for a single fragment? Or any other way to programmatically clear savedstate without actually doing so "manually" by resetting the UI elements in fragment onDestroy/onResume or similar?
What I did was just use the same androidx.navigation.ui.NavigationUI.setupWithNavController logic but change the saveState and other logic specific to my use case. You could apply this when navigating to one specific fragment.
this.findViewById<BottomNavigationView>(R.id.bottom_navigation).apply {
setOnItemSelectedListener { item ->
val builder = NavOptions.Builder().setLaunchSingleTop(true)
val destinationId = item.itemId
item.isChecked = true
if (
navController.currentDestination!!.parent!!.findNode(item.itemId)
is ActivityNavigator.Destination
) {
builder.setEnterAnim(R.anim.nav_default_enter_anim)
.setExitAnim(R.anim.nav_default_exit_anim)
.setPopEnterAnim(R.anim.nav_default_pop_enter_anim)
.setPopExitAnim(R.anim.nav_default_pop_exit_anim)
} else {
builder.setEnterAnim(R.animator.nav_default_enter_anim)
.setExitAnim(R.animator.nav_default_exit_anim)
.setPopEnterAnim(R.animator.nav_default_pop_enter_anim)
.setPopExitAnim(R.animator.nav_default_pop_exit_anim)
}
if (item.order and Menu.CATEGORY_SECONDARY == 0) {
builder.setPopUpTo(
navController.graph.findStartDestination().id,
inclusive = false,
saveState = false
)
}
val options = builder.build()
return#setOnItemSelectedListener try {
navController.navigate(destinationId, null, options)
// Return true only if the destination we've navigated to matches the MenuItem
(navController.currentDestination?.id ?: false) == destinationId
} catch (e: IllegalArgumentException) {
false
}
}
// Do nothing on reselect
setOnItemReselectedListener {}
val weakReference = WeakReference(this)
navController.addOnDestinationChangedListener(
object : NavController.OnDestinationChangedListener {
override fun onDestinationChanged(
controller: NavController,
destination: NavDestination,
arguments: Bundle?
) {
// Hide BottomNavigationView from top level fragments
if (topLevelDestinations.any { it == destination.id }) {
this#apply.visibility = View.VISIBLE
} else this#apply.visibility = View.GONE
// Highlight item in BottomNavigationView
val view = weakReference.get()
if (view == null) {
navController.removeOnDestinationChangedListener(this)
return
}
view.menu.forEach { item ->
if (destination.id == item.itemId) {
item.isChecked = true
}
}
}
})
}
I am working on Jetpack Compose Navigation demo and I have a nested navigation graph with two different nested routes and screens for each nested route:
Login Graph
Main Graph
Login Graph has three routes for display three different Screens
Route "login" for displaying LoginScreen
Route "register" for displaying RegisterScreen
Route "recoverPassword" for displaying RecoverPasswordScreen
Main Graph has two routes for these screens
Route "home" for displaying HomeScreen
Route "settings" for displaying SettingsScreen
The nested graph creation is called in the MainActivity.kt
setContent {
NavigationDemoTheme {
val navController = rememberNavController()
SetupNavGraph(navController = navController)
}
}
The function in the file NestedNavGraph.kt looks like this:
fun SetupNavGraph(navController: NavHostController) {
NavHost(navController = navController, startDestination = "login_route")
{
loginGraph(navController = navController)
mainGraph(navController = navController)
}
}
In the file LoginNavGraph.kt I have defined the routes and start destination
fun NavGraphBuilder.loginGraph(navController: NavController) {
navigation(startDestination = "login", route = "login_route") {
composable(route = "login") {
LoginScreen(navController = navController)
}
composable(route = "register") {
RegisterScreen(navController = navController)
}
composable(route = "recover") {
RecoverPasswordScreen(navController = navController)
}
}
}
In the file MainNavGraph.kt I have defined these two routes and this start destination:
navigation(startDestination = "home", route = "main_route") {
composable(route = "home") {
HomeScreen(navController = navController)
}
composable(route = "settings") {
SettingsScreen(navController = navController)
}
}
My questions now is: How can I display the RecoverPasswordScreen from SettingsScreen. I know I can navigate to the "login_route" from the SettingsScreen with but then the startDestination will be displayed, which is the LoginScreen.
// shows the LoginScreen because the startDestination in the "login_route" is set to "login"
navController.navigate(route = "login_route")
So, how can I directly navigate to the route "recover" in the nested graph route "login_route"? The following "workarounds" are in my mind:
Pass a parameter to the "login_route", for example something with:
navController.navigate(route = "login_route?destination=recover")
I will then have only a single route as a destination, for example "LoginView". This will change the loginGraph like this:
fun NavGraphBuilder.loginGraph(navController: NavController) {
navigation(startDestination = "login_view, route = "login_route/{destination}) {
composable(
route = "login_view",
arguments = listOf(
navArgument("destination") { defaultValue = "login" },
)
) { backStackEntry ->
val destination = backStackEntry.arguments?.getString("destination");
destination?.let { destination ->
LoginView(destination = destination)
}
}
}
}
The LoginView is composable whichw will have a own NavHost where I can set the startDestination with the query parameter from the previous route:
fun LoginView( destination : String = "login"){
val navController = rememberNavController()
var startDestination = destination;
Scaffold ()
{
NavHost(
navController = navController,
startDestination = startDestination
) {
composable(route = "login") {
LoginScreen(navController = navController)
}
composable(route = "register") {
RegisterScreen(navController = navController)
}
composable(route = "recover") {
RecoverPasswordScreen(navController = navController)
}
}
}
Now I should be able to call the RecoverPasswordScreen from the SettingsScreen with this:
navController.navigate(route = "login_route?destination=recover")
Another possibility is to have extra route for the RecoverPassword Screen in the MainGraph defined. Is there any other possibilty to directly acess a route in a nested graph? It would be great if could dynamically change startDestination when routing to "login_route" but I don't know how or if this is even possible.
Compose allows you to (Navigate with arguments). This allows you to navigate to what you are calling "nested routes", that is a specific part within a screen.
Now, this is a simple explanation and I could leave you and have you figure it out. But I don't think this would be helpful to you as I think you have implemented your navigation in a difficult manner. Hence why trying to navigate is a bit more complex.
Here is a better way to implement it so that navigation like the one you want(RecoverPasswordScreen from Settings Screen) is easier.
Disclaimers
Change anything that's referred to as Main to your AppName.
I have not added all your screens
Main Screen class
//you could pass in parameters if needed into this constructor
enum class MainScreen(){
//these are your screens
LogIn(),
Settings(),
Recover(),
Home();
companion object {
fun fromRoute(route: String?): MainScreen =
when (route?.substringBefore("/")) {
LogIn.name -> LogIn
Home.name -> Home
Settings.name -> Settings
Recover.name -> Recover
//add the remaining screens
// a null route resolves to LogInScreen.
null -> LogIn
else -> throw IllegalArgumentException("Route $route is not recognized.")
}
}
}
Main Activity Class
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MainApp()
}
}
}
#Composable
fun MainApp() {
MainTheme {
val allScreens = MainScreen.values().toList()
val navController = rememberNavController()
val backStackEntry = navController.currentBackStackEntryAsState()
// currentScrren user is on good if app is large
val currentScreen = MainScreen.fromRoute(
backStackEntry.value?.destination?.route
)
//Using scaffold is a good idea
Scaffold(
//add topAppBar and all other things here
) { innerPadding ->
MainNavHost(navController = navController, modifier = Modifier.padding(innerPadding))
}
}
}
//Scaffold requires innerPadding so remove if you decide not to use scaffold
#Composable
fun MainNavHost(navController: NavHostController, modifier: Modifier = Modifier) {
NavHost(
navController = navController,
startDestination = LogIn.name,
modifier = modifier
) {
composable(LogIn.name) {
/**
Your body for logIn page
**/
}
//this is how you will navigate to Recover Screen from settings
composable(Settings.name) {
SettingsBody(onClickRecoverScreen = {navController.navigate(Recover.name)})
}
}
composable(Recover.name) {
/**
Your body for Recover page
**/
}
composable(Home.name) {
/**
Your body for Home page
**/
}
}
Settings Screen
#Composable
fun SettingsBody(
//this callback is how you will navigate from Settings to RecoverPassword
onClickRecoverScreen: () -> Unit = {},
) {
Column(
//Add your designs for this screen
) {
Button(onClick = {onClickRecoverScreen})
}
}
This is the simplest way (in my opinion) to implement Navigation as you can simply add callbacks to navigate to different places in the app and it is much more testable(if you test ;) ) and scalable. You can also add deep links and use arguments (as mentioned above) to navigate to specific parts of the app (e.g., a specific account in an Accounts Screen)
I highly recommend this Navigation Codelab if you want to understand more.
A possible solution is to use deeplinks defined in the navigation graph - they also work for nested destinations. Then, instead of navigating to the route name, you can use navController.navigate(deepLinkUri)
So I have two tabs, Tab A and Tab B. Each tab has its own back stack. I implemented the multiple back stack navigation using code in this google docs
val navController = rememberNavController()
Scaffold(
bottomBar = {
BottomNavigation {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
BottomNavigationItem(
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
label = { Text(stringResource(screen.resourceId)) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
)
}
}
}
) {
NavHost(navController, startDestination = A1.route) {
composable(A1.route) {
val viewModelA1 = hiltViewModel()
A1(viewModelA1)
}
composable(A2.route) {
val viewModelA2 = hiltViewModel()
A2(viewModelA2)
}
composable(A3.route) {
val viewModelA3 = hiltViewModel()
A3(viewModelA3)
}
}
}
Tab A has 3 screens (Screen A1 -> Screen A2 -> Screen A3). I use the hiltViewModel() function to instantiate the view model and I invoked it inside the composable() block for each screen
The issue is when I'm navigating from A1 to A2 to A3 and then when I change tab to Tab B, the view model for Screen A2 seems like it's being disposed (onCleared is called). So when I go back to Tab A displaying Screen A3 then hit back to Screen A2, the view model for A2 is instantiated again (init block is called again). What I wanted to achieve is to retain the view model for A2 for this flow until I back out of A2.
Is this even possible?
This seems like a bug when you click on the next navigation item too fast, while the current view appear transition is not yet finished. This is a known issue, please star it to bring more attention.
Meanwhile you can wait current screen transition to finish before navigating to the next one. To do so you can check visibleEntries variable and navigate only after it contains only single item.
Also I think that current documentation provide not the best example of bottom navigation, because if you're not on a start destination screen, pressing back button will bring you back to the start destination, when I expect the view to be dismissed. So I've changed how you navigate too, if you're fine with the documentation behaviour, you can replace content of fun navigate() with your own.
val navController = rememberNavController()
var waitEndAnimationJob by remember { mutableStateOf<Job?>(null)}
Scaffold(
bottomBar = {
BottomNavigation {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val scope = rememberCoroutineScope()
items.forEach { screen ->
fun navigate() {
navController.navigate(screen.route) {
val navigationRoutes = items
.map(Screen::route)
val firstBottomBarDestination = navController.backQueue
.firstOrNull { navigationRoutes.contains(it.destination.route) }
?.destination
// remove all navigation items from the stack
// so only the currently selected screen remains in the stack
if (firstBottomBarDestination != null) {
popUpTo(firstBottomBarDestination.id) {
inclusive = true
saveState = true
}
}
// Avoid multiple copies of the same destination when
// reselecting the same item
launchSingleTop = true
// Restore state when reselecting a previously selected item
restoreState = true
}
}
BottomNavigationItem(
icon = { Icon(Icons.Filled.Favorite, contentDescription = null) },
label = { Text(stringResource(screen.resourceId)) },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
// if we're already waiting for an other screen to start appearing
// we need to cancel that job
waitEndAnimationJob?.cancel()
if (navController.visibleEntries.value.count() > 1) {
// if navController.visibleEntries has more than one item
// we need to wait animation to finish before starting next navigation
waitEndAnimationJob = scope.launch {
navController.visibleEntries
.collect { visibleEntries ->
if (visibleEntries.count() == 1) {
navigate()
waitEndAnimationJob = null
cancel()
}
}
}
} else {
// otherwise we can navigate instantly
navigate()
}
}
)
}
}
}
) { innerPadding ->
// ...
}
Found the root cause of this. I was using these dependencies and they don't seem to go together.
androidx.hilt:hilt-navigation-compose:1.0.0-alpha03
androidx.navigation:navigation-compose:2.4.0-alpha10"
I removed the navigation:navigation-compose dependency and it seemed to work fine now.
According to this example I implemented shared viewModels in a nested navigation graph.
Setup
Nested Graph:
private fun NavGraphBuilder.accountGraph(navController: NavHostController) {
navigation(
startDestination = "main",
route = "account") {
composable("main") {
val vm = hiltViewModel<AccountViewModel(navController.getBackStackEntry("account"))
//... ui ...
}
composable("login") {
val vm = hiltViewModel<AccountViewModel(navController.getBackStackEntry("account"))
//... ui ...
}
}
}
NavHost:
#Composable
private fun NavHost(navController: NavHostController, modifier: Modifier = Modifier){
NavHost(
navController = navController,
startDestination = MainScreen.Home.route,
modifier = modifier
) {
composable("home") { HomeScreen(hiltViewModel()) }
composable("otherRoute") { OtherScreen(hiltViewModel()) }
accountGraph(navController)
}
}
BottomNavBar:
#Composable
private fun ButtonNav(navController: NavHostController) {
BottomNavigation {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
items.forEach { screen ->
BottomNavigationItem(
icon = { ... },
label = { ... },
selected = currentDestination?.hierarchy?.any { it.route == screen.route } == true,
onClick = {
navController.navigate(screen.route) {
// Pop up to the start destination of the graph to
// avoid building up a large stack of destinations
// on the back stack as users select items
navController.graph.startDestinationRoute?.let { route ->
popUpTo(route) { saveState = true }
}
// Avoid multiple copies of the same destination when
// re-selecting the same item
launchSingleTop = true
// Restore state when re-selecting a previously selected item
restoreState = true
}
}
)
}
}
}
Problem
With this setup if I naviagte to "account" (the nested graph) and back to any other route I get the error:
java.lang.IllegalArgumentException: No destination with route account is on the NavController's back stack. The current destination is Destination(0x78dd8526) route=otherRoute
Assumptions / Research Results
BottomNavItem
The exception did not occure when I remove the popUpTo(route) onClick. But then I ended up with a large stack.
lifecycle of backStackEntry
Have a look at the following:
//...
composable("main") { backStackEntry ->
val vm = hiltViewModel<AccountViewModel(navController.getBackStackEntry("account"))
//... ui ...
}
//...
I found out when navigating back the composable which will be left will be recomposed but in this case the backStackEntry seams to have another lifecycle.currentState because if I wrap the whole composable like this:
//...
composable("main") { backStackEntry ->
if(backStackEntry.lifecycle.currentState == Lifecycle.State.RESUMED){
val vm = hiltViewModel<AccountViewModel(navController.getBackStackEntry("account"))
//... ui ...
}
}
//...
... the exception did not occure.
The idea with the lifecycle issue came into my mind when I saw that the offical example has similar workarounds in place.
Summary
I actually do not know if I did something wrong or if I miss a conecept here. I can put the lifecycle-check-workaround into place but is this really as intended? Additional to that I did not find any hint in the doc regarding that.
Does anybody know how to fix that in a proper way?
Regards,
Chris
This is how you do it now but make sure you have the latest compose navigation artefacts:
private fun NavGraphBuilder.accountGraph(navController: NavHostController) {
navigation(
startDestination = "main",
route = "account") {
composable("main") {
val parentEntry = remember {
navController.getBackstackEntry("account")
}
val vm = hiltViewModel<AccountViewModel(parentEntry)
//... ui ...
}
composable("login") {
val parentEntry = remember {
navController.getBackstackEntry("account")
}
val vm = hiltViewModel<AccountViewModel(parentEntry)
//... ui ...
}
}
}
There was an issue with the navigation component. It has been fixed for me with v2.4.0-alpha08