Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to use DiffUtil.Callback with a RecyclerView that has header and footer?

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.

enter image description here

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" ?

like image 214
android developer Avatar asked Apr 10 '18 07:04

android developer


1 Answers

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.

like image 189
Ashutosh Tiwari Avatar answered Sep 24 '22 20:09

Ashutosh Tiwari