I solved the problem by implementing a custom RecyclerView.OnItemTouchListener
and by redefining what a long press is.
A long press in Android is triggered by a MotionEvent#ACTION_DOWN
and then a continuous MotionEvent#ACTION_MOVE
for a specific duration of time without moving much, and without needing the Pointer (finger or mouse) to triggere MotionEvent#ACTION_UP
.
I had to changed the long press to be triggered after the continuous MotionEvent#ACTION_MOVE
for a specific duration of time on MotionEvent#ACTION_UP
, for the ItemTouchHelper
to not misfire and trigger a Drag Event.
Custom on Item Touch Logic Implementation
import android.graphics.PointF;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import java.util.function.Supplier;
import de.blueworldgmbh.tracemate.util.TimberWrapper;
public class SelectionAndDragNDropOnTouchHelper
{
private static final long LONG_PRESS_TIME_MS = ViewConfiguration.getLongPressTimeout();
private static final float MOVE_MARGIN_PX = 15;
private long initialTouchTime;
private boolean isDragging = false;
private boolean isScrolling = false;
@NonNull
private PointF initialPoint = new PointF();
@NonNull
private final ItemTouchHelper itemTouchHelper;
@NonNull
private final ItemTouchCallback touchCallback;
@NonNull
private final Supplier<Boolean> isMultiSelectActive;
public SelectionAndDragNDropOnTouchHelper(
@NonNull ItemTouchHelper itemTouchHelper,
@NonNull ItemTouchCallback touchCallback,
@NonNull Supplier<Boolean> isMultiSelectActive
)
{
this.itemTouchHelper = itemTouchHelper;
this.touchCallback = touchCallback;
this.isMultiSelectActive = isMultiSelectActive;
touchCallback.enabledDragNDrop(false);
}
public boolean onTouch(OnSelectionAndDragNDropOnTouchHelper v, MotionEvent event)
{
int action = event.getAction();
PointF currentPoint = new PointF(event.getX(), event.getY());
if (action == MotionEvent.ACTION_DOWN)
{
reset();
initialTouchTime = System.currentTimeMillis();
initialPoint = new PointF(event.getX(), event.getY());
}
else if (action == MotionEvent.ACTION_MOVE)
{
long pressTime = System.currentTimeMillis() - initialTouchTime;
float distanceX = Math.abs(currentPoint.x - initialPoint.x);
float distanceY = Math.abs(currentPoint.y - initialPoint.y);
if (!isMultiSelectActive.get() && !isDragging && !isScrolling && pressTime > LONG_PRESS_TIME_MS)
{
if (distanceX > MOVE_MARGIN_PX || distanceY > MOVE_MARGIN_PX)
{
isDragging = true;
touchCallback.enabledDragNDrop(true);
itemTouchHelper.startDrag(v.getViewHolder());
}
}
else
{
if (distanceX > MOVE_MARGIN_PX || distanceY > MOVE_MARGIN_PX)
{
isScrolling = true;
}
}
}
else if (action == MotionEvent.ACTION_UP)
{
if (isDragging)
{
touchCallback.enabledDragNDrop(false);
return false;
}
if (isScrolling)
{
return false;
}
long pressTime = System.currentTimeMillis() - initialTouchTime;
if (pressTime <= LONG_PRESS_TIME_MS)
{
v.notifyOnClick();
}
else
{
v.notifyOnLongClick();
}
return true;
}
return false;
}
private void reset()
{
touchCallback.enabledDragNDrop(false);
isDragging = false;
isScrolling = false;
}
public interface OnTouchHelper
{
void notifyOnClick();
void notifyOnLongClick();
RecyclerView.ViewHolder getViewHolder();
}
}
To initialize this class you need the ItemTouchHelper
of your list and it's callback ItemTouchHelper.Callback
implementation, and a supplier for when the list is in multi-selection mode.
Check out the LONG_PRESS_TIME_MS
constant, for how long the user has to press and hold for a Drag and Drop or a long press event to be triggered, I used ViewConfiguration.getLongPressTimeout()
which for the phones I'm using is 400ms, I think it is a bit much, but I'm still fine tuning it.
Every time we return false
, we indicate that we didn't "handle" the touch event. That is how the drag and drop event can be used after starting it manually here, or how scrolling will continue to function as intendet.
OnTouchHelper
Interface
This interface is for the View Holders of the list. See below for the implementation.
ItemTouchHelper.Callback
Implementation
This implementation need a few additions.
First a method to enable or disable the drag and drop feature of the list, see enabledDragNDrop(boolean)
and in the method getMovementFlags(RecyclerView, RecyclerView.ViewHolder)
it should check if the drag and drop feature is active to pass on the drag flags or not. ❕ Do not overlook the swipe flags, I only had to deal with Drag and Drop.
@Override
public int getMovementFlags(
@NotNull RecyclerView recyclerView,
@NotNull RecyclerView.ViewHolder viewHolder
)
{
if (enabled)
{
return makeFlag(ACTION_STATE_DRAG, ItemTouchHelper.DOWN | ItemTouchHelper.UP);
}
else
{
return 0;
}
}
public void enabledDragNDrop(boolean enabled) {
this.enabled = enabled;
}
RecyclerView.ViewHolder implements OnTouchHelper
In the View Holder, do not register the click or long click listeners with the view, but "triggere" it manually, otherwise it will trigger twice. If you find a way to register these listener and not triggere it twice, a comment is appreciated
@Override
public void notifyOnClick()
{
if (clickListener != null)
{
// custom click listener interface
clickListener.onItemClick(this, getAdapterPosition());
}
}
@Override
public void notifyOnLongClick()
{
if (longClickListener != null)
{
// custom long click listener interface
longClickListener.onItemLongClick(this, getAdapterPosition());
}
}
@Override
public RecyclerView.ViewHolder getViewHolder()
{
return this;
}
RecyclerView.OnItemTouchListener
And finally the RecyclerView.OnItemTouchListener
, only implement onInterceptTouchEvent
and ignore the other two methods
@Override
public boolean onInterceptTouchEvent(
@NonNull RecyclerView rv,
@NonNull MotionEvent motionEvent
)
{
View view = rv.findChildViewUnder(motionEvent.getX(), motionEvent.getY());
if (view != null)
{
RecyclerView.ViewHolder viewHolder = rv.findContainingViewHolder(view);
if (viewHolder instanceof SelectionAndDragNDropOnTouchHelper.OnTouchHelper)
{
return selectionAndDragNDropOnTouchHelper.onTouch(
(SelectionAndDragNDropOnTouchHelper.OnTouchHelper) viewHolder,
motionEvent
);
}
}
return false;
}
Here we get the view via the touch coordinates and with the view we get the View Holder.
Just pass the item touch event along to the custom on item touch logic implementation in SelectionAndDragNDropOnTouchHelper
, and return it's return value, otherwise return false
.
I know the code can be optimize a bit more and written better, but it is fulfilling it's function.
I hope it helps