Android Jetpack Navigation Fragment Lost State After Navigation

December 18, 2019
RecyclerView save and restore last position

The Problem

An obvious limitation of current Jetpack Navigation

dependencies {
  def nav_version = "2.2.0-rc03"

  implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
  implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
}

is the lost of state after navigate away (findNavController().navigate) or using BottomNavigationView With Jetpack Navigation UI.

For example, you might scroll midway using on RecylerView on a fragment, then you navigate to another fragment and come back again, the RecyclerView will be back to the top again.

The main reason for this problem is that Navigation does not support multiple back stacks within a single FragmentManager. Besides, Navigation replace the fragment thus causing the state lost.

https://issuetracker.google.com/issues/80029773#comment25

One very conscious decision we made for Navigation 1.0.0 was to depend on the 28.0.0 Support Library. This means that, out of the box, we are limited to the APIs and behavior contained within. Fragments, the default NavHost+Navigator implementation provided out of the box, does not support multiple back stacks within a single FragmentManager. Even more unfortunately, Fragments do not offer an API for a system built on top of it (such as Navigation) to access the saved instance state and non configuration instance state (i.e., ViewModels) needed to write our own multiple back stack implementation while still using a single FragmentManager.

With that in mind, we’ll be doing two things:

  1. Short term: provide a public sample that shows our recommended implementation of multiple back stacks with Navigation using the current APIs (i.e., a separate NavHostFragment and navigation graph for each bottom navigation item). This is a top priority for us right now, only superseded by fixing issues blocking the 1.0 release.
  1. Medium term: build the correct API on Fragments so that they can properly support multiple simultaneous back stacks, including properly saving and restoring saved instance state and non configuration instance state for all of the Fragments on all of the back stacks. This work is currently in an exploratory stage and, while I’m hopeful, I cannot offer a timeline or promise that this work will succeed.

This problem is expected to be solved with Fragment 1.3 (currently 1.2.0-rc03 as of Dec 2019) and Navigation 2.3 (currently 2.2.0-rc03 as of Dec 2019), so the solution could take quite a while.

https://issuetracker.google.com/issues/80029773#comment65

If you look in the upper left, you’ll see ‘Blocked by’ with a link to https://issuetracker.google.com/issues/139536619 which is under active development.

As per the public hotlists, this is expected as part of Fragment 1.3 and Navigation 2.3. That’s what we’re continuing to work towards.

Google provided a sample hack fix: https://github.com/android/architecture-components-samples/tree/master/NavigationAdvancedSample

This sample uses multiple NavHostFragments, one for each bottom navigation tab, to work around the current limitations of the Fragment API in supporting multiple back stacks.

We’ll be proceeding with the Fragment API to support multiple back stacks and the Navigation API to plug into it once created, which will remove the need for anything like the NavigationExtensions.kt file. We’ll continue to use this issue to track that work.

Observe Fragment lifecycle of Navigation

When the Fragment is shown (everytime).

onCreateView
onViewCreated
onActivityCreated

When the Fragment navigate away

onDestroyView

NOTE: onDestroy is only called the next time the fragment is shown.

The reason RecyclerView doesn’t maintain the last scroll state is because the fragment is actually created and destroyed each time it is shown.

Maintain State via ViewModel

If you use ViewModel via the standard way, you will notice the ViewModel is destroyed (ViewModel.onCleared is called) everytime the fragment is shown and navigate away.

class HomeFragment : Fragment() {
    private val viewModel by viewModels<HomeViewModel>()
}

To maintain ViewModel across navigation, you need to use the following way

class HomeFragment : Fragment() {
    // R.id.mobile_navigation is the navigation graph
    private val viewModel by navGraphViewModels<HomeViewModel>(R.id.mobile_navigation)
}

Now we need to save the RecyclerView state.

Fragment.onSaveInstanceState and Fragment.onViewStateRestored is never called across navigation, so we can’t rely on them to save state. I use onDestroyView to save state, and restore state after RecyclerView adapter is loaded.

Edit ViewModel

class HomeViewModel : ViewModel() {
    var listState: Parcelable? = null
}

Edit Fragment

class HomeFragment : Fragment() {
    private val viewModel by navGraphViewModels<HomeViewModel>(R.id.mobile_navigation)

    override fun onActivityCreated(savedInstanceState: Bundle?) {
        super.onActivityCreated(savedInstanceState)

        // TODO: list.adapter = adapter
        // TODO: load adapter

        if (viewModel.listState != null) {
            list.layoutManager?.onRestoreInstanceState(viewModel.listState)
            viewModel.listState = null
        }
    }

    override fun onDestroyView() {
        super.onDestroyView()
        viewModel.listState = list.layoutManager?.onSaveInstanceState()
    }
}

Use SavedStateHandle?

You could use SavedStateHandle, though I am not sure would it provide additional benefit on top of the solution above, since onSaveInstanceState is not actually called.

ViewModel objects can handle configuration changes so you don’t need to worry about state in rotations or other cases. However, if you need to handle system-initiated process death, you may want to use onSaveInstanceState() as backup.

Edit ViewModel

class HomeViewModel(private val state: SavedStateHandle) : ViewModel() {
    companion object {
        private const val LIST_STATE = "list"
    }

    var listState: Parcelable?
        get() = state.get(LIST_STATE)
        set(value) {
            state.set(LIST_STATE, value)
        }
}

No changes is required at Fragment

class HomeFragment : Fragment() {
    private val viewModel by navGraphViewModels<HomeViewModel>(R.id.mobile_navigation)

    // private val viewModel by navGraphViewModels<HomeViewModel>(R.id.mobile_navigation) { SavedStateViewModelFactory(activity!!.application,this) }
}

NOTE: Initializing SavedStateViewModelFactory no longer necessary: https://developer.android.com/jetpack/androidx/releases/lifecycle#viewmodel-savedstate-1.0.0-alpha05

NOTE: SavedStateVMFactory is renamed to SavedStateViewModelFactory: https://developer.android.com/jetpack/androidx/releases/lifecycle#lifecycle-viewmodel-savedstate-1.0.0-alpha02

NOTE: implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:1.0.0-rc03" no longer necessary. I believe Fragment include the library.

Conclusion

The above solution is a duck-tape for RecyclerView, and it didn’t cater other state loss scenario like EditText, etc.

You might want to refer to Android Jetpack Navigation: Support Multiple Back Stacks for BottomNavigationView.

This work is licensed under a
Creative Commons Attribution-NonCommercial 4.0 International License.