I have an app using AndroidX's Navigation library, but I'm getting odd behavior. Particularly around my app going in/out of the background. Here are two examples:
In a simple on click listener in a Fragment I have:
(Kotlin)
button.setOnClickListener {
findNavController().popBackStack()
}
From this, I see crashes saying it threw an IllegalStateException since it ran after onSaveInstanceState.
I have a ViewModel associated with my Fragment and I register my observers to the fragment view's lifecycle. This means that I get notified during onStart. Some key events, such as login state determine the app's navigation. In my case I have a splash screen that could go to either a login screen or the main screen. Once a user completes login, I reset the navigation (taking me back to the splash screen). Now the auth state is ready and I want to navigate to the main fragment, this throws an error often because onResume must be called before the FragmentManager is considered ready. I get an error saying I'm in the middle of a transaction and I can't add a new one. To mediate this I had to write this strange bit of code:
(Kotlin)
private fun safeNavigateToMain() {
if (fragmentManager == null) {
return
}
if (!isResumed) {
view?.post { safeNavigateToMain() }
return
}
try {
findNavController().navigate(R.id.main)
} catch (tr: Throwable) {
view?.post { safeNavigateToMain() }
}
}
Does anyone know how I can get the navigation controller to play nice with the fragment lifecycles without having to add these workarounds?
As per the Navigation 1.0.0-alpha03 release notes:
FragmentNavigator now ignores navigation operations after FragmentManager has saved state, avoiding “Can not perform this action after onSaveInstanceState” exceptions b/110987825
So upgrading to alpha03 should remove this error.
Related
In my app, I have a PlaylistMenuFragment with a button that replaces itself in the ViewPager with PlaylistContentFragment, and vice-versa, through the following methods:
fun goToPlaylistContent() {
pagerAdapter.replaceLastFragmentWith(PlaylistContentFragment())
}
fun goToPlaylistMenu() {
pagerAdapter.replaceLastFragmentWith(PlaylistMenuFragment())
}
The adapter method that is being called:
fun replaceLastFragmentWith(newFragment: Fragment) {
fragmentList[LAST_FRAGMENT_INDEX] = newFragment
notifyItemChanged(LAST_FRAGMENT_INDEX)
}
If I click a button inside PlaylistMenuFragment that goes to PlaylistContentFragment, executing the methods above, everything works fine. But if then I click a button inside PlaylistContentFragment that goes back to PlaylistMenuFragment, the app crashes, throwing the following exception:
java.lang.IllegalStateException: Cannot call this method while RecyclerView is computing a layout or scrolling androidx.viewpager2.widget.ViewPager2$RecyclerViewImpl{8ebfd7a VFED..... ......ID 0,0-720,1040 #1}, adapter:com.pegoraro.musicast.main.PagerAdapter#a12492b, layout:androidx.viewpager2.widget.ViewPager2$LinearLayoutManagerImpl#e567188, context:com.pegoraro.musicast.main.MainActivity#e8e6424
The crash traces back to the methods above. If I wrap the notifyItemChanged inside the adapter method with a try-catch block, the app works as intended, with no sign of problem, unless I look in the logcat, which still shows the exception being thrown. It is a dirty fix, which I don't like:
fun replaceLastFragmentWith(newFragment: Fragment) {
fragmentList[LAST_FRAGMENT_INDEX] = newFragment
try {
notifyItemChanged(LAST_FRAGMENT_INDEX)
} catch (e: Exception) {
Log.d("TEST", e.toString())
}
}
I'm trying to implement a sort of navigation between these two Fragments inside a TabLayout, and this is the way I managed to do. So if the error comes from a fundamental problem of how I am approaching this issue, what is an alternative? And if not, what could be the cause of the problem? Thank you.
Calling any of notifyXX() methods on the ViewPager2 adapter is the same as that of the RecyclerView, because ViewPager2 internally functions based on RecyclerView.
And since notifyXX() methods work in background thread, and in your case this directly affects one of the ViewPager current fragments; so you need to explicitly do this in UI thread
viewpager.post {
notifyItemChanged(LAST_FRAGMENT_INDEX)
}
I am writing a single Activity app that uses Android's Navigation Components to help with navigation and Fragment Scenario for instrumentation testing. I have run into a performance discrepancy when using the back button between the actual app navigation behavior and the behavior of a Fragment being tested in isolation during an Instrumentation tests when using fragment scenario.
In my MainActivity I have a main NavHostFragment that takes up the entire screen. I use that nav host fragment to show several screens including some master detail fragments. Each master detail fragment has another NavHostFragment in it to show the different detail fragments for that feature. This setup works great and provides the behavior I desire.
To accomplish the master detail screen I use a ParentFragment that has two FrameLayouts to create the split screen for tablet and for handset I programatically hide one of the FrameLayouts. When the ParentFragment is created, it detects if it is being run on a tablet or handset and then programatically adds a NavHostFragment to the right frame layout on tablet, and on handset hides the right pane adds a NavHostFragment to the left pane. The NavHostFragments also have a different navigation graph set on them depending on if they are being run on tablet or handset (on handset we show fragments as dialogs, on tablet we show them as regular fragments).
private fun setupTabletView() {
viewDataBinding.framelayoutLeftPane.visibility = View.VISIBLE
if (navHostFragment == null) {
navHostFragment = NavHostFragment.create(R.navigation.transport_destinations_tablet)
navHostFragment?.let {
childFragmentManager.beginTransaction()
.add(R.id.framelayout_left_pane, it, TRANSPORT_NAV_HOST_TAG)
.setPrimaryNavigationFragment(it)
.commit()
}
}
if (childFragmentManager.findFragmentByTag(SummaryFragment.TAG) == null) {
childFragmentManager.beginTransaction()
.add(R.id.framelayout_right_pane, fragFactory.instantiate(ClassLoader.getSystemClassLoader(), SummaryFragment::class.java.canonicalName!!), SummaryFragment.TAG)
.commit()
}
}
private fun setupPhoneView() {
viewDataBinding.framelayoutLeftPane.visibility = View.GONE
if (navHostFragment == null) {
navHostFragment = NavHostFragment.create(R.navigation.transport_destinations_phone)
navHostFragment?.let {
childFragmentManager.beginTransaction()
.replace(R.id.framelayout_left_pane, it, TRANSPORT_NAV_HOST_TAG)
.setPrimaryNavigationFragment(it)
.commit()
}
}
}
When running the devDebug version of the app, everything works as expected. I am able to navigate using the main NavHostFragment to different master-detail screens. After I navigate to the master-detail screen, the nested NavHostFragment takes over and I can navigate screens in and out of the master detail fragment using the nested NavHostFragment.
When the user attempts to click the back button, which would cause the to leave the master detail screen and navigate to the previous screen, we pop up a dialog to the user asking if they really want to leave the screen (it's a screen where they enter a lot of data). To accomplish this we register an onBackPressDispatcher callback so we know when the back button was pressed and navigate to the dialog when the callback is invoked. In the devDebug version, the user begins by being at location A on the nav graph. If, when they are at location A, they click the back button, then we show a dialog fragment asking if the user really intends to leave the screen. If, instead, the user navigates from location A to location B and clicks back they are first navigated back to location A. If they click the back button again, the back press dispatcher callback is invoked and they are then shown the dialog fragment asking if they really intent to leave location A. So it seems that that the back button affects the back stack of the nested NavHostFragment until the nested NavHostFragment only has one fragment left. When only one fragment is left and the back button is clicked, the onBackPressDisapatcher callback is invoked. This is exactly the desired behavior. However, when I write an Instrumentation test with Fragment Scenario where I attempt to test the ParentFragment I have found that the back press behavior is different. In the test I use Fragment Scenario to launch ParentFragment, I then run a test where I do a navigation in the nested NavHostFragment. When I click the back button I expect that the nested nav host fragment will pop its stack. However, the onBackPressDispatcher callback is invoked immediately instead of after the nested nav host fragment has one fragment left on its stack.
I set some breakpoints in the NavHostFragment and it seems that when the tests are run, the NavHostFragment is not setup to intercept back clicks. Its enableOnBackPressed() method is always called with a flag set to false.
I don't understand what about the test setup is causing this behavior. I would think that the nav host fragment would intercept the back clicks itself until it only had one fragment left on its backstack and only then would the onBackPressDispatcher callback be invoked.
Am I misunderstanding how I should be testing this? Why does the onBackPressDispatcher's callback get called when the back button is pressed.
As seen in the FragmentScenario source code, it does not currently (as of Fragment 1.2.1) use setPrimaryNavigationFragment(). This means that the Fragment being tested does not intercept the back button and hence, its child fragments (such as your NavHostFragment) do not intercept the back button.
You can set this flag yourself in your test:
#Test
fun testParentFragment() {
// Use the reified Kotlin extension to launchFragmentInContainer
with(launchFragmentInContainer<ParentFragment>()) {
onFragment { fragment ->
// Use the fragment-ktx commitNow Kotlin extension
fragment.parentFragmentManager.commitNow {
setPrimaryNavigationFragment(fragment)
}
}
// Now you can proceed with your test
}
Issue:
While working with Navigation Library, I observed when I navigate back to the previous fragment, it recreates the fragment and thus re-registering all my Observers which triggers OnChanged() again
I have a Snackbar which shows some error messages example if I am looking for no more data present or no Internet connection to the server:
deliveriesListViewModel.isMoreDataPresent.observe(this, Observer {
if (!it) showSnackBar(getString(R.string.no_more_data))
})
Source of above code here
And on navigating back and forth, the SnackBar pops up every time, and also every time I change the orientation or rotate my device.
My architecture has a single Activity with startDestination as my ListFragment in the navigation graph and a DetailFragment as destination. SupportNavigationUp or a simple OnBackPressed on DetailFragment returns me to my ListFragment and then recreates the fragment and thus re-registering all my Observers which triggers OnChanged() again and the SnackBar pops up when noMoreDataPresent LiveData is false
Now I tried the solution from here but unfortunately, it doesn't work
I have also tried to switch my LifecycleOwner to my activity by that also doesn't work.
Tried moving the ViewModelProviders.of to OnCreate and onActivityCreated - doesn't work
Please suggest corrections or any ideas what can be done to prevent SnackBar popping up after navigation and orientation change.
Footnotes
I have gone through these issues:
Multiple LiveData Observers After Popping Fragment
How to avoid fragment recreation when tap back button using navigation architecture actions?
Is there a way to keep fragment alive when using BottomNavigationView with new NavController?
here is my complete source code
This article, especially item 1, might be relevant to what you're experiencing. Basically, what happens is that you may have multiple spawned Observers every time you navigate back to your fragment, thus executing onChanged multiple times. Using the fragment's view lifecycle as the LifecycleOwner should prevent this from happening, so your code above would look like this:
deliveriesListViewModel.isMoreDataPresent.observe(viewLifecycleOwner, Observer {
if (!it) showSnackBar(getString(R.string.no_more_data))
})
I have a single Activity application with multiple Fragments that are being switched by using Navigation components. When I switch between two fragments their onCreate() and onDestroy() methods seem to overlap. Thus making it difficult for me to write initialization and clean up code for fragments when they access the same global objects.
Navigating from Framgent_A to Fragment_B has the following order of methods:
Fragment_B.onCreate()
Fragment_A.onDestroy()
In Fragment_A.onDestroy() I reverse the operations I do in Fragment_A.onCreate(). And in Fragment_B I expect things to be in a neutral state when onCreate() is called. However that is not the case since Fragment_A.onDestroy() has not yet been called.
Is the overlap normal on Android or did I configure something wrong in my Navigation components? Is there another way I could achieve what I am trying to do? I know I could couple both Fragments and make it work, but I don't want either Fragment to know about each other. To me it seems weird that Framgnet_A is still alive when Fragment_B is created, when Fragment_B is supposed to replace Fragment_A.
Any help is greatly appreciated!
Edit:
After groing through the source code while debugging I have found out that in FragmentNavigator.navigate() FragmentTransaction.setReorderingAllowed() is called, which allows reordering of operations, even allowing onCreate() of a new fragment to be called before onDestroy() of the previous. The question still remains, how can I solve my problem of correctly cleaning up global state in one Fragment before initializing the same global state in the next Fragment.
The Android Fragment life-cycle is not really an appropriate callback host for your needs. The navigation controller will replace the two fragments with animation, so both are somehow visible the same time and eventually even onPause() of the exiting fragment is called after onResume() of the entering one.
Solution 1: Use OnDestinationChangedListener
The onDestinationChanged() callback is called before any of the life-cycle events. As a very simplified approach (look out for leaks) you could do the following:
findNavController().addOnDestinationChangedListener { _, destination, _ ->
if(shouldCleanupFor(destination)) cleanup()
}
Solution 2: Abstract the global changes away
Instead of having single navigation points change the global state, have a single point of truth for it. This could be another fragment independent of the navigation hierarchy. This then observes the navigation as before:
findNavController(R.id.nav_graph).addOnDestinationChangedListener { _, destination, _ ->
resetAll()
when(distination.id) {
R.id.fragment_a -> prepareForA()
R.id.fragment_b -> prepareForB()
else -> prepareDefault()
}
}
As an additional advantage you could implement the state changes idempotently as well.
Since you have an activity that controls the inflation of your Fragments you can manually control the lifecycles of the fragment that are being inflated. By calling into below methods you can control which fragment is ready to use global data. You will at this point have to, some how pass data back to Mainactivity to establish which fragment is active since your asking about how to inflate 2 fragment simultaneously which will share an object. Better approach would be to have the MainActivity implement FragmentA and FragmentB-detail with specific classes to do Stuff this way you have to treat your app like Tablet and determine 2 pane mode and which point you can use appropriate classes out of those fragments controlled by your Activity. The included link matches what your trying to accomplish
private void addCenterFragments(Fragment fragment) {
try {
removeActiveCenterFragments();
fragmentTransaction = fragmentManager.beginTransaction();
fragmentTransaction.add(R.id.content_fragment, fragment);
fragmentTransaction.setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN);
activeCenterFragments.add(fragment);
fragmentTransaction.commit();
}catch (Exception e){
Crashlytics.logException(e);
}
}
private void removeActiveCenterFragments() {
if (activeCenterFragments.size() > 0) {
fragmentTransaction = fragmentManager.beginTransaction();
for (Fragment activeFragment : activeCenterFragments) {
fragmentTransaction.remove(activeFragment);
}
activeCenterFragments.clear();
fragmentTransaction.commit();
}
}
Perhaps you could move some the code related to initialization where you assume a neutral state to that fragments onStart() or onCreateView() method. According to the developer documentation this is where initialization should take place.
Another option available is using an Observer /Observable pattern, where you could notify your Activity once onDestroy() in Fragment A is completed. The Activity would then notify Fragment B that it is safe to assume a cleaned up state and begin initialization.
My case was a little bit different, and I would like to share it in case anyone faced the same issue.
I wanted to do an action in onPause() of the current fragment, but not execute that code when one navigates from a fragment to another. What I had to do was to call isRemoving() method to check if the current fragment is being removed or not. It is set to true when NavController.navigate(...) method is called.
override fun onPause() {
super.onPause()
if (!isRemoving()) {
// Write your code here
}
}
Per Google's Fragment.isRemoving() documentation:
Return true if this fragment is currently being removed from its activity. This is not whether its activity is finishing, but rather whether it is in the process of being removed from its activity.
I am implementing Android Architecture Components. Imagine a case like the following where your Fragment is observing a LiveData to change its UI. User minimizes the app and the state is changed (in my case from the repository). So the Observer from the Fragment is not triggered with a change because the Fragment is not visible. But then, when the user comes back to the app it doesn't trigger the new state. If the state is changed again (while the Fragment is visible), the Observer receives the change. Do you know any way to force an update when fragment is visible again?
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
vm = ViewModelProviders.of(this, viewModelFactory).get(MyViewModel::class.java)
vm.getStatus()?.observe(this, Observer<MyRepository.Status> { status ->
if (status != null) {
when (status) {
NONE -> setNoneUI()
LOADING -> setLoadingUI()
CONTENT -> setContentUI()
ERROR -> setErrorUI()
}
}
})
}
Actually your activity should receive the latest state from LiveData when it is visible again, as explained in this video and described here:
Always up to date data: If a lifecycle becomes inactive, it receives the latest data upon becoming active again. For example, an activity that was in the
background receives the latest data right after it returns to the
foreground.
I just tested it in my own application, works as described. So there must be another error in your application: Do you set the LiveData correctly from your repository class? Maybe a different thread / postValue problem?