How to retain RecyclerView's position after Orientation change, while using Firebase & ChildEventListener?
Asked Answered
P

6

27

I'm working on a simple APOD app that implements:

  • RecyclerView
  • CardView
  • Firebase
  • Picasso

The app grabs images and text from Firebase and Firebase Storage, displays them in a CardView, and sets an OnClickListener to each View. When the user clicks on an image, I open a new Activity through an Intent. The second Activity displays the original clicked image, and more info about it.

I've implemented this all using a GridLayoutManager, 1 column if the user's phone is VERTICAL, 3 columns if the user's phone is HORIZONTAL.

The problem I'm having is I can't seem to save the RecyclerView's position on orientation change. I've tried every single option that I could find, but none seem to work. The only conclusion I could come up with, is that on rotation, I'm destroying Firebase's ChildEventListener to avoid a memory leak, and once orientation is complete, Firebase re-queries the database because of the new instance of ChildEventListener.

Is there any way I can save my RecyclerView's position on orientation change? I do not want android:configChanges as it won't let me change my layout, and I've already tried saving as a Parcelable, which was unsuccessful. I'm sure it's something easy I'm missing, but hey, I'm new to developing. Any help or suggestions on my code is greatly appreciated. Thanks!

Below are my classes, which I have shortened only to the necessary code.

MainActivity

public class MainActivity extends AppCompatActivity {

private RecyclerAdapter mRecyclerAdapter;
private DatabaseReference mDatabaseReference;
private RecyclerView mRecyclerView;
private Query query;


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

    PreferenceManager.setDefaultValues(this, R.xml.preferences, false);
    setContentView(R.layout.activity_main);
    getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));

    Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
    setSupportActionBar(toolbar);

    final int columns = getResources().getInteger(R.integer.gallery_columns);

    mDatabaseReference = FirebaseDatabase.getInstance().getReference();
    query = mDatabaseReference.child("apod").orderByChild("date");

    mRecyclerView = (RecyclerView) findViewById(R.id.recycler_view);
    mRecyclerView.setHasFixedSize(true);
    mRecyclerView.setLayoutManager(new GridLayoutManager(this, columns));


    mRecyclerAdapter = new RecyclerAdapter(this, query);
    mRecyclerView.setAdapter(mRecyclerAdapter);


  }

@Override
public void onDestroy() {
    mRecyclerAdapter.cleanupListener();
  }

}

RecyclerAdapter

public class RecyclerAdapter extends RecyclerView.Adapter<RecyclerAdapter.ApodViewHolder> {

private final Context mContext;
private final ChildEventListener mChildEventListener;
private final Query mDatabaseReference;
private final List<String> apodListIds = new ArrayList<>();
private final List<Apod> apodList = new ArrayList<>();

public RecyclerAdapter(final Context context, Query ref) {
    mContext = context;
    mDatabaseReference = ref;


    ChildEventListener childEventListener = new ChildEventListener() {


        @Override
        public void onChildAdded(DataSnapshot dataSnapshot, String s) {
            int oldListSize = getItemCount();
            Apod apod = dataSnapshot.getValue(Apod.class);

            //Add data and IDs to the list
            apodListIds.add(dataSnapshot.getKey());
            apodList.add(apod);

            //Update the RecyclerView
            notifyItemInserted(oldListSize - getItemCount() - 1);
        }

        @Override
        public void onChildChanged(DataSnapshot dataSnapshot, String s) {

        }

        @Override
        public void onChildRemoved(DataSnapshot dataSnapshot) {
            String apodKey = dataSnapshot.getKey();
            int apodIndex = apodListIds.indexOf(apodKey);

            if (apodIndex > -1) {

                // Remove data and IDs from the list
                apodListIds.remove(apodIndex);
                apodList.remove(apodIndex);

                // Update the RecyclerView
                notifyDataSetChanged();
            }
        }

        @Override
        public void onChildMoved(DataSnapshot dataSnapshot, String s) {

        }

        @Override
        public void onCancelled(DatabaseError databaseError) {

        }

    };
    ref.addChildEventListener(childEventListener);
    mChildEventListener = childEventListener;

 }

   @Override
    public int getItemCount() {
    return apodList.size();
}
    public void cleanupListener() {
    if (mChildEventListener != null) {
        mDatabaseReference.removeEventListener(mChildEventListener);
    }
  }

}
Phasia answered 28/2, 2017 at 16:29 Comment(2)
I think the problem is that the new RV after a configuration change doesn't know anything about its view contents at the end of onCreate, because that data comes in only after all the child listeners trigger. So it doesn't know how to fully restore itself given that it thinks it's empty at that time. I don't know a solution offhand, but did want to offer up that info. You can query a RV for its scroll offset, if that helps.Bellew
@DougStevenson. Thanks a lot Doug, I tried saving and restoring the position, but could not get it to work. I thought this post would get more attention, because I am either completely missing something, or Firebase makes it hard to implement this sort of thing. I'll continue to try and will update my post if I find anything else.Phasia
P
13

EDIT:

I was finally able to make this work using multiple factors. If any one of these were left out, it simply would not work.

Create new GridLayoutManager in my onCreate

gridLayoutManager = new GridLayoutManager(getApplicationContext(), columns);

Save the RecyclerView state in onPause

private final String KEY_RECYCLER_STATE = "recycler_state"; 
private static Bundle mBundleRecyclerViewState;
private Parcelable mListState = null;

@Override
protected void onPause() {
    super.onPause();

    mBundleRecyclerViewState = new Bundle();
    mListState = mRecyclerView.getLayoutManager().onSaveInstanceState();
    mBundleRecyclerViewState.putParcelable(KEY_RECYCLER_STATE, mListState);
}

Include configChanges in the AndroidManifest.xml

Saving and restoring the RecyclerView state was not enough. I also had to go against the grain and change my AndroidManifest.xml to 'android:configChanges="orientation|screenSize"'

<activity
    android:name=".MainActivity"
    android:configChanges="orientation|screenSize">

Restore the RecyclerView state in onConfigurationChanged using a Handler

The handler was one of the most important parts, if it's not included, it simply will not work and the position of the RecyclerView will reset to 0. I guess this has been a flaw going back a few years, and was never fixed. I then changed the GridLayoutManager's setSpanCount depending on the orientation.

@Override
public void onConfigurationChanged(Configuration newConfig) {
    super.onConfigurationChanged(newConfig);
    columns = getResources().getInteger(R.integer.gallery_columns);

    if (mBundleRecyclerViewState != null) {
        new Handler().postDelayed(new Runnable() {
            @Override
            public void run() {
                mListState = mBundleRecyclerViewState.getParcelable(KEY_RECYCLER_STATE);
                mRecyclerView.getLayoutManager().onRestoreInstanceState(mListState);
            }
        }, 50);
    }

    // Checks the orientation of the screen
    if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {
        gridLayoutManager.setSpanCount(columns);
    } else if (newConfig.orientation == Configuration.ORIENTATION_PORTRAIT) {
        gridLayoutManager.setSpanCount(columns);
    }
    mRecyclerView.setLayoutManager(gridLayoutManager);
}

Like I said, all of these changes needed to be included, at least for me. I don't know if this is the most efficient way, but after spending many hours, it works for me.

Phasia answered 2/3, 2017 at 19:2 Comment(2)
Working very well with grid span changes, thanks man!Jacquard
Is there a way write the below xml code from AndroidManifest.xml in Java in the respective acticity? <activity android:name=".MainActivity" android:configChanges="orientation|screenSize">Zagazig
A
8

While rotation activity get destroyed and re-created once again that means all your objects destroyed and re-created and also layout re-drawn again.

It you want to stop re-creation of activity you can use android:configChanges but it is bad practice and also not the right way.

Only thing remains now is to save position of recyclerview before rotation and get it after activity re-created.

// to save recyclerview position

 @Override
    public void onSaveInstanceState(Bundle savedInstanceState) {
      // Save UI state changes to the savedInstanceState.
      // This bundle will be passed to onCreate if the process is
      // killed and restarted.
      savedInstanceState.putInt("position", mRecyclerView.getAdapterPosition()); // get current recycle view position here.
      super.onSaveInstanceState(savedInstanceState);
    }

// to restore value

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

    PreferenceManager.setDefaultValues(this, R.xml.preferences, false);
    setContentView(R.layout.activity_main);
    getWindow().setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));

    Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
    setSupportActionBar(toolbar);

    final int columns = getResources().getInteger(R.integer.gallery_columns);

    mDatabaseReference = FirebaseDatabase.getInstance().getReference();
    query = mDatabaseReference.child("apod").orderByChild("date");

    mRecyclerView = (RecyclerView) findViewById(R.id.recycler_view);
    mRecyclerView.setHasFixedSize(true);
    mRecyclerView.setLayoutManager(new GridLayoutManager(this, columns));


    mRecyclerAdapter = new RecyclerAdapter(this, query);
    mRecyclerView.setAdapter(mRecyclerAdapter);
   if(savedInstanceState != null){
     // scroll to existing position which exist before rotation.
     mRecyclerView.scrollToPosition(savedInstanceState.getInt("position"));
   }  

  }

Hope these help you.

Anemone answered 8/3, 2017 at 3:32 Comment(5)
Thanks @jitesh, I tried this before, but it was not enough to work. One reason was due to me having to add a Handler. When I was finally able to make it work, it still had a terrible delay where it refreshed the whole screen, I'm guessing because notifyItemInserted(oldListSize - getItemCount() - 1); was called again on the new Activity. The only way around this was to use android:configChanges, I know it's frowned upon, but it looks a hell of a lot less uglier during the orientation change.Phasia
There is no method RecyclerView.getAdapterPosition()Duna
There is no method getAdapterPosition()Argentous
getAdapterPosition isn't available, but you can get it from the layout manager, eg LinearLayoutManager.findFirstVisibleItemPosition()Halves
You need this as well: <activity android:name=".MainActivity" android:configChanges="orientation|screenSize" >Presurmise
V
1

Actually there is much better approach to restore the recycler view position when activity recreates, and this is by using the recycler view layout manager option to store and restore the last position (the last recycler view layout state). Take a look this tutorial.

With a few words, you need to call the layout manager methods onSaveInstanceState() and onRestoreInstanceState(Parcelable state) inside the corresponding activity methods. At the end you have to restore the layout manager state right after you’ve populated the adapter with data.

If you take a look inside the implementation of LinearLayoutManager for example, you will see how this class manage the state (store / restore)

Vender answered 1/2, 2018 at 22:3 Comment(0)
M
1

I finaly did it. Without hacks or change in manifest.xml. After long time of reading i found the key point :

So if you are loading your data asynchronously on each screen rotation and then posting it to the Main thread through the Looper queue, you will always end up with losing the previous scroll position. In fact, any

handler.post(this::setListItems);

will break it as well, because this action will be posted into the queue and delivered after the list has already started its layout.

As a result you have to use Handler or cache data if you want restoring recycler view state. Here is the code sample (androidx fragment).

private static String STATE = "STATE";
    
    // the recycler view manager
    private GridLayoutManager layoutManager;
    
    @Override
    public void onSaveInstanceState(@NonNull Bundle outState) {
        super.onSaveInstanceState(outState);
        // save recycler view layout
        if(layoutManager != null) {
            outState.putParcelable(STATE, layoutManager.onSaveInstanceState());
        }
    }
    
    
    @Override
    public View onCreateView(LayoutInflater inflater, ViewGroup container,
                             Bundle savedInstanceState) {
                             
        ...
        

        loadDataAsync(savedInstanceState);
        
    
        return view;
    }
    
    private void loadDataAsync(Bundle savedInstanceState){
    
        repository.getAll(new IMyListener()){
                @Override
                public void getAll(List<String> listResult) {
                        reyclerViewFilterableAdapter.setItems(listResult);
                        reyclerViewFilterableAdapter.notifyDataSetChanged();
                        new Handler().postDelayed(new Runnable() {

                            @Override
                            public void run() {
                                if (layoutManager != null) {
                                    layoutManager.onRestoreInstanceState(savedInstanceState.getParcelable(STATE));
                                }
                            }
                        }, 100);
                }
            });
    
    }

Source : https://medium.com/@dimezis/android-scroll-position-restoring-done-right-cff1e2104ac7

Mortenson answered 8/9, 2020 at 15:52 Comment(0)
T
0

Activity gets recreated every time there is an orientation change. So you will have to save the position in the bundle and then re-use it when the onCreate() gets called the 2nd time.

@Override
public void onCreate(Bundle savedInstanceState) {

   super.onCreate(savedInstanceState);

   int recyclerViewPosition;
   if(savedInstanceState != null){
     recyclerViewPosition = savedInstanceState.getInt(RECYCLER_VIEW_POSITION);
   }

   mRecyclerView.scrollToPosition(recyclerViewPosition);

}

@Override
public void onSaveInstanceState(Bundle outState) {
    super.onSaveInstanceState(outState);

    //save the current recyclerview position 
    outState.putInt(RECYCLER_VIEW_POSITION, mRecyclerView.getAdapterPosition());
}

You may want to have a look at RecyclerView.State() link for more options on how to restore the recycler view's state.

Titty answered 7/3, 2017 at 17:57 Comment(0)
P
0

What worked for me is simply using this line in AndroidManifest.xml

android:configChanges="orientation|screenSize"

example:

<activity
     android:name=".Activity.MainActivity"
     android:exported="false"
     android:configChanges="orientation|screenSize"
     android:theme="@style/Theme.AppCompat.Light.NoActionBar">
</activity>
Pelf answered 16/5, 2024 at 16:13 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.