Slim_user71169
Slim_user71169

Reputation: 866

Keeping states of recyclerview in fragment with paging library and navigation architecture component

I'm using 2 components of the jetpack: Paging library and Navigation.

In my case, I have 2 fragment: ListMoviesFragment & MovieDetailFragment

when I scroll a certain distance and click a movie item of the recyclerview, MovieDetailFragment is attached and the ListMoviesFragment is in the backstack. Then I press back button, the ListMoviesFragment is bring back from the backstack.

The point is scrolled position and items of the ListMoviesFrament are reset exactly like first time attach to its activity. so, how to keep states of recyclerview to prevent that?

In another way, how to keep states of whole fragment like hide/show a fragment with FragmentTransaction in traditional way but for modern way(navigation)

My sample codes:

fragment layout:

<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
         xmlns:tools="http://schemas.android.com/tools"
         android:layout_width="match_parent"
         android:layout_height="match_parent"
         tools:context="net.karaokestar.app.SplashFragment">

<TextView
        android:id="@+id/singer_label"
        android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:text="Ca sĩ"
        android:textColor="@android:color/white"
        android:layout_alignParentLeft="true"
        android:layout_toLeftOf="@+id/btn_game_more"
        android:layout_centerVertical="true"
        android:background="@drawable/shape_label"
        android:layout_marginTop="10dp"
        android:layout_marginBottom="@dimen/header_margin_bottom_list"
        android:textStyle="bold"
        android:padding="@dimen/header_padding_size"
        android:textAllCaps="true"/>


<androidx.recyclerview.widget.RecyclerView
        android:id="@+id/list_singers"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"/>

Fragment kotlin code:

    package net.karaokestar.app
    import android.os.Bundle
    import android.view.LayoutInflater
    import android.view.View
    import android.view.ViewGroup
    import androidx.fragment.app.Fragment
    import androidx.lifecycle.LiveData
    import androidx.lifecycle.Observer
    import androidx.navigation.fragment.findNavController
    import androidx.paging.LivePagedListBuilder
    import androidx.paging.PagedList
    import androidx.recyclerview.widget.LinearLayoutManager
    import kotlinx.android.synthetic.main.fragment_splash.*
    import net.karaokestar.app.home.HomeSingersAdapter
    import net.karaokestar.app.home.HomeSingersRepository

    class SplashFragment : Fragment() {

        override fun onCreateView(
            inflater: LayoutInflater, container: ViewGroup?,
            savedInstanceState: Bundle?
        ): View? {
            // Inflate the layout for this fragment
            return inflater.inflate(R.layout.fragment_splash, container, false)
        }

        override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
            super.onViewCreated(view, savedInstanceState)
            val singersAdapter = HomeSingersAdapter()
            singersAdapter.setOnItemClickListener{
findNavController().navigate(SplashFragmentDirections.actionSplashFragmentToSingerFragment2())
}
            list_singers.layoutManager = LinearLayoutManager(context, LinearLayoutManager.HORIZONTAL, false)
            list_singers.setHasFixedSize(true)
            list_singers.adapter = singersAdapter

            getSingersPagination().observe(viewLifecycleOwner, Observer {
                singersAdapter.submitList(it)
            })
        }

        fun getSingersPagination() : LiveData<PagedList<Singer>> {
            val repository = HomeSingersRepository()
            val pagedListConfig = PagedList.Config.Builder().setEnablePlaceholders(true)
                .setPageSize(Configurations.SINGERS_PAGE_SIZE).setPrefetchDistance(Configurations.SINGERS_PAGE_SIZE).build()

            return LivePagedListBuilder(repository, pagedListConfig).build()
        }
    }

Upvotes: 25

Views: 11561

Answers (11)

Xeyal
Xeyal

Reputation: 72

This so simple, only use this method cachedIn()

fun getAllData() {
    viewModelScope.launch {
        _response.value = repository.getPagingData().cachedIn(viewModelScope)
    }
}

Upvotes: 1

Willey Hute
Willey Hute

Reputation: 1088

Simple steps...

  1. Give id to all the views which you need to maintain state in fragment back stack. Like Scrollview, root layout, Recyclerview...

  2. The properties which should hold values, initialize in onCreate() of the fragment. Like adapter, count...except view references.

  3. If you are setting observers use lifecycleowner as this@yourFragment instead of viewLifecycleOwner.

That's it. Fragment onCreate() is called only once so setting properties on onCreate() will be there in memory till the fragment onDestroy() is called(not onDestroyView).

All the view referencing code should be there after onCreateView() and before onDestroyView().

BTW If possible you can save the properties in ViewModel which will be there even when fragment configuration changes where all the instance variables will be destroyed.

hope this link will help: PagingDataAdapter.refresh() not working after fragment navigation

Upvotes: 0

artenson.art98
artenson.art98

Reputation: 1605

Try the following steps:

  1. Initialize your adapter in onCreate instead of onCreateView. Keep the initialization one time, and attach it in onCreateView or onViewCreated.
  2. Don't return a new instance of your pagedList from getSingersPagination() method everytime, instead store it in a companion object or ViewModel (preferred) and reuse it.

Check the following code to get a rough idea of what to do:

class SingersViewModel: ViewModel() {
   private var paginatedLiveData: MutableLiveData<YourType>? = null;

   fun getSingersPagination(): LiveData<YourType> {
      if(paginatedLiveData != null) 
         return paginatedLiveData;
      else {
         //create a new instance, store it in paginatedLiveData, then return
      }
   }
}

The causes of your problem are:

  1. You are attaching a new adapter each time, so it jumps to top.
  2. You are creating new paged list each time, so it jumps to the top, thinking the data is all new.

Upvotes: 2

Prat137
Prat137

Reputation: 339

Well... You can check if the view is initialized or not (for kotlin)

Initialize view variable

  private lateinit var _view: View

In onCreateView check if view is initialized or not

    if (!this::_view.isInitialized) {
        return inflater.inflate(R.layout.xyz, container, false)
    }
    return _view

And in onViewCreated just check for it

  if (!this::_view.isInitialized) {
        _view = view
       // ui related methods
    }

This solution is for onreplace()....but if data is not changing on back press you may use add() method of fragment.

Here oncreateview will be called but your data won't be reloaded.

Upvotes: 0

Samdan Tetteh
Samdan Tetteh

Reputation: 31

I had the same problem with my recycle view using the paging library. My list list would always reload and scroll to the top when up navigation button is clicked from my details fragment. Picking up from @Janos Breuer's point I moved the initialisation of my view model and initial list call(repository) to the fragment onCreate() method which is called only once in the fragment lifecycle.

onCreate() The system calls this method when creating the fragment. You should initialize essential components of the fragment that you want to retain when the fragment is paused or stopped, then resumed.

Upvotes: 1

Ignacio Garcia
Ignacio Garcia

Reputation: 1153

Sorry to be late. I had a similar problem and I figured out a solution (maybe not the best). In my case I adapted the orientation changes. What you need is to save and retrieve is the LayoutManager state AND the last key of the adapter.

In the activity / fragment that is showing your RecyclerView declare 2 variables:

private Parcelable layoutState;
private int lastKey;

In onSaveInstanceState:

@Override
protected void onSaveInstanceState(@NonNull Bundle outState) {
    super.onSaveInstanceState(outState);
    if(adapter != null) {
        lastKey = (Integer)adapter.getCurrentList().getLastKey();
        outState.putInt("lastKey",lastKey);
        outState.putParcelable("layoutState",dataBinding.customRecyclerView
        .getLayoutManager().onSaveInstanceState());
    }      
 }

In onCreate:

 @Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    //.... setContentView etc...

    if(savedInstanceState != null) {
        layoutState = savedInstanceState.getParcelable("layoutState");
        lastKey = savedInstanceState.getInt("lastKey",0);
    }

    dataBinding.customRecyclerView
   .addOnScrollListener(new RecyclerView.OnScrollListener() {
        @Override
        public void onScrollStateChanged(@NonNull RecyclerView   recyclerView, int newState) {
            super.onScrollStateChanged(recyclerView, newState);
        }

        @Override
        public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
            super.onScrolled(recyclerView, dx, dy);
            if(lastKey != 0) {
                dataBinding.customRecyclerView.scrollToPosition(lastKey);
                Log.d(LOG_TAG, "list restored; last list position is: " + 
                ((Integer)adapter.getCurrentList().getLastKey()));
                lastKey = 0;
                if(layoutState != null) {
                    dataBinding.customRecyclerView.getLayoutManager()
                    .onRestoreInstanceState(layoutState);
                    layoutState = null;
                }
            }
        }


    });
}

And that's it. Now your RecyclerView should restore properly.

Upvotes: 0

Lạng Ho&#224;ng
Lạng Ho&#224;ng

Reputation: 1800

Whenever the fragment back again, it get the new PagedList, this cause the adapter present new data thus you will see the recycler view move to top.

By moving the creation of PagedList to viewModel and check to return the exist PagedList instead of create new one will solve the problem. Of course, depends on your app business create new PagedList might require but you can control it completely. (ex: when pull to refresh, when user input data to search...)

Upvotes: 2

Vaios
Vaios

Reputation: 567

Things to keep in mind when dealing with these issues:

  1. In order to let OS handle the state restoration of a view automatically, you must provide an id. I believe that this is not the case (because you must identify the RecyclerView in order to bind data).

  2. You must use the correct FragmentManager. I had the same issue with a nested fragment and all that i had to do was to use ChildFragmentManager.

  3. The SaveInstanceState() is triggered by the activity. As long as activity is alive, it won't be called.

  4. You can use ViewModels to keep state and restore it in onViewCreated()

Lastly, navigation controller creates a new instance of a fragment every time we navigate to a destination. Obviously, this does not work well with keeping persistent state. Until there is an official fix you can check the following workaround which supports attaching/detaching as well as different backstacks per tab. Even if you do not use BottomNavigationView, you can use it to implement an attaching/detaching mechanism.

https://github.com/googlesamples/android-architecture-components/blob/27c4045aa0e40d402bbbde16d7ae0c9822a34447/NavigationAdvancedSample/app/src/main/java/com/example/android/navigationadvancedsample/NavigationExtensions.kt

Upvotes: 1

Janos Breuer
Janos Breuer

Reputation: 480

Since you use NavController, you cannot keep the view of the list fragment when navigating.

What you could do instead is to keep the data of the RecyclerView, and use that data when the view is recreated after back navigation.

The problem is that your adapter and the singersPagination is created anew every time the view of the fragment is created. Instead,

  1. Move singersAdapter to a field:

    private val singersAdapter = HomeSingersAdapter()
    
  2. Move this part to onAttach

    getSingersPagination().observe(viewLifecycleOwner, Observer {
        singersAdapter.submitList(it)
    })
    
  3. Call retainInstance(true) in onAttach. This way even configuration changes won't reset the state.

Upvotes: 4

Janos Breuer
Janos Breuer

Reputation: 480

The sample fragment code you posted does not correspond to the problem description, I guess it's just an illustration of what you do in your app.

In the sample code, the actual navigation (the fragment transaction) is hidden behind this line:

findNavController().navigate(SplashFragmentDirections.actionSplashFragmentToSingerFragment2())

The key is how the details fragment is attached.

Based on your description, your details fragment is probably attached with FragmentTransaction.replace(int containerViewId, Fragment fragment). What this actually does is first remove the current list fragment and then add the detail fragment to the container. In this case, the state of the list fragment is not kept. When you press the back button, the onViewCreated of the list fragment will run again.

To keep the state of your list fragment, you should use FragmentTransaction.add(int containerViewId, Fragment fragment) instead of replace. This way, the list fragment remains where it is and it gets "covered" by the detail fragment. When you press the back button, the onViewCreated will not be called, since the view of the fragment did not get destroyed.

Upvotes: 2

vokod
vokod

Reputation: 173

On fragment's onSaveinstanceState save the layout info of the recyclerview:

    @Override
    public void onSaveInstanceState(@NonNull Bundle outState) {
        super.onSaveInstanceState(outState);
        outState.putParcelable(KEY_LAYOUT, myRecyclerView.getLayoutManager().onSaveInstanceState());
    }

and on onActivityCreated, restore the scroll position:

    @Override
    public void onActivityCreated(@Nullable Bundle savedInstanceState) {
        super.onActivityCreated(savedInstanceState);
        if (savedInstanceState != null) {
           myRecyclerView.getLayoutManager().onRestoreInstanceState(
                    savedInstanceState.getParcelable(KEY_LAYOUT));
        }
    }

Upvotes: 3

Related Questions