I have a simple recyclerview with items (tips) and a loading spinner at the bottom.
here's how the item count and item view type methods look:
#Override
public int getItemViewType(int position) {
if (position == getItemCount() - 1) { // last position
return LOADING_FOOTER_VIEW_TYPE;
}
else {
return TIP_VIEW_TYPE;
}
}
#Override
public int getItemCount() {
return tips.size() + 1; // + 1 for the loading footer
}
basically, i just have a loading spinner under all my items.
I create the adapter once like so:
public TipsListAdapter(TipsActivity tipsActivity, ArrayList<Tip> tips) {
this.tipsActivity = tipsActivity;
this.tips = tips;
}
and then once i have fetched additional items, i call add like so:
public void addTips(List<Tip> tips) {
// hide the loading footer temporarily
isAdding = true;
notifyItemChanged(getItemCount() - 1);
// insert the new items
int insertPos = this.tips.size(); // this will basically give us the position of the loading spinner
this.tips.addAll(tips);
notifyItemRangeInserted(insertPos, tips.size());
// allow the loading footer to be shown again
isAdding = false;
notifyItemChanged(getItemCount() - 1);
}
What's odd here is that when i do that, the scroll position goes to the very bottom. It almost seems like it followed the loading spinner. This only happens on the first add (i.e. when there is only the loading spinner showing initally). subsequent adds maintains the proper scroll position (the position where the items were inserted).
This doesn't happen if i change notifyItemRangeInserted() to notifyItemRangeChanged() like so:
public void addTips(List<Tip> tips) {
// hide the loading footer temporarily
isAdding = true;
notifyItemChanged(getItemCount() - 1);
// insert the new items
int insertPos = this.tips.size(); // this will basically give us the position of the loading spinner
this.tips.addAll(tips);
notifyItemRangeChanged(insertPos, tips.size());
// allow the loading footer to be shown again
isAdding = false;
notifyItemChanged(getItemCount() - 1);
}
Nor does it happen if i simply call notifyDataSetChanged() like so:
public void addTips(List<Tip> tips) {
this.tips.addAll(tips);
notifyDataSetChanged();
}
Here's the code for setting the adapter in my Activity:
public void setAdapter(#NonNull ArrayList<Tip> tips) {
if (!tips.isEmpty()) { // won't be empty if restoring state
hideProgressBar();
}
tipsList.setAdapter(new TipsListAdapter(this, tips));
}
public void addTips(List<Tip> tips) {
hideProgressBar();
getAdapter().addTips(tips);
restorePageIfNecessary();
}
private TipsListAdapter getAdapter() {
return (TipsListAdapter) tipsList.getAdapter();
}
Note:
I don't manually set scroll position anywhere.
I call setAdapter() in onResume()
addTips() is called after I fetch items from the server
Let me know if you need any additional parts of my code.
This only happens on the first add (i.e. when there is only the loading spinner showing initally). subsequent adds maintains the proper scroll position (the position where the items were inserted).
RecyclerView has built-in behavior when calling the more-specific dataset change methods (like notifyItemRangeInserted() as opposed to notifyDataSetChanged()) that tries to keep the user looking at "the same thing" as before the operation.
When the data set changes, the first item the user can see is prioritized as the "anchor" to keep the user looking at approximately the same thing. If possible, the RecyclerView will try to keep this "anchor" view visible after the adapter update.
On the very first load, the first item (the only item) is the loading indicator. Therefore, when you load the new tips and update the adapter, this behavior will prioritize keeping the loading indicator on-screen. Since the loading indicator is kept at the end of the list, this will scroll the list to the bottom.
On subsequent loads, the first item is not the loading indicator, and it doesn't move. So the RecyclerView will not appear to scroll, since it doesn't have to do so to keep the "anchor" on-screen.
My recommendation is to check insertPos and see if it is zero. If it is, that means this is the first load, so you should update the adapter by calling notifyDataSetChanged() in order to avoid this anchoring behavior. Otherwise, call notifyItemRangeInserted() as you're currently doing.
Remove the setAdapter code from onResume ASAP as you are setting new TipsListAdapter(this, tips);
Every time a new reference of the adapter is created...make field mAdapter and then set it in onCreate . RecyclerView doesnt remember the scrolled position because everytime a new reference of adapter is being created.. onResume gets called infinitely when activity is in running state..
So either you setAdapter in onCreate using new operator to create reference for adapter or,
in onResume use mAdapter field variable reference..
Background
I work on an app that has a RecyclerView which you can scroll up and down however you wish.
The data items are loaded from the server, so if you are about to reach the bottom or the top, the app gets new data to show there.
To avoid weird scrolling behavior, and staying on the current item, I use 'DiffUtil.Callback' , overriding 'getOldListSize', 'getNewListSize', 'areItemsTheSame', 'areContentsTheSame'.
I've asked about this here, since all I get from the server is a whole new list of items, and not the difference with the previous list.
The problem
The RecyclerView doesn't have only data to show. There are some special items in it too:
Since Internet connection might be slow, there is a header item and a footer item in this RecyclerView, which just have a special Progress view, to show you've reached the edge and that it will get loaded soon.
The header and footer always exist in the list, and they are not received from the server. It's purely a part of the UI, just to show things are about to be loaded.
Thing is, just like the other items, it needs to be handled by DiffUtil.Callback, so for both areItemsTheSame and areContentsTheSame, I just return true if the old header is the new header, and the old footer is the new footer:
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldItems[oldItemPosition]
val newItem = newItems[newItemPosition]
when {
oldItem.itemType != newItem.itemType -> return false
oldItem.itemType == ItemType.TYPE_FOOTER || oldItem.itemType == AgendaItem.TYPE_HEADER -> return true
...
}
}
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean {
val oldItem = oldItems[oldItemPosition]
val newItem = newItems[newItemPosition]
return when {
oldItem.itemType == ItemType.TYPE_FOOTER || oldItem.itemType == ItemType.TYPE_HEADER -> true
...
}
}
}
Seems right? Well it's wrong. If the user is at the top of the list, showing the header, and the list gets updated with new items, the header will stay at the top, meaning the previous items you've seen will get pushed away by the new ones.
Example:
Before: header, 0, 1, 2, 3, footer
After: header, -3, -2, -1, 0, 1, 2, 3, footer
So if you stayed on the header, and the server sent you the new list, you still see the header, and below the new items, without seeing the old ones. It scrolls for you instead of staying on the same position .
Here's a sketch showing the issue. The black rectangle shows the visible part of the list.
As you can see, before loading, the visible part has the header and some items, and after loading it still has the header and some items, but those are new items that pushed away the old ones.
I need the header to be gone on this case, because the real content is below it. Instead of the area of the header, it might show other items (or a part of them) above it, but the visible position of the current items should stay where they are.
This issue only occurs when the header is shown, at the top of the list. In all other cases it works fine, because only normal items are shown at the top of the visible area.
What I've tried
I tried to find how to set DiffUtil.Callback to ignore some items, but I don't think such a thing exists.
I was thinking of some workarounds, but each has its own disadvantages:
A NestedScrollView (or RecyclerView) which will hold the header&footer and the RecyclerView in the middle, but this might cause some scrolling issues, especially due to the fact I already have a complex layout that depends on the RecyclerView (collapsing of views etc...).
Maybe in the layout of the normal items, I could put the layout of the header and footer too (or just the header, because this one is the problematic one). But this is a bad thing for performance as it inflates extra views for nothing. Plus it requires me to toggle hiding and viewing of the new views within.
I could set a new ID for the header each time there is an update from the server, making it as if the previous header is gone, and there is a totally new header at the top of the new list. However, this might be risky in the case of no real updates of the list at the top, because the header will be shown as if it's removed and then re-added.
The questions
Is there a way to solve this without such workarounds?
Is there a way to tell DiffUtil.Callback : "these items (header&footer) are not real items to scroll to, and these items (the real data items) should be" ?
I will try to explain what I see as a solution to your problem:
Step 1: Remove all the code for FOOTER and HEADER views.
Step 2: Add these methods that add and remove dummy model items in adapter based on the user scroll direction:
/**
* Adds loader item in the adapter based on the given boolean.
*/
public void addLoader(boolean isHeader) {
if (!isLoading()) {
ArrayList<Model> dataList = new ArrayList<>(this.oldDataList);
if(isHeader) {
questions.add(0, getProgressModel());
else {
questions.add(getProgressModel());
setData(dataList);
}
}
/**
* Removes loader item from the UI.
*/
public void removeLoader() {
if (isLoading() && !dataList.isEmpty()) {
ArrayList<Model> dataList = new ArrayList<>(this.oldDataList);
dataList.remove(getDummyModel());
setData(questions);
}
}
public MessageDetail getChatItem() {
return new Model(0, 0, 0, "", "", "")); // Here the first value is id which is set as zero.
}
And here is your rest of the adapter logic that you need to decide if the item is a loader item or an actual data item:
#Override
public int getItemViewType(int position) {
return dataList.get(position).getId() == 0 ? StaticConstants.ItemViewTypes.PROGRESS : StaticConstants.ItemViewTypes.CONTENT;
}
According to the view type, you can add a progress bar view holder in your adapter.
Step 3: use these methods in data loading logic:
While making the API call in onScrolled() method of recyclerView, you need to add a loader item before the api call and then remove it after the api call. Use the given adapter methods above. The coded in onScrolled should look a little like this:
recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
#Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
super.onScrolled(recyclerView, dx, dy);
if (dy < 0) { //This is top scroll, so add a loader as the header.
recyclerViewAdapter.addLoader(true);
LinearLayoutManager linearLayoutManager = (LinearLayoutManager) recyclerView.getLayoutManager();
if (!recyclerViewAdapter.isLoading(true)) {
if (linearLayoutManager.findFirstCompletelyVisibleItemPosition() <= 2) {
callFetchDataApi();
}
}
}
} else {
if (!recyclerViewAdapter.isLoading(false)) {
if (linearLayoutManager.findLastCompletelyVisibleItemPosition() >= linearLayoutManager.getItemCount() - 2) {
callFetchDataApi();
}
}
});
Now after the api call gives you the data you need. Simply remove the added loader from the list like this:
private void onGeneralApiSuccess(ResponseModel responseModel) {
myStreamsDashboardAdapter.removeLoader();
if (responseModel.getStatus().equals(SUCCESS)) {
// Manage your pagination and other data loading logic here.
dataList.addAll(responseModel.getDataList());
recyclerViewAdapter.setData(dataList);
}
}
And lastly, you need to avoid any scroll during data loading operation is add a logic method for that is isLoading() method. which is used in the code of method onScrolled():
public boolean isLoading(boolean isFromHeader) {
if (isFromHeader) {
return dataList.isEmpty() || dataList.get(0).getId() == 0;
} else {
return dataList.isEmpty() || dataList.get(dataList.size() -1).getId() == 0;
}
}
Let me know if you don't understand any of this.
I think for now, the solution I took will suffice. It's a bit weird, but I think it should work:
The header item gets a new id each time the list is different in its first real item. The footer always have the same id, because it's ok for it to move in the current way it works. I don't even need to check that its id is the same. The check of areItemsTheSame is as such for them:
oldItem.agendaItemType == AgendaItem.TYPE_HEADER -> return oldItem.id == newItem.id
oldItem.agendaItemType == AgendaItem.TYPE_FOOTER -> return true
This way, if the header belongs to a new list data, old one will be removed, and new one will be at the top.
It's not the perfect solution, as it doesn't really push the original header to be at the top, and theoretically it makes us "kinda" have 2 headers at the same time (one being removed and one being added) but I think it's good enough.
Also, for some reason, I can't use notifyItemChanged on the header and footer in case only they get updated (internet connection changes its state, so need to change the header&footer alone). Only notifyDataSetChanged works for some reason.
Still, if there is a more official way, could be nice to know.
I have a UI screen that creates CardViews that are saved to a RecyclerView list. Initially, there is no RecyclerView list because there are no CardViews initially. When the first CardView is saved it creates a RecyclerView list to show the CardView data.
I want to be able to test if there is a RecyclerView list already created. If not, launch the CardView activity for user input. If there is a RecyclerView list because a CardView(s) was previously created, then launch the RecyclerView activity. Any ideas on how to test for the existence of the RecyclerView list that I can use with an if/then statement to launch the correct activity?
If you are using a custom recycler adapter which i think you are(Provide more code if not) , Override getItemCount() function like
public int getItemCount() {
if(list!=null)
return this.list.size();
else
return -1;
}
And check the list size and do the functionalities in an if else
Edit
Check this in the activity like
if(adapter.getItemCount()>0)
{
//Do what you want
}
else
{
//Launch the activity
}
I'm new to Android and I'm trying to do the following task on my school project:
I have a grid view of movies which the user can scroll endlessly.
When the app starts I fetch the first 20 movies and each time the user scrolls to the bottom of the grid I execute an AsyncTask to fetch 20 more movies and add them to the Adapter.
When the user clicks on a movie he goes to a new child activity to see the movie details.
I'm having troubles maintaining the GridView's scroll position in the following cases:
When the user goes to the details activity and returns to the main activity of the movies.
When the user changes the device orientation.
And when dealing with theses 2 cases I also need to take in consideration that maybe the user scrolled a lot and had 100 movies in the adapter and when he goes back the activity start from the start with only the first 20 movies, so I would be able to scroll to his last position.
Can someone please tell me how can I give the best user experience in my project by not losing the user's scroll position at any case?
I don't know if this is the best practice, but in my case it is.
I decided to set my adapter as a global static variable, in this way I maintain the amount of data loaded via the API, and I don't need to perform a request for every time the user moves between activities.
For maintaining the scroll position I used the onItemClickListener when moving to the details activity and the savedInstanceState when changing orientation.
Here is my code for that:
//Static variables
private static MoviesAdapter mMoviesAdapter;
private static int mGridViewPosition = 0;
//Call this method when user clicks the back button
public static void ClearStaticData(){
mMoviesAdapter.clear();
mMoviesAdapter = null;
}
#Override
public void onSaveInstanceState(Bundle outState) {
int index = mGridView.getFirstVisiblePosition();
outState.putInt(GRID_VIEW_POSITION, index);
super.onSaveInstanceState(outState);
}
#Override
public View onCreateView(...) {
if (mMoviesAdapter == null) {
mMoviesAdapter = new MoviesAdapter(...);
} else {
RestoreGridPosition();
}
}
private void RestoreGridPosition(){
if(mGridViewPosition > 0 && mMoviesAdapter.getCount() >= mGridViewPosition)
mGridView.setSelection(mGridViewPosition);
}
Since I fill my adapter via API call, I think this is probably the best solution to save the data and not to perform requests every time.
Try not finishing mainActivity once a gridItem is clicked so when user navigates back to mainActivity (from detailsActivity) he will have all the data that was there before.
You can handle this situation with activity's lifecycle callbacks:
You can get currently visible GridView item's position like this:
int mCurrentPosition = gridview.getFirstVisiblePosition();
When an orientation change is occurring the activity is recreated and going through the following stages:
onSaveInstanceState
onRestoreInstanceState
You can then save the position before orientation change is happening and get it back when its being restored.
Save Your Activity State
#Override
public void onSaveInstanceState(Bundle savedInstanceState) {
// Save the user's current scroll state
savedInstanceState.putInt(STATE_POSITION, mCurrentPosition);
// Always call the superclass so it can save the view hierarchy state
super.onSaveInstanceState(savedInstanceState);
}
Restore Your Activity State
public void onRestoreInstanceState(Bundle savedInstanceState) {
// Always call the superclass so it can restore the view hierarchy
super.onRestoreInstanceState(savedInstanceState);
// Restore state members from saved instance
mCurrentPosition = savedInstanceState.getInt(STATE_POSITION);
}
Here once you have the previous position you can move to the desired position in the gridView:
gridview.smoothScrollToPosition(int mCurrentPosition)
This is taken from android docs: Recreating an Activity
Scrolling gridView to position GridView scrolling stackoverflow
I am writing an android app which contains a listview with some items (fetch by database, can be 100+)
i would like the scroll the listview to display the specific item (let say position x in the listview)
i used
listView.post(new Runnable() {
#Override
public void run() {
listView.smoothScrollToPosition(position);
}
});
the view has scrolled. but it stopped once the item appear. (the item is now displayed at the bottom of the screen)
i think better design is to display the item at the top of the screen or at least at the middle of the screen.
How can i do this?
I am now finding the way to find out the index of the listview which is really visible in the screen.
I have tried getFirstVisiblePosition() but it seems always return 0.
You will find setSelection() works better than smoothScrollToPosition(). If you are loading the data backing the ListView in the background, you will want to delay issuing the setSelection() until the data has been loaded.
getFirstVisiblePosition() works fine for me, but again is only useful once all the data has been loaded. Here is some code I use for a ListView that displays data fetched from a remote site:
// the list has been updated, fix the selection. loadFinished will be true if there are no phantom placeholders left.
protected void listLoaded(boolean loadFinished) {
// if the user has scrolled or we previously completed resetting the position, do nothing
if(wasScrolled)
return;
// if we were restored from saved data, reload that data now.
if(listState != null) {
postListView.onRestoreInstanceState(listState);
listState = null;
} else if(initialPos >= 0) // if we had a specific initial position, set it now
setSelection(initialPos);
else if(postId >= 0) // or if there is a specific post ID we want displayed
setSelection(postAdapter.getPostPosition(postId));
else // otherwise scroll to the first unread post
setSelection(postAdapter.getFirstUnread());
if(loadFinished) // if all data has been loaded, set a flag to prevent this being repeated
wasScrolled = true;
}