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

11

24

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()
        }
    }
Toth answered 10/1, 2019 at 9:55 Comment(8)
why dont you launch an activity on recyclerview item click ?Lifer
Because I design a single activity applicationToth
@Toth can you post some of the code? I didn't encountered this kind of issue with navigation component, but on the other hand i didn't used paging library.Petulancy
@Alex: I just pushed some codes, paging library is the point. Because if I use normal recyclerview and normal adapter, no problem.Toth
do you solved this problem @Toth ?Coadunate
@dariushf you can give a try with my answer.Inefficiency
It's still an issue from 1.5 years ago. LOL Follow on github to see this thread still going on: github.com/android/architecture-components-samples/issues/…Toth
Until now, for simply control states of fragments, I choose navigation framework but for complex lifecycle's application, I still choose the old school: FragmentTransaction. Navigation is a failure in my opinionToth
T
2

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.
Treacherous answered 19/8, 2021 at 9:24 Comment(2)
even though my problem was gone away for years but I think I will give you a check mark. Because I know It's correct without my testingToth
The best answer, thank you!Slipper
A
4

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.

Adduct answered 10/1, 2019 at 16:5 Comment(3)
your answer leads me to the point: singersAdapter.submitList(it) will refresh the recyclerview. End up, I moved the method getSingersPagination() to onAttach, only observe data in onViewCreated. So, singersAdapter.submitList(it) only once. Finally, scrolled position and data are saved as I wanted. But Items are still inflated, not like FragmentTransaction.hide()/show()Toth
I tried your solution. It works. setting retainInstance = true and moving adapter to a field is the solution for me.Edsel
retainInstance = true as suggested by Sourav is now deprecated but moving my observer to onAttach worked for me.Sedimentation
F
3

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));
        }
    }
Ferrite answered 10/1, 2019 at 10:16 Comment(2)
I tried it. It's not simple like that because of using PagedListAdapter and LiveDataToth
oh yeah, I forgot the paged listFerrite
A
2

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.

Adduct answered 10/1, 2019 at 14:6 Comment(3)
For easy reading, I created a new fragment then copy some codes into :DToth
I know what you are talking to, but the problem is we are working with NavController ,not FragmentTransaction like traditional wayToth
I looked up in the source code of Navigation, and found here that NavController ultimately calls replace when navigating. I don't see an easy solution right now but maybe I will think about it later because it's interesting. :)Adduct
W
2

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...)

Welch answered 3/10, 2019 at 11:13 Comment(2)
Thank you @LạngHoàng . I Exactly did this and it's workCoadunate
I'm glad it helps!Inefficiency
T
2

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.
Treacherous answered 19/8, 2021 at 9:24 Comment(2)
even though my problem was gone away for years but I think I will give you a check mark. Because I know It's correct without my testingToth
The best answer, thank you!Slipper
J
1

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

Jaxartes answered 4/7, 2019 at 8:37 Comment(0)
J
1

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.

Jonellejones answered 8/6, 2020 at 10:11 Comment(2)
It's still an issue from 1.5 years ago. LOL Follow on github to see this thread still going on: github.com/android/architecture-components-samples/issues/…Toth
I tried this way..but after navigating back to the screen..I cannot run adapter.refresh()Viki
L
1

This so simple, only use this method cachedIn()

fun getAllData() {
    viewModelScope.launch {
        _response.value = repository.getPagingData().cachedIn(viewModelScope)
    }
}
Luellaluelle answered 6/11, 2022 at 19:0 Comment(0)
D
0

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.

Derwin answered 8/10, 2019 at 23:27 Comment(0)
A
0

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.

Anselmi answered 9/4, 2021 at 13:27 Comment(0)
V
0

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

Viki answered 9/3, 2022 at 10:58 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.