androidx.navigation multiple destinations for menu item - android

I have a single activity app using the androidx navigation library. For one of the menu destinations I effectively have a fragment as destination with no view whatsoever that depending on the state of the user provided configuration either redirects to the real destination that should be there or to one of currently two different views that tell the user that either he needs to setup a configuration first or that there currently is no active configuration (deleted?) and he needs to select one of the available configurations.
Now, functionally this approach works perfectly fine. However, since androidx navigation ties menu items to destinations by id the menu item that gets you to that view is never selected as it matches the fragment destination with no view in it.
I tried to add a NavController.OnDestinationChangedListener to my Activity and added it to the navController navController.addOnDestinationChangedListener(this). But it seems to get overwritten by the navigation afterwards.
override fun onDestinationChanged(controller: NavController, destination: NavDestination, arguments: Bundle?) {
val destinations = listOf(R.id.destinationA, R.id.destinationB, R.id.destinationC)
if(destinations.contains(destination.id)) {
nav_view.menu.getItem(0).isChecked = true
}
}
It is deffinitely the right menu item. As when I change isChecked = true to isEnabled = false I can no longer click on it.
Also when I do this odd hack it works
GlobalScope.launch(Dispatchers.Main) {
delay(1000)
nav_view.menu.getItem(0).isChecked = true
}
Needless to say this is not a very good solution.
Anyone here knows how to overwride the default behaviour of androidx navigation in this regard?
I´ll come back to this later and report back if I find a proper solution to this.
Adding a listener to the drawer opening and setting the selected menu item then might be a good workaround for this if it is not possible to do currently.

Instead of using setupWithNavController(), as mentioned in the documentation, setup it up yourself.
As mentioned here, onNavDestinationSelected() helper method in NavigationUI is called when the menu item is clicked when you set it up using setupWithNavController(). So you could try something like this:
yourNavigationView.setNavigationItemSelectedListener { item: MenuItem ->
if(item.itemId == R.id.noViewFragmentId) {
val isConfigurationProvided = ...
if(!isConfigurationProvided) {
//Perform your actions (navigate to either of the two alternate views)
return#setNavigationItemSelectedListener true
}
}
val success = NavigationUI.onNavDestinationSelected(item, navController)
if(success) {
drawerLayout.closeDrawer(GravityCompat.START)
item.isChecked = true
}
success
}

I´ll add this as a possible solution and stick with it for the time being. I still feel like there should be a better way to do this, so I will not accept it as an awnswer.
It´s essentially the idea I got at the end of writing the question
Adding a listener to the drawer opening and setting the selected menu item then might be a good workaround for this if it is not possible to do currently.
class SetActiveMenuDrawerListener(
private val navController: NavController,
navigationView: NavigationView) : DrawerLayout.DrawerListener {
private var checked = false
private val destinations = listOf(R.id.destinationA, R.id.destinationB, R.id.destinationC)
private val menu = navigationView.menu.getItem(0)
init {
navController.addOnDestinationChangedListener { _, _, _ -> checked = false }
}
override fun onDrawerSlide(drawerView: View, slideOffset: Float) {
}
override fun onDrawerOpened(drawerView: View) {
}
override fun onDrawerClosed(drawerView: View) {
}
override fun onDrawerStateChanged(newState: Int) {
if(checked) return
val currentDestination = navController.currentDestination ?: return
if(destinations.contains(currentDestination.id)) {
menu.isChecked = true
}
checked = true
}
}
Then add this to the DrawerLayout
drawer_layout.addDrawerListener(SetActiveMenuDrawerListener(navController, nav_view))
I did add the code into the onDrawerStateChanged instead onDrawerOpened, because onDrawerOpened gets called a bit late if clicking the drawer and not at all while dragging it.
It´s not the pretties thing to look at, but it gets the job done.

Related

Android: Override Navigate Up in App Bar Defined in Fragment

I have an app bar defined from my fragment rather than activity by using
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
binding.toolbar.apply {
//add menu
inflateMenu(R.menu.menu_fragment)
//setup with navcontroller/navgraph
setupWithNavController(findNavController())
}
}
The problem I'm facing is trying to implement a warning message when a user clicks the Navigate Up button using the app bar. I want this behaviour only in one fragment.
I've found solutions online pertaining to app bars defined in an activity but they don't seem to work for me (such as using override fun onSupportNavigateUp().
Any ideas if I may be able to accomplish this?
Update
Initially, I implemented the chosen answer which worked but was causing some memory leaks. The kind individual who answered this question also found a workaround for the memory leaks here . Unfortunately, it didn't work so great for me (I believe because I am using navigation components) but it may work for you.
I later realized that I could easily override the navigate up default behaviour by adding this piece of line to my toolbar code:
binding.toolbar.apply {
//add menu
inflateMenu(R.menu.menu_fragment)
//setup with navcontroller/navgraph
setupWithNavController(findNavController())
//****************ADD THIS******************
setNavigationOnClickListener { view ->
//do what you want after user clicks navigate up button
}
}
The problem I'm facing is trying to implement a warning message when a user clicks the Navigate Up button using the app bar. I want this behaviour only in one fragment.
So, you just need to catch the event of hitting the UP button of the app bar for that particular fragment.
You can enable the options menu for that fragment:
setHasOptionsMenu(true)
And override onOptionsItemSelected to catch the UP button id:
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
// Handle the UP button here
Toast.makeText(requireContext(), "UP button clicked", Toast.LENGTH_SHORT).show()
return true
}
return super.onOptionsItemSelected(item)
}
Note: if you want to use a unique toolbar for that fragment other than the default one, check this answer.
now I am unable to inflate my menu using inflateMenu(R.menu.menu_fragment). Any ideas?
You can remove this inflation, and instead override onCreateOptionsMenu for that:
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
inflater.inflate(R.menu.menu_fragment, menu)
}
onCreateOptionsMenu() didn't work for me,
I have write this code part in onCreate() for Activity; (navigationView is id my NavigationView)
for (i in 0 until navigationView.menu!!.size()) {
val item = navigationView.menu.getItem(i)
val s = SpannableString(item.title)
s.setSpan(AlignmentSpan.Standard(Layout.Alignment.ALIGN_CENTER), 0, s.length, 0)
item.title = s
}

When setting navigation graph programmatically after recreating activity, wrong fragment is shown

I am setting a navigation graph programmatically to set the start destination depending on some condition (for example, active session), but when I tested this with the "Don't keep activities" option enabled I faced the following bug.
When activity is just recreated and the app calls method NavController.setGraph, NavController forces restoring the Navigation back stack (from internal field mBackStackToRestore in onGraphCreated method) even if start destination is different than before so the user sees the wrong fragment.
Here is my MainActivity code:
class MainActivity : AppCompatActivity() {
lateinit var navController: NavController
lateinit var navHost: NavHostFragment
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.main_activity)
log("fresh start = ${savedInstanceState == null}")
navHost = supportFragmentManager.findFragmentById(R.id.main_nav_host) as NavHostFragment
navController = navHost.navController
createGraph(App.instance.getValue())
}
private fun createGraph(bool: Boolean) {
Toast.makeText(this, "Is session active: $bool", Toast.LENGTH_SHORT).show()
log("one: ${R.id.fragment_one}, two: ${R.id.fragment_two}")
val graph =
if (bool) {
log("fragment one")
navController.navInflater.inflate(R.navigation.nav_graph).also {
it.startDestination = R.id.fragment_one
}
} else {
log("fragment two")
navController.navInflater.inflate(R.navigation.nav_graph).also {
it.startDestination = R.id.fragment_two
}
}
navController.setGraph(graph, null)
}
}
App code:
class App : Application() {
companion object {
lateinit var instance: App
}
private var someValue = true
override fun onCreate() {
super.onCreate()
instance = this
}
fun getValue(): Boolean {
val result = someValue
someValue = !someValue
return result
}
}
Fragment One and Two are just empty fragments.
How it looks like:
Repository with full code and more explanation available by link
My question: is it a Navigation library bug or I am doing something wrong? Maybe I am using a bad approach and there is a better one to achieve what I want?
As you tried in your repository, It comes from save/restoreInstanceState.
It means you set suit graph in onCreate via createGraph(App.instance.getValue()) and then fragmentManager in onRestoreInstanceState will override your configuration for NavHostFragment.
So you can set another another time the graph in onRestoreInstanceState. But it will not work because of this line and backstack is not empty. (I think this behavior may be a bug...)
Because of you're using a graph (R.navigation.nav_graph) for different situation and just change their startDestination, you can be sure after process death, used graph is your demand graph. So just override startDestination in onRestoreInstanceState.
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
if (codition) {
navController.graph.startDestination = R.id.fragment_one
} else {
navController.graph.startDestination = R.id.fragment_two
}
}
Looks like there is some wrong behaviour in the library and my approach wasn't 100% correct too. At least, there is the better one and it works well.
Because I am using the same graph and only changing the start destination, I can simply set that graph in onCreate of my activity and set some default start destination there. Then, in createGraph method, I can do the following:
// pop backStack while it is not empty
while (navController.currentBackStackEntry != null) {
navController.popBackStack()
}
// then just navigate to desired destination with additional arguments if needed
navController.navigate(destinationId, destinationBundle)

Android navigation component: How to get the current navigation graph's id?

I am using nested navigation graphs in order to scope and share my viewmodels across a set of fragments.
I also have a BaseFragment class which obtains the reference to the required viewmodel:
fun provideViewModel() : VM {
return if(viewModelScopeGraphId != null) {
ViewModelProvider(findNavController().getViewModelStoreOwner(viewModelScopeGraphId!!)).get(viewModelClass)
} else {
ViewModelProvider(this).get(viewModelClass)
}
}
I can override a property, viewModelScopeGraphId, if I need the viewmodel to be scoped to the navigation graph (or nested navigation graph) with that specific id.
Ideally I would just want to set a boolean flag like useScopedViewModel and obtain the id of the current navigation graph, for example:
fun provideViewModel() : VM {
return if(useScopedViewModel) {
ViewModelProvider(findNavController().getViewModelStoreOwner(getCurrentNavGraphId())).get(viewModelClass)
} else {
ViewModelProvider(this).get(viewModelClass)
}
}
I have tried using navController.graph.id to get the current graph id, but it seems the id I get from there does not match up with my resource id's (eg. R.id.nav_graph). Is there something I am missing?
I have a similar issue (more context of my case at the end), tried a few solutions, none successful.
In the end, I give up and just give the graph id as a parameter for the Fragment.
As it could help you to achieve your own solution, so my solution looks like the following:
Have to create this extension, as the original navGraphViewModels only accepts a #IdRes navGraphId: Int and I want to lazy load the arguments, etc. (Also I've simplified removing the factoryProducer from the arguments, as so far I will not use it for our solution.
inline fun <reified VM : ViewModel> Fragment.navGraphViewModels(
noinline graphIdProducer: () -> Int
): Lazy<VM> {
val backStackEntry by lazy {
findNavController().getBackStackEntry(graphIdProducer())
}
val storeProducer: () -> ViewModelStore = {
backStackEntry.viewModelStore
}
return createViewModelLazy(VM::class, storeProducer, {
backStackEntry.defaultViewModelProviderFactory
})
}
In my fragment where I want to use it, I recover the Arguments using the navArgs, and recover the ViewModel using the extension above:
private val listenerViewModel: ListenerViewModel by navGraphViewModels {
navArgs.graphId
}
private val navArgs: MyFragmentArgs by navArgs()
And to whoever needs to "listen" for that ViewModel, can simply load using the Navigation navGraphViewModels:
private val listenerViewModel: ListenerViewModel by navGraphViewModels(
R.id.my_graph_a
)
And from another graph/fragment I simple do:
private val listenerViewModel: ListenerViewModel by navGraphViewModels(
R.id.my_graph_b
)
To explain a little the context of my case:
I have two distinct Fragments, where the user can click in a Country selection.
Each of these Fragments is inside a different Graph, as they are different flows.
The Country selection is a Fragment where load the supported countries list from an API, displays it, pre-select any previous user selection (also given as a FragmentArgs), and the user can change the selection, which implies coming back to the previous screen, with the newly selected value or just come back not triggering anything here.
I'm aware and expecting the release of https://issuetracker.google.com/issues/79672220, but as it is today (March-2020) it is only available in alpha.
You may try to check it with graph's start destination.
when (navController.graph.startDestinationId) {
R.id.firstFragmentOfFirstGraph -> { /* First graph */ }
R.id.firstFragmentOfSecondGraph -> { /* Second graph */}
}

Only allow one instance when navigate with NavController

I'm currently using Android Navigation Architecture in my project. It has a feature that can launch any fragment with a shortcut. Currently I'm using NavController to navigate to desired destination when clicking at a shortcut.
But when I clicked a shortcuts with multiple times, every time a new instance of the fragment will be created.
So, my question is, Is there any way to only accept one instance of a fragment when navigate to it with NavController?
I'm googling many times but found nothing. Thanks in advance.
Add a check before navigating to the destination as it would not add a new instance.
class A: AppCompatActivity {
fun onCreate(...) {
// ...While navigating
if (navController.currentDestination?.id != desiredDestination?.id) {
navController.navigate(desiredDestination)
}
// ...else do nothing
}
}
Callback from NavController: https://developer.android.com/reference/androidx/navigation/NavController#addOnDestinationChangedListener(androidx.navigation.NavController.OnDestinationChangedListener)
You can use by navGraphViewModels delegate
The most important thing is to set id to your views in order to save state during config changes.This has not mentioned in official docs.
by default fragment navigation won't be saved during config changes(rotation and ...).
ViewModel will remain across config changes and you can save your state there then restore it.
Also check these links:
https://code.luasoftware.com/tutorials/android/android-jetpack-navigation-lost-state-after-navigation/
and
Android navigation component: how save fragment state
You can use safeOnClickListener instead of default onClickListener for capturing click on shortcut, so basically with safeOnClickListener you ignore all the click event for a given duration.
class SafeClickListener(
private var defaultInterval: Int = 2000,
private val onSafeCLick: (View) -> Unit
) : View.OnClickListener {
private var lastTimeClicked: Long = 0
override fun onClick(v: View) {
if (SystemClock.elapsedRealtime() - lastTimeClicked < defaultInterval) {
return
}
lastTimeClicked = SystemClock.elapsedRealtime()
onSafeCLick(v)
}
}
fun View.setSafeOnClickListener(delay: Int = 2000, onSafeClick: (View) -> Unit) {
val safeClickListener = SafeClickListener(delay) {
onSafeClick(it)
}
setOnClickListener(safeClickListener)
}

SearchView with fragments

I have a single Activity app where I put a SearchView on the topbar. I am struggling since there are like thousands of manuals/tutorials on implementing search online, but all of them seem to be outdated somehow. Even the official documentation does not make it clear for me.
For one reason or the other, I have to use a single Activity in my app, and handle the whole interaction with Fragment.
I am struggling on how to make the SearchView behave like I want to: I want the menu item to show the search bar at the top when i click it, then offer history and suggestions, then whenever something is searched, open a different Fragment with the results (actually, a TabLayout with three different result types) -- this is the way Youtube does I think.
I got to the point where searching for something brings another Fragment to the screen, but then I want that when the user clicks on the back arrow, the user is brought back to the previous Fragment (without it having to reload again the info -- there is endless scrolling so the user might have loaded tons of data): Whenever I click, first the action view for search disappears, then if I hit back again, the previous Fragment shows up but it's reloading its content.
this is my setupSearch() method:
private fun setupSearch(menu: Menu) {
// Get the SearchView and set the searchable configuration
val searchManager = getSystemService(Context.SEARCH_SERVICE) as SearchManager
val searchItem = menu.findItem(R.id.app_search)
val searchView = searchItem.actionView as SearchView
// Assumes current activity is the searchable activity
searchView.setSearchableInfo(searchManager.getSearchableInfo(componentName))
var searchEditTextId = R.id.search_src_text;
var searchEditText = searchView.findViewById<AutoCompleteTextView>(searchEditTextId)
var dropDownAnchor = searchView.findViewById<View>(searchEditText.dropDownAnchor)
if (dropDownAnchor != null) {
dropDownAnchor.addOnLayoutChangeListener { p0, p1, p2, p3, p4, p5, p6, p7, p8 ->
// screen width
var screenWidthPixel = this#DashboardListActivity.resources.displayMetrics.widthPixels
searchEditText.dropDownWidth = screenWidthPixel
}
}
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String): Boolean {
var searchSuggestion = SearchRecentSuggestions(this#DashboardListActivity, SearchHistoryProvider.AUTHORITY, SearchHistoryProvider.MODE)
searchSuggestion.saveRecentQuery(query, null)
supportFragmentManager
.beginTransaction()
.replace(R.id.fragment_container, SearchResultsContainerFragment.newInstance())
.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
.addToBackStack("SEARCH")
.commit()
return false
}
override fun onQueryTextChange(newText: String): Boolean {
return false
}
})
searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener{
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
return true
}
})
searchView.setOnSuggestionListener(object: SearchView.OnSuggestionListener {
override fun onSuggestionSelect(position: Int): Boolean {
return false
}
override fun onSuggestionClick(position: Int): Boolean {
return false
}
})
}
Also, when displaying the suggestion list, it seems the Activity is somehow paused and resumed (I guess because it is implemented as a dialog?). This would not be a problem but I am keeping a video window always on top of all views (hence my need to do everything with Fragment) and the video stops for a split sec then continues. Is it possible to prevent that?
Search seemed like an easy task but its becoming kind of a nightmare. The official documentation says that i must create a different Activity that is "Searchable" but I cannot do that.
If you do not want to reload the fragment(that contains the search) when you go back you have to change the replace fragment with add. That is because replace does a remove (of any fragment) followed by an add(the new fragment).
You can use any of these library as per your requirements :
https://github.com/MiguelCatalan/MaterialSearchView
https://github.com/arimorty/floatingsearchview
https://github.com/lapism/SearchView

Categories

Resources