47
votes

What I want to achieve: Have a RecyclerView with GridLayoutManager that supports drag'n'drop and that rearranges the items while dragging.

Side note: First time developing anything with drag and drop.

There are a lot of topics on how to achieve this feature using a ListView, for example: https://raw.githubusercontent.com/btownrippleman/FurthestProgress/master/FurthestProgress/src/com/anappforthat/android/languagelineup/DynamicListView.java

However the examples are usually a lot of code with, creating bitmaps of the dragged view and it feels like it should be possible to achieve the same result using View.startDrag(...) and RecyclerView with notifyItemAdded(), notifyItemMoved() and notifyItemRemoved() since they provide rearrange animations.

So I played around some and came up with this:

final CardAdapter adapter = new CardAdapter(list);
adapter.setHasStableIds(true);
adapter.setListener(new CardAdapter.OnLongClickListener() {
    @Override
    public void onLongClick(View view) {
        ClipData data = ClipData.newPlainText("","");
        View.DragShadowBuilder builder = new View.DragShadowBuilder(view);
        final int pos = mRecyclerView.getChildAdapterPosition(view);
        final Goal item = list.remove(pos);

        mRecyclerView.setOnDragListener(new View.OnDragListener() {
            int prevPos = pos;

            @Override
            public boolean onDrag(View view, DragEvent dragEvent) {
                final int action = dragEvent.getAction();
                switch(action) {
                    case DragEvent.ACTION_DRAG_LOCATION:
                        View onTopOf = mRecyclerView.findChildViewUnder(dragEvent.getX(), dragEvent.getY());
                        int i = mRecyclerView.getChildAdapterPosition(onTopOf);

                        list.add(i, list.remove(prevPos));
                        adapter.notifyItemMoved(prevPos, i);
                        prevPos = i;
                        break;

                    case DragEvent.ACTION_DROP:
                        View underView = mRecyclerView.findChildViewUnder(dragEvent.getX(), dragEvent.getY());
                        int underPos = mRecyclerView.getChildAdapterPosition(underView);

                        list.add(underPos, item);
                        adapter.notifyItemInserted(underPos);
                        adapter.notifyDataSetChanged();
                        break;
                }

                return true;
            }
        });

        view.startDrag(data, builder, view, 0);
    }
});
mRecyclerView.setAdapter(adapter);

This piece of code sort of work, I get the swapping, but very unstable/shaky and sometimes when it's refreshing the whole grid is rearranged back to original order or to something random. Anyway the code above is just my first quick attempt, what I'm really more interested in knowing is if there's some standard/best practice way of doing the drag and drop with ReyclerView's or if the correct way of solving it is still the same that's been used for ListViews for years?

4

4 Answers

117
votes

There is actually a better way to achieve this. You can use some of the RecyclerView's "companion" classes:

ItemTouchHelper, which is

a utility class to add swipe to dismiss and drag & drop support to RecyclerView.

and its ItemTouchHelper.Callback, which is

the contract between ItemTouchHelper and your application

// Create an `ItemTouchHelper` and attach it to the `RecyclerView`
ItemTouchHelper ith = new ItemTouchHelper(_ithCallback);
ith.attachToRecyclerView(rv);

// Extend the Callback class
ItemTouchHelper.Callback _ithCallback = new ItemTouchHelper.Callback() {
    //and in your imlpementaion of
    public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
        // get the viewHolder's and target's positions in your adapter data, swap them
        Collections.swap(/*RecyclerView.Adapter's data collection*/, viewHolder.getAdapterPosition(), target.getAdapterPosition());
        // and notify the adapter that its dataset has changed
        _adapter.notifyItemMoved(viewHolder.getAdapterPosition(), target.getAdapterPosition());
        return true;
    }

    @Override
    public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
        //TODO    
    }

    //defines the enabled move directions in each state (idle, swiping, dragging). 
    @Override
    public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
        return makeFlag(ItemTouchHelper.ACTION_STATE_DRAG,
                ItemTouchHelper.DOWN | ItemTouchHelper.UP | ItemTouchHelper.START | ItemTouchHelper.END);
    }
};

For more details check their documentation.

18
votes

This is my solution with database reordering:

    ItemTouchHelper.SimpleCallback simpleItemTouchCallback = new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) {
        @Override
        public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
            final int fromPosition = viewHolder.getAdapterPosition();
            final int toPosition = target.getAdapterPosition();
            if (fromPosition < toPosition) {
                for (int i = fromPosition; i < toPosition; i++) {
                    Collections.swap(mAdapter.getCapitolos(), i, i + 1);
                }
            } else {
                for (int i = fromPosition; i > toPosition; i--) {
                    Collections.swap(mAdapter.getCapitolos(), i, i - 1);
                }
            }
            mAdapter.notifyItemMoved(fromPosition, toPosition);
            return true;
        }

        @Override
        public void onSwiped(RecyclerView.ViewHolder viewHolder, int swipeDir) {
            MyViewHolder svH = (MyViewHolder ) viewHolder;
            int index = mAdapter.getCapitolos().indexOf(svH.currentItem);
            mAdapter.getCapitolos().remove(svH.currentItem);
            mAdapter.notifyItemRemoved(index);
            if (emptyView != null) {
                if (mAdapter.getCapitolos().size() > 0) {
                emptyView.setVisibility(TextView.GONE);
                } else {
                emptyView.setVisibility(TextView.VISIBLE);
                }
            }
        }

        @Override
        public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
            super.clearView(recyclerView, viewHolder);
            reorderData();
        }
    };

    ItemTouchHelper itemTouchHelper = new ItemTouchHelper(simpleItemTouchCallback);
    itemTouchHelper.attachToRecyclerView(recList);

There is a support functions tahat make use of AsyncTask:

private void reorderData() {
    AsyncTask<String, Void, Spanned> task = new AsyncTask<String, Void, Spanned>() {
        @Override
        protected Spanned doInBackground(String... strings) {
            dbService.deleteAllData();
            for (int i = mAdapter.getCapitolos().size() - 1; i >= 0; i--) {
                Segnalibro s = mAdapter.getCapitolos().get(i);
                dbService.saveData(s.getIdCapitolo(), s.getVersetto());
            }
            return null;
        }

        @Override
        protected void onPostExecute(Spanned spanned) {
        }
    };
    task.execute();
}
7
votes

Here, I've made a full sample in Kotlin (here), and, if you wish, you can enable swipe-to-dismiss on it . Here's the entire code of it:

build.gradle

implementation 'androidx.appcompat:appcompat:1.1.0-rc01'
implementation 'androidx.core:core-ktx:1.2.0-alpha02'
implementation 'androidx.constraintlayout:constraintlayout:2.0.0-beta2'
implementation 'com.google.android.material:material:1.1.0-alpha08'
implementation 'androidx.recyclerview:recyclerview:1.1.0-beta01'

grid_item.xml

<TextView
    android:id="@+id/textView" xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent" android:layout_height="100dp" android:gravity="center"/>

activity_main.xml

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/recyclerView" tools:listitem="@layout/grid_item"  xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent" android:layout_height="match_parent"
    android:orientation="vertical" app:spanCount="3" app:layoutManager="androidx.recyclerview.widget.GridLayoutManager"/>

manifest

<manifest package="com.sample.recyclerviewdraganddroptest" xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:tools="http://schemas.android.com/tools">

    <application
        android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true"
        android:theme="@style/AppTheme.NoActionBar" tools:ignore="AllowBackup,GoogleAppIndexingWarning">
        <activity
            android:name=".MainActivity" android:label="@string/app_name" android:theme="@style/AppTheme.NoActionBar">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>

                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
    </application>

</manifest>

MainActivity.kt

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        val items = ArrayList<Int>(100)
        for (i in 0 until 100)
            items.add(i)
        recyclerView.adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
            override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
                return object : RecyclerView.ViewHolder(LayoutInflater.from(parent.context).inflate(R.layout.grid_item, parent, false)) {}
            }

            override fun getItemCount() = items.size

            override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
                val data = items[position]
                holder.itemView.setBackgroundColor(if (data % 2 == 0) 0xffff0000.toInt() else 0xff00ff00.toInt())
                holder.itemView.textView.text = "item $data"
            }
        }
        val itemTouchHelper = ItemTouchHelper(object : ItemTouchHelper.Callback() {
            override fun isLongPressDragEnabled() = true
            override fun isItemViewSwipeEnabled() = false

            override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
                val dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
                val swipeFlags = if (isItemViewSwipeEnabled) ItemTouchHelper.START or ItemTouchHelper.END else 0
                return makeMovementFlags(dragFlags, swipeFlags)
            }

            override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean {
                if (viewHolder.itemViewType != target.itemViewType)
                    return false
                val fromPosition = viewHolder.adapterPosition
                val toPosition = target.adapterPosition
                val item = items.removeAt(fromPosition)
                items.add(toPosition, item)
                recyclerView.adapter!!.notifyItemMoved(fromPosition, toPosition)
                return true
            }

            override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
                val position = viewHolder.adapterPosition
                items.remove(position)
                recyclerView.adapter!!.notifyItemRemoved(position)
            }

        })
        itemTouchHelper.attachToRecyclerView(recyclerView)
    }

}
-4
votes

I found Advancedrecyclerview quite useful. Have a look!!!