Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

RecyclerView two-way endless scroll with loading data on demand

There's lots of information on how to use EndlessScroll and load data on demand with RecyclerView. However, it supports scrolling and loading data only in one direction. In our project we need to have ability to load an arbitrary part of data and allow to the user to scroll in either of directions(Up and Down) and load data on demand in both directions. In other words, each time user scrolls to end - load data at the end of history. And each time user scrolls to beginning - load data at the beginning of history

An example of such implementation is Skype/Telegram chat history. When you open the chat, you get to the beginning of unread messages list and as long as you start scrolling the chat history they load data on demand.

Problem with RecyclerView is that it uses an offset position to address items and views; making it difficult to provide the loaded data into the adapter and notify about the changes in positions and count. When we scroll to the beginning of the history we cannot insert data at positions -1 to -n. Has anyone found a solution for this? Updating positions of the items on the fly?

like image 1000
Dmitrii Semenov Avatar asked Jan 23 '18 11:01

Dmitrii Semenov


1 Answers

I know this is a late answer but when the question was first posted I thought there'd be implementations already available on github, unfortunately I couldn't find any and was a little busy with other stuff and the repo got pushed back.

So I came up with a solution to the problem. You will need to extend RecyclerView.Adapter and maintain your own start and end positions to your data adapter (Map<Integer, DataItem>).

TwoWayEndlessAdapter.java

/**
 * The {@link TwoWayEndlessAdapter} class provides an implementation to manage two end data
 * insertion into a {@link RecyclerView} easy by handling all of the logic within.
 * <p>To implement a TwoWayEndlessAdapter simply extend from it, provide the class type parameter
 * of the data type and <code>Override onBindViewHolder(ViewHolder, DataItem, int)</code> to bind
 * the view to.</p>
 *
 * @param <DataItem> A class type that can used by the data adapter.
 * @version 1.0.0
 * @author Abbas
 * @see android.support.v7.widget.RecyclerView.Adapter
 * @see TwoWayEndlessAdapterImp
 */

public abstract class TwoWayEndlessAdapter<VH extends RecyclerView.ViewHolder, DataItem> extends RecyclerView.Adapter<VH> {

    /*
    * Data Adapter Container.
    * */
    protected List<DataItem> data;

    private Callback mEndlessCallback = null;

    /*
    * Number of items before the last to get the lazy loading callback to load more items.
    * */
    private int bottomAdvanceCallback = 0;

    private boolean isFirstBind = true;

    /**
     * @param callback A listener to set if want to receive bottom and top reached callbacks.
     * @see TwoWayEndlessAdapter.Callback
     */
    public void setEndlessCallback(Callback callback)
    {
        mEndlessCallback = callback;
    }

    /**
     * Appends the provided list at the bottom of the {@link RecyclerView}
     *
     * @param bottomList The list to append at the bottom of the {@link RecyclerView}
     */
    public void addItemsAtBottom(ArrayList<DataItem> bottomList)
    {
        if (data == null) {
            throw new NullPointerException("Data container is `null`. Are you missing a call to setDataContainer()?");
        }

        if (bottomList == null || bottomList.isEmpty()) {
            return;
        }

        int adapterSize = getItemCount();

        data.addAll(adapterSize, bottomList);

        notifyItemRangeInserted(adapterSize, adapterSize + bottomList.size());
    }

    /**
     * Prepends the provided list at the top of the {@link RecyclerView}
     *
     * @param topList The list to prepend at the bottom of the {@link RecyclerView}
     */
    public void addItemsAtTop(ArrayList<DataItem> topList)
    {
        if (data == null) {
            throw new NullPointerException("Data container is `null`. Are you missing a call to setDataContainer()?");
        }

        if (topList == null || topList.isEmpty()) {
            return;
        }

        Collections.reverse(topList);
        data.addAll(0, topList);

        notifyItemRangeInserted(0, topList.size());
    }

    /**
     * To call {@link TwoWayEndlessAdapter.Callback#onBottomReached()} before the exact number of items to when the bottom is reached.
     * @see this.bottomAdvanceCallback
     * @see Callback
     * */
    public void setBottomAdvanceCallback(int bottomAdvance)
    {
        if (bottomAdvance < 0) {
            throw new IndexOutOfBoundsException("Invalid index, bottom index must be greater than 0");
        }

        bottomAdvanceCallback = bottomAdvance;
    }

    /**
     * Provide an instance of {@link Map} where the data will be stored.
     * */
    public void setDataContainer(List<DataItem> data)
    {
        this.data = data;
    }

    /**
     * Called by RecyclerView to display the data at the specified position. This method should
     * update the contents of the {@link RecyclerView.ViewHolder#itemView} to reflect the item at
     * the given position.
     * <p>
     * Note that unlike {@link android.widget.ListView}, RecyclerView will not call this method
     * again if the position of the item changes in the data set unless the item itself is
     * invalidated or the new position cannot be determined. For this reason, you should only
     * use the <code>position</code> parameter while acquiring the related data item inside
     * this method and should not keep a copy of it. If you need the position of an item later
     * on (e.g. in a click listener), use {@link RecyclerView.ViewHolder#getAdapterPosition()} which
     * will have the updated adapter position.
     *
     * Any class that extends from {@link TwoWayEndlessAdapter} should not Override this method but
     * should Override {@link #onBindViewHolder(VH, DataItem, int)} instead.
     *
     * @param holder The ViewHolder which should be updated to represent the contents of the
     *        item at the given position in the data set.
     * @param position The position of the item within the adapter's data set.
     */
    @Override
    public void onBindViewHolder(VH holder, int position)
    {
        EndlessLogger.logD("onBindViewHolder() for position : " + position);

        onBindViewHolder(holder, data.get(position), position);

        if (position == 0 && !isFirstBind) {
            notifyTopReached();
        }
        else if ((position + bottomAdvanceCallback) >= (getItemCount() - 1)) {
            notifyBottomReached();
        }

        isFirstBind = false;
    }

    /**
     * Called by {@link TwoWayEndlessAdapter} to display the data at the specified position. This
     * method should update the contents of the {@link RecyclerView.ViewHolder#itemView} to reflect
     * the item at the given position.
     * <p>
     * Note that unlike {@link android.widget.ListView}, {@link TwoWayEndlessAdapter} will not call
     * this method again if the position of the item changes in the data set unless the item itself
     * is invalidated or the new position cannot be determined. For this reason, you should only
     * use the <code>position</code> parameter while acquiring/verifying the related data item
     * inside this method and should not keep a copy of it. If you need the position of an item
     * later on (e.g. in a click listener), use {@link RecyclerView.ViewHolder#getAdapterPosition()}
     * which will have the updated adapter position.
     *
     * Any class that extends from {@link TwoWayEndlessAdapter} must Override this method.
     *
     * @param holder The ViewHolder which should be updated to represent the contents of the
     *               item at the given position in the data set.
     * @param data The data class object associated with the corresponding position which contains
     *            the updated content that represents the item at the given position in the data
     *            set.
     * @param position The position of the item within the adapter's data set.
     */
    public abstract void onBindViewHolder(VH holder, DataItem data, int position);

    /**
     * Sends the {@link Callback#onTopReached} callback if provided.
     * */
    protected void notifyTopReached()
    {
        Handler handler = new Handler(Looper.getMainLooper());

        handler.postDelayed(new Runnable() {
            @Override
            public void run() {
                if (mEndlessCallback != null) {
                    mEndlessCallback.onTopReached();
                }
            }
        }, 50);

    }

    /**
     * Sends the {@link Callback#onBottomReached} callback if provided.
     * */
    protected void notifyBottomReached()
    {
        Handler handler = new Handler(Looper.getMainLooper());

        handler.postDelayed(new Runnable() {
            @Override
            public void run() {
                if (mEndlessCallback != null) {
                    mEndlessCallback.onBottomReached();
                }
            }
        }, 50);
    }

    /**
     * The {@link TwoWayEndlessAdapter.Callback} class provides an interface notify when bottom or
     * top of the list is reached.
     */
    public interface Callback {
        /**
         * To be called when the first item of the {@link RecyclerView}'s data adapter is bounded to
         * the view.
         * Except the first time.
         * */
        void onTopReached();
        /**
         * To be called when the last item of the {@link RecyclerView}'s data adapter is bounded to
         * the view.
         * Except the first time.
         * */
        void onBottomReached();
    }
}

An implementation of the above class could be.

TwoWayEndlessAdapterImp.java

public class TwoWayEndlessAdapterImp<VH extends RecyclerView.ViewHolder> extends TwoWayEndlessAdapter<VH, ValueItem> {

    @Override
    public int getItemViewType(int position)
    {
        return R.layout.item_layout;
    }

    @Override
    public VH onCreateViewHolder(ViewGroup parent, int viewType)
    {
        View itemViewLayout = LayoutInflater.from(parent.getContext()).inflate(viewType, parent, false);

        switch (viewType)
        {
            case R.layout.item_layout:

                return (VH) new ItemLayoutViewHolder(itemViewLayout);

            default:
                return null;
        }
    }

    @Override
    public void onBindViewHolder(VH holder, ValueItem item, int position)
    {
        switch (getItemViewType(position)) {
            case R.layout.item_layout:
                ItemLayoutViewHolder viewHolder = (ItemLayoutViewHolder) holder;

                viewHolder.textView.setText(item.data);
                break;
        }
    }

    @Override
    public int getItemCount()
    {
        return data == null ? 0 : data.size();
    }
}

To use TwoWayEndlessAdapter

TwoWayEndlessAdapterImp endlessAdapter = new TwoWayEndlessAdapterImp<>();
endlessAdapter.setDataContainer(new ArrayList<DataItem>());
endlessAdapter.setEndlessCallback(this);

Finally call addItemsAtBottom(list); to add new items at the bottom and call addItemsAtTop(list); only to add the items at the top.

like image 125
Abbas Avatar answered Oct 20 '22 00:10

Abbas