I'm using Android navigation jetpack library with BottomNavigationView. I have implemented the NavHost, the NavGraph and my fragments. Everything is working as intented when I use actions to navigate.
I use the following code to setup everything:
val navController = Navigation.findNavController(this, R.id.nav_host)
bottom_navigation.setupWithNavController(navController)
The problem is that if I click a tab 2 times the fragment is recreated twice. Is there any way to intercept navigation? I don't want to navigate to the same fragment that's being shown.
As per this issue,
Feel free to set a OnNavigationItemReselectedListener, which takes precedence over the OnNavigationItemSelectedListener set by NavigationUI.
val navController = Navigation.findNavController(this, R.id.nav_host)
bottom_navigation.setupWithNavController(navController)
bottom_navigation.setOnNavigationItemReselectedListener {
// Do nothing to ignore the reselection
}
inside setOnItemSelectedListener use :
if( item.getItemId() == navController.getCurrentDestination().getId()){ return true; }
because OnNavigationItemSelectedListener is deprecated now.
I write this extension. It will check current fragment with destinations and if both are same, it will only close drawer. But there is a some animation problem while closing drawer.
fun NavigationView.setupWithUniqueFragment(navController: NavController) {
this.setNavigationItemSelectedListener(object : NavigationView.OnNavigationItemSelectedListener {
override fun onNavigationItemSelected(item: MenuItem): Boolean {
val parent = this#setupWithUniqueFragment.parent
if (item.itemId == navController.currentDestination?.id) {
if (parent is DrawerLayout) {
parent.closeDrawer(this#setupWithUniqueFragment, true)
}
return true
}
val handled = NavigationUI.onNavDestinationSelected(item, navController)
if (handled) {
if (parent is DrawerLayout) {
parent.closeDrawer(this#setupWithUniqueFragment, true)
}
}
return handled
}
})
val weakReference = WeakReference<NavigationView>(this#setupWithUniqueFragment)
navController.addOnDestinationChangedListener(
object : NavController.OnDestinationChangedListener {
override fun onDestinationChanged(
controller: NavController,
destination: NavDestination, arguments: Bundle?
) {
val view = weakReference.get()
if (view == null) {
navController.removeOnDestinationChangedListener(this)
return
}
val menu = view.menu
var h = 0
val size = menu.size()
while (h < size) {
val item = menu.getItem(h)
item.isChecked = matchDestination(destination, item.itemId)
h++
}
}
})
}
internal fun matchDestination(
destination: NavDestination,
#IdRes destId: Int
): Boolean {
var currentDestination: NavDestination? = destination
while (currentDestination!!.id != destId && currentDestination.parent != null) {
currentDestination = currentDestination.parent
}
return currentDestination.id == destId
}
I had this same problem where I am using an About page (using the really good AboutLibraries), and it would stack up duplicate pages. I ended up doing this in my OnOptionsItemSelected method
case R.id.action_about:
NavController navController = Navigation.findNavController( this, R.id.nav_host_fragment );
if ( navController.getCurrentDestination().getId() != R.id.nav_about )
{
navController.navigate( R.id.nav_about );
}
And now it only shows a single fragment and won't stack the same one ontop. It's a shame this behaviour couldn't be defined in the XML and I needed to do this in code.
Related
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
}
}
}
})
}
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
I want to have a bottom navigation bar with two items/screens: Order and Account. Order is the start destination. Order has its own navigation and it has two screens: ItemList and ItemDetail. ItemDetail opens when an item is clicked in ItemList screen.
When I run the app, I can see the ItemList screen but Order item in the bottom navigation bar is not selected. If I click on Account item, I can see Account screen and Account item gets selected in the bottom navigation bar.
I think this is happening because of the recomposition: when Order is selected at the beginning since it is the start destination, its nested graph is called and a new destination (ItemList) is navigated, leading a recomposition, with currentRoute being "itemList" rather than "order".
How can I get Order icon selected in the bottom navigation bar? Is there a recommended what of handling nested graphs with bottom nav?
This is what I have at the moment:
object Destinations {
const val ORDER_ROUTE = "order"
const val ACCOUNT_ROUTE = "account"
const val ITEM_LIST_ROUTE = "itemList"
const val ITEM_DETAIL_ROUTE = "itemDetail"
const val ITEM_DETAIL_ID_KEY = "itemId"
}
class NavigationActions(navController: NavHostController) {
val selectItem: (Long) -> Unit = { itemId: Long ->
navController.navigate("${Destinations.ITEM_DETAIL_ROUTE}/$itemId")
}
val upPress: () -> Unit = {
navController.navigateUp()
}
}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MyApp()
}
}
}
#Compose
fun MyApp() {
MyAppTheme {
val navController = rememberNavController()
val tabs = listOf(Destinations.ORDER_ROUTE, Destinations.ACCOUNT_ROUTE)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.arguments?.getString(KEY_ROUTE)
Scaffold(
bottomBar = {
BottomNavigation {
tabs.forEach { tab ->
BottomNavigationItem(
icon = { Icons.Filled.Favorite },
label = { Text(tab) },
selected = currentRoute == tab,
onClick = {
navController.navigate(tab) {
popUpTo = navController.graph.startDestination
launchSingleTop = true
}
},
alwaysShowLabel = true,
selectedContentColor = MaterialTheme.colors.secondary,
unselectedContentColor = LocalContentColor.current
)
}
}
}
) {
NavGraph(navController)
}
}
}
#Composable
fun NavGraph(
navController: NavHostController,
startDestination: String = Destinations.ORDER_ROUTE
) {
val actions = remember(navController) { NavigationActions(navController) }
NavHost(navController = navController, startDestination = startDestination) {
navigation(startDestination = Destinations.ITEM_LIST_ROUTE, route = Destinations.ORDER_ROUTE) {
composable(Destinations.ITEM_LIST_ROUTE) {
ItemList(actions.selectItem)
}
composable(
"${Destinations.ITEM_DETAIL_ROUTE}/{$Destinations.ITEM_DETAIL_ID_KEY}",
arguments = listOf(navArgument(Destinations.ITEM_DETAIL_ID_KEY) {
type = NavType.LongType
})
) {
ItemDetail()
}
}
composable(Destinations.ACCOUNT_ROUTE) {
Account()
}
}
}
I wrote this article with a similar example. It's in Portuguese but if you translate the page to English you'll get the idea... Also, you can find the sources here.
I think the problem is happening because you're using just one NavHost for the entire app. In fact, I guess you need to use one NavHost for each tab, then when the user select a tab, you must change the current NavHost.
oh! my article is based on this post here, which can also help you.
On my action, I use the onBackPressed() method. When I compare the fragment that triggered the event, the id is never the same and always falls into the else-statement. What's wrong?
override fun onBackPressed() {
val navHost = this.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
val pressed = navHost?.childFragmentManager.fragments?.get(0) as IOnBackPressed
var currentFragment = navHost?.childFragmentManager.fragments?.get(0)
pressed?.onBackPressed()?.takeIf { it }?.let {
when (currentFragment.id) {
R.id.myfragmet -> {
// implementation
}
else -> {
super.onBackPressed()
}
}
}
}
I want to know from what fragment I returning when I press the back button, so that I can do the necessary action. Let me simplify the code:
override fun onBackPressed() {
// get the nav host in the action
val navHost = this.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
// get the current fragment in the nav host
var currentFragment = navHost?.childFragmentManager.fragments?.get(0)
// Verify the fragment to do a action
when (currentFragment.id) {
R.id.myfragmet -> {
// implementation
}
else -> {
super.onBackPressed()
}
}
}
But the currentFragment.Id is never equals R.id.myfragemnt
I have a simple activity with a BottomNavigationView. I'm using fragments to implement the contents of the activity for the different pages.
When the user presses the back button, it's supposed to go back to the previously looked at page. The problem is, when you repeatedly switch back and forth between the pages (fragments), this entire history is recorded. Take this example:
A -> B -> A -> B -> C -> A -> C
Pressing the back button would result in the reverse, but instead I want this behaviour (I noticed it in the Instagram app):
C -> A -> B -> Exit App
So every fragment should only have one entry in the backstack. How do I do this? I do I remove the previous transactions for a fragment from the stack?
Is this at all possible using a FragmentManager? Or do I have to implement my own?
My Activity with the BottomNavigationView:
class ActivityOverview : AppCompatActivity() {
// Listener for BottomNavigationView
private val mOnNavigationItemSelectedListener = BottomNavigationView.OnNavigationItemSelectedListener { item ->
when (item.itemId) {
R.id.navigation_home -> {
// "Home" menu item pressed
setActiveFragment(resources.getString(R.string.tag_fragment_home))
return#OnNavigationItemSelectedListener true
}
R.id.navigation_dashboard -> {
// "Dashboard" menu item pressed
return#OnNavigationItemSelectedListener true
}
R.id.navigation_settings -> {
// "Settings" menu item pressed
setActiveFragment(resources.getString(R.string.tag_fragment_settings))
return#OnNavigationItemSelectedListener true
}
}
false
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_overview)
navigation.setOnNavigationItemSelectedListener(mOnNavigationItemSelectedListener)
navigation.menu.findItem(R.id.navigation_home).setChecked(true)
// Set initial fragment
setActiveFragment(resources.getString(R.string.tag_fragment_home))
}
override fun onBackPressed() {
// > 1 so initial fragment addition isn't removed from stack
if (fragmentManager.backStackEntryCount > 1) {
fragmentManager.popBackStack()
} else {
finish()
}
}
// Update displayed fragment
fun setActiveFragment(tag: String) {
val fragment = if (fragmentManager.findFragmentByTag(tag) != null) {
// Fragment is already initialized
if (fragmentManager.findFragmentByTag(tag).isVisible) {
// Fragment is visible already, don't add another transaction
null
} else {
// Fragment is not visible, add transaction
fragmentManager.findFragmentByTag(tag)
}
} else {
// Fragment is not initialized yet
when (tag) {
resources.getString(R.string.tag_fragment_home) -> FragmentHome()
resources.getString(R.string.tag_fragment_settings) -> FragmentSettings()
else -> null
}
}
if (fragment != null) {
val transaction = fragmentManager.beginTransaction()
transaction.replace(R.id.container_fragment, fragment, tag)
transaction.addToBackStack(null)
transaction.commit()
}
}
}
At this point I'm pretty sure it doesn't work with FragmentManager, so I created a class to implement a stack that doesn't allow duplicates:
class NoDuplicateStack<T> {
val stack: MutableList<T> = mutableListOf()
val size: Int
get() = stack.size
// Push element onto the stack
fun push(p: T) {
val index = stack.indexOf(p)
if (index != -1) {
stack.removeAt(index)
}
stack.add(p)
}
// Pop upper element of stack
fun pop(): T? {
if (size > 0) {
return stack.removeAt(stack.size - 1)
} else {
return null
}
}
// Look at upper element of stack, don't pop it
fun peek(): T? {
if (size > 0) {
return stack[stack.size - 1]
} else {
return null
}
}
}
I then integrated this class into my activity:
class ActivityOverview : AppCompatActivity() {
val fragmentsStack = NoDuplicateStack<String>()
val fragmentHome = FragmentHome()
val fragmentSettings = FragmentSettings()
val fragmentHistory = FragmentHistory()
// Listener for BottomNavigationView
private val mOnNavigationItemSelectedListener = ...
override fun onCreate(savedInstanceState: Bundle?) {
...
}
override fun onBackPressed() {
if (fragmentsStack.size > 1) {
// Remove current fragment from stack
fragmentsStack.pop()
// Get previous fragment from stack and set it again
val newTag = fragmentsStack.pop()
if (newTag != null) {
setActiveFragment(newTag)
}
} else {
finish()
}
}
// Update displayed fragment
fun setActiveFragment(tag: String) {
val fragment = when (tag) {
resources.getString(R.string.tag_fragment_home) -> fragmentHome
resources.getString(R.string.tag_fragment_settings) -> fragmentSettings
resources.getString(R.string.tag_fragment_history) -> fragmentHistory
else -> null
}
if (fragment != null && !fragment.isVisible) {
fragmentManager.beginTransaction()
.replace(R.id.container_fragment, fragment, tag)
.commit()
fragmentsStack.push(tag)
}
}
}
I also faced the same problem, I did this which uses the system stack
val totalFragments = supportFragmentManager.backStackEntryCount
if (totalFragments != 0) {
val removed = supportFragmentManager.getBackStackEntryAt(totalFragments - 1)
poppedFragments.add(removed.name!!)
for (idx in totalFragments - 1 downTo 0) {
val fragment = supportFragmentManager.getBackStackEntryAt(idx)
if (!poppedFragments.contains(fragment.name)) {
supportFragmentManager.popBackStack(fragment.name, 0)
return
}
}
finish()
return
}
super.onBackPressed()
and then added this while launching the fragment
if (poppedFragments.contains(tag)) {
poppedFragments.remove(tag)
}