RecyclerView with CursorAdapter and reordering

This is a combination of a CursorAdapter fit for RecyclerViews, and several helpers to support drag-and-drop and swipe-to-dismiss.

Note: we suggest using Room and LiveData for this; cursors are a thing of the past.

Usage

DragDropItemTouchCallback callback = new DragDropItemTouchCallback(context);
ItemTouchHelper touchHelper = new ItemTouchHelper(callback);
touchHelper.attachToRecyclerView(mRecyclerView);

RecyclerViewCursorAdapter.java

An adapter for RecyclerViews that uses a Cursor. This class does not handle drag-drop yet, it is just a CursorAdapter.

package com.pixplicity.example.ui;

import android.content.Context;
import android.database.Cursor;
import android.database.DataSetObserver;
import android.support.v7.widget.RecyclerView;

/**
 * https://gist.github.com/skyfishjy/443b7448f59be978bc59
 */
public abstract class RecyclerViewCursorAdapter<VH extends RecyclerView.ViewHolder> extends RecyclerView.Adapter<VH> {

    private final Context mContext;
    private Cursor mCursor;
    private boolean mDataValid;
    private int mRowIdColumn;
    private final NotifyingDataSetObserver mDataSetObserver;

    public RecyclerViewCursorAdapter(Context context, Cursor cursor) {
        mContext = context;
        mCursor = cursor;
        mDataValid = cursor != null;
        mRowIdColumn = mDataValid ? mCursor.getColumnIndex("_id") : -1;
        mDataSetObserver = new NotifyingDataSetObserver();
        if (mCursor != null) {
            mCursor.registerDataSetObserver(mDataSetObserver);
        }
    }

    protected Context getContext() {
        return mContext;
    }

    public Cursor getCursor() {
        return mCursor;
    }

    @Override
    public int getItemCount() {
        if (mDataValid && mCursor != null) {
            return mCursor.getCount();
        }
        return 0;
    }

    @Override
    public long getItemId(int position) {
        if (mDataValid && mCursor != null && mCursor.moveToPosition(position)) {
            return mCursor.getLong(mRowIdColumn);
        }
        return 0;
    }

    @Override
    public void setHasStableIds(boolean hasStableIds) {
        super.setHasStableIds(true);
    }

    public abstract void onBindViewHolder(VH viewHolder, Cursor cursor);

    @Override
    public void onBindViewHolder(VH viewHolder, int position) {
        if (!mDataValid) {
            throw new IllegalStateException("this should only be called when the cursor is valid");
        }
        if (!mCursor.moveToPosition(position)) {
            throw new IllegalStateException("couldn't move cursor to position " + position);
        }
        onBindViewHolder(viewHolder, mCursor);
    }

    /**
     * Change the underlying cursor to a new cursor. If there is an existing cursor it will be
     * closed.
     */
    public void changeCursor(Cursor cursor) {
        Cursor old = swapCursor(cursor);
        if (old != null) {
            old.close();
        }
    }

    /**
     * Swap in a new Cursor, returning the old Cursor.  Unlike {@link #changeCursor(Cursor)}, the
     * returned old Cursor is <em>not</em> closed.
     */
    public Cursor swapCursor(Cursor newCursor) {
        if (newCursor == mCursor) {
            return null;
        }
        final Cursor oldCursor = mCursor;
        if (oldCursor != null && mDataSetObserver != null) {
            oldCursor.unregisterDataSetObserver(mDataSetObserver);
        }
        mCursor = newCursor;
        if (mCursor != null) {
            if (mDataSetObserver != null) {
                mCursor.registerDataSetObserver(mDataSetObserver);
            }
            mRowIdColumn = newCursor.getColumnIndexOrThrow("_id");
            mDataValid = true;
            notifyDataSetChanged();
        } else {
            mRowIdColumn = -1;
            mDataValid = false;
            notifyDataSetChanged();
            //There is no notifyDataSetInvalidated() method in RecyclerView.Adapter
        }
        return oldCursor;
    }

    private class NotifyingDataSetObserver extends DataSetObserver {

        @Override
        public void onChanged() {
            super.onChanged();
            mDataValid = true;
            notifyDataSetChanged();
        }

        @Override
        public void onInvalidated() {
            super.onInvalidated();
            mDataValid = false;
            notifyDataSetChanged();
            //There is no notifyDataSetInvalidated() method in RecyclerView.Adapter
        }
    }
}

RecyclerReorderCursorAdapter.java

Extends the previous class to add drag-and-drop support.

package com.pixplicity.example.ui.util;

import android.content.Context;
import android.database.Cursor;
import android.os.Build;
import android.support.v7.widget.RecyclerView;
import android.util.SparseIntArray;

import java.util.LinkedList;
import java.util.List;

/**
 * An extension to the RecyclerCursorAdapter that allows reordering list items by dragging them
 * (after a long-press) and dismissing items by swiping the to the right.
 */
public abstract class RecyclerReorderCursorAdapter<VH extends RecyclerView.ViewHolder> extends RecyclerCursorAdapter<VH> {

    private boolean mResetOnCursorChange = true;

    /**
     * Mapping from cursor index to the reordered index
     */
    private SparseIntArray mPositionMap = new SparseIntArray();

    public RecyclerReorderCursorAdapter(Context context, Cursor cursor) {
        super(context, cursor);
    }

    @Override
    public long getItemId(int position) {
        return super.getItemId(mPositionMap.get(position, position));
    }

    @Override
    public void changeCursor(Cursor newCursor) {
        super.changeCursor(newCursor);

        if (mResetOnCursorChange) {
            resetMap();
        }
    }

    /**
     * Informs the adapter that an item was reordered.  This will add a ReorderItem to
     * the cache used to calculate the modified positions in the cursor.  If either of
     * the positions are outside the cursor bounds, or they are the same then
     * no ReorderItem will be added to the cache
     *
     * @param originalPosition The original position of the item
     * @param newPosition      The new position for the item
     */
    public void reorderItem(int originalPosition, int newPosition) {
        //Make sure the positions aren't the same
        if (originalPosition == newPosition) {
            return;
        }

        //Make sure the positions aren't out of bounds
        if (originalPosition < 0 || newPosition < 0 || originalPosition >= getItemCount() || newPosition >= getItemCount()) {
            return;
        }

        int curFrom = mPositionMap.get(originalPosition, originalPosition);

        //Iterates through the items that will be effected and changes their positions
        if (originalPosition > newPosition) {
            for (int i = originalPosition; i > newPosition; i--) {
                mPositionMap.put(i, mPositionMap.get(i - 1, i - 1));
            }
        } else {
            for (int i = originalPosition; i < newPosition; i++) {
                mPositionMap.put(i, mPositionMap.get(i + 1, i + 1));
            }
        }

        //Makes sure the actual change is in place
        mPositionMap.put(newPosition, curFrom);

        cleanMap();
        notifyItemMoved(originalPosition, newPosition);
    }

    /**
     * Determines if the position map for the cursor will be reset
     * when the cursor is changed
     *
     * @return True if the map will be reset on cursor changes [default: true]
     */
    public boolean getResetMapOnCursorChange() {
        return mResetOnCursorChange;
    }

    /**
     * Sets if the position map for the cursor will be reset when the cursor
     * is changed
     *
     * @param resetOnSwap True if the map should be reset on cursor changes
     */
    public void setResetMapOnCursorChange(boolean resetOnSwap) {
        this.mResetOnCursorChange = resetOnSwap;
    }

    /**
     * Resets the position map for the cursor.  By default this will be
     * called when a cursor is changed (see {@link #getResetMapOnCursorChange()})
     */
    public void resetMap() {
        mPositionMap.clear();
    }

    /**
     * Retrieves the position map for the cursor.  This is organized by the
     * index being the list (visual) position and the values representing the corresponding
     * cursor positions.
     *
     * @return A SparseIntArray representing a map of list positions to cursor positions
     */
    public SparseIntArray getPositionMap() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
            return mPositionMap.clone();
        }

        return clone(mPositionMap);
    }

    /**
     * Goes through the {@link #mPositionMap} removing any mappings that
     * are unnecessary.  This will help keep the map as small as possible
     */
    private void cleanMap() {
        List<Integer> removeList = new LinkedList<>();

        //Finds all the mappings that point to themselves
        for (int i = 0; i < mPositionMap.size(); i++) {
            if (mPositionMap.keyAt(i) == mPositionMap.valueAt(i)) {
                removeList.add(mPositionMap.keyAt(i));
            }
        }

        //Actually removes the items
        for (int i : removeList) {
            mPositionMap.delete(i);
        }
    }

    /**
     * Clones the specified {@link SparseIntArray} using an iterator
     *
     * @param sparseIntArray The {@link SparseIntArray} to clone
     * @return A clone of the specified <code>sparseIntArray</code>
     */
    private SparseIntArray clone(SparseIntArray sparseIntArray) {
        SparseIntArray clone = new SparseIntArray();

        //Iterates through the keys, adding the value to the clone
        for (int index = 0; index < sparseIntArray.size(); index++) {
            int key = sparseIntArray.keyAt(index);
            clone.put(key, sparseIntArray.get(key));
        }

        return clone;
    }
}

DragDropItemCallback.java

Helper class for drag-and-drop and swipe-to-dismiss.

package com.pixplicity.example.ui.util;

import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.helper.ItemTouchHelper;

/**
 * ItemTouchHelper.Callback that adds drag & drop behaviour to a RecyclerView.
 *
 * Kudos to Paul Burke:
 * https://medium.com/@ipaulpro/drag-and-swipe-with-recyclerview-b9456d2b1aaf
 */
public class DragDropItemTouchCallback extends ItemTouchHelper.Callback {

    private final ReorderListener mListener;

    public DragDropItemTouchCallback(ReorderListener listener) {
        mListener = listener;
    }

    @Override
    public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
        int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
        int swipeFlags = ItemTouchHelper.START | ItemTouchHelper.END;
        return makeMovementFlags(dragFlags, swipeFlags);
    }

    @Override
    public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
        mListener.onItemMove(viewHolder.getAdapterPosition(),
                target.getAdapterPosition());
        return true;
    }

    @Override
    public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
        mListener.onItemDismiss(viewHolder.getAdapterPosition());
    }

    @Override
    public boolean isLongPressDragEnabled() {
        return true;
    }

    @Override
    public boolean isItemViewSwipeEnabled() {
        return true;
    }

    public interface ReorderListener {

        void onItemMove(int fromPosition, int toPosition);

        void onItemDismiss(int position);
    }
}