-
-
Save atfox7/f5ece37919e4f57ab0c8 to your computer and use it in GitHub Desktop.
Modified ReorderRecyclerView implementing smoother item swap logic and a scaled drawable for the dragging view instead of a line drawn around it.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* Modified version of GitHub user mohlendo's ReorderRecyclerView gist | |
* original at https://gist.github.com/mohlendo/68b7e2f89d0b1b354abe | |
* | |
* Copyright (C) 2014 I.C.N.H GmbH | |
* | |
* Licensed under the Apache License, Version 2.0 (the "License"); | |
* you may not use this file except in compliance with the License. | |
* You may obtain a copy of the License at | |
* | |
* http://www.apache.org/licenses/LICENSE-2.0 | |
* | |
* Unless required by applicable law or agreed to in writing, software | |
* distributed under the License is distributed on an "AS IS" BASIS, | |
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
* See the License for the specific language governing permissions and | |
* limitations under the License. | |
*/ | |
import android.animation.Animator; | |
import android.animation.AnimatorListenerAdapter; | |
import android.animation.ObjectAnimator; | |
import android.animation.TypeEvaluator; | |
import android.animation.ValueAnimator; | |
import android.content.Context; | |
import android.graphics.Bitmap; | |
import android.graphics.Canvas; | |
import android.graphics.Rect; | |
import android.graphics.drawable.BitmapDrawable; | |
import android.os.Handler; | |
import android.support.annotation.NonNull; | |
import android.support.v7.widget.RecyclerView; | |
import android.util.AttributeSet; | |
import android.util.DisplayMetrics; | |
import android.view.GestureDetector; | |
import android.view.MotionEvent; | |
import android.view.View; | |
import timber.log.Timber; | |
/** | |
* A {@link android.support.v7.widget.RecyclerView} that provides reordering with drag&drop. | |
* The Adapter has to be of type {@link ReorderRecyclerView.ReorderAdapter}. | |
* Furthermore you have to provide stable ids {@link android.support.v7.widget.RecyclerView.Adapter#setHasStableIds(boolean)}} | |
*/ | |
public class ReorderRecyclerView extends RecyclerView { | |
private static final int INVALID_POINTER_ID = -1; | |
private static final int LINE_THICKNESS = 15; | |
private static final int SMOOTH_SCROLL_AMOUNT_AT_EDGE = 100; | |
private static final int INVALID_ID = -1; | |
private int activePointerId = INVALID_POINTER_ID; | |
private int downX; | |
private int downY; | |
private int totalOffsetY, totalOffsetX; | |
private static final int SCALE_FACTOR = 20; | |
private BitmapDrawable hoverCell; | |
private Rect hoverCellOriginalBounds; | |
private Rect hoverCellCurrentBounds; | |
private boolean cellIsMobile = false; | |
private long mobileItemId = INVALID_ID; | |
private int smoothScrollAmountAtEdge; | |
private boolean usWaitingForScrollFinish; | |
// stop the swaps from happening while another is still animating | |
boolean allowSwaps = true; | |
public ReorderRecyclerView(Context context) { | |
super(context); | |
init(context); | |
} | |
public ReorderRecyclerView(Context context, AttributeSet attrs) { | |
super(context, attrs); | |
init(context); | |
} | |
public ReorderRecyclerView(Context context, AttributeSet attrs, int defStyle) { | |
super(context, attrs, defStyle); | |
init(context); | |
} | |
public void init(Context context) { | |
DisplayMetrics metrics = context.getResources().getDisplayMetrics(); | |
smoothScrollAmountAtEdge = (int) (SMOOTH_SCROLL_AMOUNT_AT_EDGE / metrics.density); | |
// detector for the long press in order to start the dragging | |
final GestureDetector longPressGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() { | |
public void onLongPress(MotionEvent event) { | |
Timber.d("Longpress detected"); | |
downX = (int) event.getX(); | |
downY = (int) event.getY(); | |
activePointerId = event.getPointerId(0); | |
totalOffsetY = 0; | |
totalOffsetX = 0; | |
View selectedView = findChildViewUnder(downX, downY); | |
if (selectedView == null) { | |
return; | |
} | |
mobileItemId = getChildItemId(selectedView); | |
hoverCell = getAndAddHoverView(selectedView); | |
selectedView.setVisibility(INVISIBLE); | |
cellIsMobile = true; | |
} | |
}); | |
// | |
final OnItemTouchListener itemTouchListener = new OnItemTouchListener() { | |
@Override | |
public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent event) { | |
if (longPressGestureDetector.onTouchEvent(event)) { | |
return true; | |
} | |
switch (event.getAction()) { | |
case MotionEvent.ACTION_MOVE: | |
return cellIsMobile; | |
default: | |
break; | |
} | |
return false; | |
} | |
@Override | |
public void onTouchEvent(RecyclerView rv, MotionEvent event) { | |
handleMotionEvent(event); | |
} | |
}; | |
addOnItemTouchListener(itemTouchListener); | |
} | |
private void handleMotionEvent(MotionEvent event) { | |
//Timber.d(String.format("handleMotionEvent %s", event)); | |
switch (event.getAction()) { | |
case MotionEvent.ACTION_MOVE: | |
if (activePointerId == INVALID_POINTER_ID) { | |
break; | |
} | |
int pointerIndex = event.findPointerIndex(activePointerId); | |
int deltaY = (int) event.getY(pointerIndex)- downY; | |
int deltaX = (int) event.getX(pointerIndex)- downX; | |
if (cellIsMobile) { | |
hoverCellCurrentBounds.offsetTo(hoverCellOriginalBounds.left + deltaX + totalOffsetX, | |
hoverCellOriginalBounds.top + deltaY + totalOffsetY); | |
hoverCell.setBounds(hoverCellCurrentBounds); | |
invalidate(); | |
handleCellSwitch(); | |
handleMobileCellScroll(); | |
} | |
break; | |
case MotionEvent.ACTION_UP: | |
touchEventsEnded(); | |
break; | |
case MotionEvent.ACTION_CANCEL: | |
touchEventsCancelled(); | |
break; | |
case MotionEvent.ACTION_POINTER_UP: | |
/* If a multitouch event took place and the original touch dictating | |
* the movement of the hover cell has ended, then the dragging event | |
* ends and the hover cell is animated to its corresponding position | |
* in the listview. */ | |
pointerIndex = (event.getAction() & MotionEvent.ACTION_POINTER_INDEX_MASK) >> | |
MotionEvent.ACTION_POINTER_INDEX_SHIFT; | |
final int pointerId = event.getPointerId(pointerIndex); | |
if (pointerId == activePointerId) { | |
touchEventsEnded(); | |
} | |
break; | |
default: | |
break; | |
} | |
} | |
/** | |
* Creates the hover cell with the appropriate bitmap and of appropriate | |
* size. The hover cell's BitmapDrawable is drawn on top of the bitmap every | |
* single time an invalidate call is made. | |
*/ | |
private BitmapDrawable getAndAddHoverView(View v) { | |
int w = v.getWidth() + SCALE_FACTOR; | |
int h = v.getHeight() + SCALE_FACTOR; | |
int top = v.getTop(); | |
int left = v.getLeft(); | |
Bitmap b = getBitmapFromView(v); | |
Bitmap scaledBitmap = Bitmap.createScaledBitmap(b, w, h, false); | |
BitmapDrawable drawable = new BitmapDrawable(getResources(), scaledBitmap); | |
hoverCellOriginalBounds = new Rect(left, top, left + w, top + h); | |
hoverCellCurrentBounds = new Rect(hoverCellOriginalBounds); | |
drawable.setBounds(hoverCellCurrentBounds); | |
return drawable; | |
} | |
/** | |
* Draws a black border over the screenshot of the view passed in. | |
*/ | |
private Bitmap getBitmapWithBorder(View v) { | |
Bitmap bitmap = getBitmapFromView(v); | |
Canvas can = new Canvas(bitmap); | |
can.drawBitmap(bitmap, 0, 0, null); | |
return bitmap; | |
} | |
/** | |
* Returns a bitmap showing a screenshot of the view passed in | |
*/ | |
private Bitmap getBitmapFromView(View v) { | |
Bitmap bitmap = Bitmap.createBitmap(v.getWidth(), v.getHeight(), Bitmap.Config.ARGB_8888); | |
Canvas canvas = new Canvas(bitmap); | |
v.draw(canvas); | |
return bitmap; | |
} | |
/** | |
* dispatchDraw gets invoked when all the child views are about to be drawn. | |
* By overriding this method, the hover cell (BitmapDrawable) can be drawn | |
* over the recyclerviews's items whenever the recyclerviews is redrawn. | |
*/ | |
@Override | |
protected void dispatchDraw(@NonNull Canvas canvas) { | |
super.dispatchDraw(canvas); | |
if (hoverCell != null) { | |
hoverCell.draw(canvas); | |
} | |
} | |
@Override | |
public void setAdapter(Adapter adapter) { | |
if (!(adapter instanceof ReorderAdapter) && !adapter.hasStableIds()) { | |
throw new IllegalStateException("ReorderRecyclerView only works with ReorderAdapter and must have stable ids!"); | |
} | |
super.setAdapter(adapter); | |
} | |
/** | |
* This method determines whether the hover cell has been shifted far enough | |
* to invoke a cell swap. If so, then the respective cell swap candidate is | |
* determined and the data set is changed. Upon posting a notification of the | |
* data set change, a layout is invoked to place the cells in the right place. | |
* | |
* A valid swap will be when both views are different and the hover view has | |
* crossed the middle of the childUnderView | |
* | |
* The hover view will be 40 pixels wider and 40 pixels higher so we needs to account for this | |
*/ | |
private void handleCellSwitch() { | |
ViewHolder mobileViewHolder = findViewHolderForItemId(mobileItemId); | |
View mobileView = mobileViewHolder != null ? mobileViewHolder.itemView : null; | |
if (mobileView != null) { | |
View childViewUnder = null; | |
int originalItem = getChildPosition(mobileView); | |
int leftRightBound = hoverCellCurrentBounds.left + (SCALE_FACTOR / 2); | |
int topBottomBound = hoverCellCurrentBounds.bottom - (SCALE_FACTOR / 2); | |
childViewUnder = findChildViewUnder(leftRightBound, topBottomBound); | |
// check if bottom left corner overlaps middle of child | |
if (!isSameView(childViewUnder, mobileView)) { | |
int childCenterX = getCenterX(childViewUnder); | |
int childCenterY = getCenterY(childViewUnder); | |
if (childCenterX > leftRightBound && childCenterY < topBottomBound) { | |
swapElements(originalItem, getChildPosition(childViewUnder)); | |
return; | |
} | |
} | |
// check if top left corner overlaps middle of child | |
topBottomBound = hoverCellCurrentBounds.top + (SCALE_FACTOR / 2); | |
childViewUnder = findChildViewUnder(leftRightBound, topBottomBound); | |
if (!isSameView(childViewUnder, mobileView)) { | |
int childCenterX = getCenterX(childViewUnder); | |
int childCenterY = getCenterY(childViewUnder); | |
if (childCenterX > leftRightBound && childCenterY > topBottomBound) { | |
swapElements(originalItem, getChildPosition(childViewUnder)); | |
return; | |
} | |
} | |
leftRightBound = hoverCellCurrentBounds.right - (SCALE_FACTOR / 2); | |
childViewUnder = findChildViewUnder(leftRightBound, topBottomBound); | |
// check if top right overlaps middle of child | |
if (!isSameView(childViewUnder, mobileView)) { | |
int childCenterX = getCenterX(childViewUnder); | |
int childCenterY = getCenterY(childViewUnder); | |
if (childCenterX < leftRightBound && childCenterY > topBottomBound) { | |
swapElements(originalItem, getChildPosition(childViewUnder)); | |
} | |
} | |
topBottomBound = hoverCellCurrentBounds.bottom - (SCALE_FACTOR / 2); | |
childViewUnder = findChildViewUnder(leftRightBound, topBottomBound); | |
// chick if bottom right overlaps middle of child | |
if (!isSameView(childViewUnder, mobileView)) { | |
int childCenterX = getCenterX(childViewUnder); | |
int childCenterY = getCenterY(childViewUnder); | |
if (childCenterX < leftRightBound && childCenterY < topBottomBound) { | |
swapElements(originalItem, getChildPosition(childViewUnder)); | |
return; | |
} | |
} | |
} | |
} | |
private int getCenterX(View view) { | |
return (int) (view.getX() + view.getWidth() / 2); | |
} | |
private int getCenterY(View view) { | |
return (int) (view.getY() + view.getHeight() / 2); | |
} | |
private boolean isSameView(View view1, View view2) { | |
if (view1 == null || view2 == null) { | |
return true; | |
} | |
return getChildPosition(view1) == getChildPosition(view2); | |
} | |
/** | |
* Swaps the the elements with the given indices. | |
* | |
* @param fromIndex the from-element index | |
* @param toIndex the to-element index | |
*/ | |
private void swapElements(int fromIndex, int toIndex) { | |
// stop elements from swapping back and forth really fast in while swap is taking place | |
if (allowSwaps) { | |
allowSwaps = false; | |
Handler handler = new Handler(); | |
handler.postDelayed(new Runnable() { | |
@Override | |
public void run() { | |
allowSwaps = true; | |
} | |
}, getItemAnimator().getMoveDuration()); | |
Timber.i(String.format("Swapping %d with %d", fromIndex, toIndex)); | |
ReorderAdapter adapter = (ReorderAdapter) getAdapter(); | |
adapter.swapElements(fromIndex, toIndex); | |
adapter.notifyItemMoved(fromIndex, toIndex); | |
} | |
} | |
/** | |
* Resets all the appropriate fields to a default state while also animating | |
* the hover cell back to its correct location. | |
*/ | |
private void touchEventsEnded() { | |
ViewHolder viewHolderForItemId = findViewHolderForItemId(mobileItemId); | |
if (viewHolderForItemId == null) { | |
return; | |
} | |
final View mobileView = viewHolderForItemId.itemView; | |
if (cellIsMobile || usWaitingForScrollFinish) { | |
cellIsMobile = false; | |
usWaitingForScrollFinish = false; | |
activePointerId = INVALID_POINTER_ID; | |
// If the autoscroller has not completed scrolling, we need to wait for it to | |
// finish in order to determine the final location of where the hover cell | |
// should be animated to. | |
if (getScrollState() != SCROLL_STATE_IDLE) { | |
usWaitingForScrollFinish = true; | |
return; | |
} | |
hoverCellCurrentBounds.offsetTo(mobileView.getLeft(), mobileView.getTop()); | |
ObjectAnimator hoverViewAnimator = ObjectAnimator.ofObject(hoverCell, "bounds", | |
sBoundEvaluator, hoverCellCurrentBounds); | |
hoverViewAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { | |
@Override | |
public void onAnimationUpdate(ValueAnimator valueAnimator) { | |
invalidate(); | |
} | |
}); | |
hoverViewAnimator.addListener(new AnimatorListenerAdapter() { | |
@Override | |
public void onAnimationStart(Animator animation) { | |
setEnabled(false); | |
} | |
@Override | |
public void onAnimationEnd(Animator animation) { | |
mobileItemId = INVALID_ID; | |
mobileView.setVisibility(VISIBLE); | |
hoverCell = null; | |
setEnabled(true); | |
invalidate(); | |
} | |
}); | |
hoverViewAnimator.start(); | |
} else { | |
touchEventsCancelled(); | |
} | |
} | |
/** | |
* This TypeEvaluator is used to animate the BitmapDrawable back to its | |
* final location when the user lifts his finger by modifying the | |
* BitmapDrawable's bounds. | |
*/ | |
private final static TypeEvaluator<Rect> sBoundEvaluator = new TypeEvaluator<Rect>() { | |
public Rect evaluate(float fraction, Rect startValue, Rect endValue) { | |
return new Rect(interpolate(startValue.left, endValue.left, fraction), | |
interpolate(startValue.top, endValue.top, fraction), | |
interpolate(startValue.right, endValue.right, fraction), | |
interpolate(startValue.bottom, endValue.bottom, fraction)); | |
} | |
public int interpolate(int start, int end, float fraction) { | |
return (int) (start + fraction * (end - start)); | |
} | |
}; | |
/** | |
* Resets all the appropriate fields to a default state. | |
*/ | |
private void touchEventsCancelled() { | |
ViewHolder viewHolderForItemId = findViewHolderForItemId(mobileItemId); | |
if (viewHolderForItemId == null) { | |
return; | |
} | |
View mobileView = viewHolderForItemId.itemView; | |
if (cellIsMobile) { | |
mobileItemId = INVALID_ID; | |
mobileView.setVisibility(VISIBLE); | |
hoverCell = null; | |
invalidate(); | |
} | |
cellIsMobile = false; | |
activePointerId = INVALID_POINTER_ID; | |
} | |
/** | |
* Determines whether this recyclerview is in a scrolling state invoked | |
* by the fact that the hover cell is out of the bounds of the recyclerview; | |
*/ | |
private void handleMobileCellScroll() { | |
handleMobileCellScroll(hoverCellCurrentBounds); | |
} | |
/** | |
* This method is in charge of determining if the hover cell is above/below or | |
* left/right the bounds of the recyclerview. If so, the recyclerview does an appropriate | |
* upward or downward smooth scroll so as to reveal new items. | |
*/ | |
public boolean handleMobileCellScroll(Rect r) { | |
if (getLayoutManager().canScrollVertically()) { | |
int offset = computeVerticalScrollOffset(); | |
int height = getHeight(); | |
int extent = computeVerticalScrollExtent(); | |
int range = computeVerticalScrollRange(); | |
int hoverViewTop = r.top; | |
int hoverHeight = r.height(); | |
if (hoverViewTop <= 0 && offset > 0) { | |
Timber.d(String.format("scrolling vertically by %d", -smoothScrollAmountAtEdge)); | |
scrollBy(0, -smoothScrollAmountAtEdge); | |
return true; | |
} | |
if (hoverViewTop + hoverHeight >= height && (offset + extent) < range) { | |
Timber.d(String.format("scrolling vertically by %d", smoothScrollAmountAtEdge)); | |
scrollBy(0, smoothScrollAmountAtEdge); | |
return true; | |
} | |
} | |
if (getLayoutManager().canScrollHorizontally()) { | |
Timber.d("CAN SCROLL HORIZONTALLY"); | |
int offset = computeHorizontalScrollOffset(); | |
int width = getWidth(); | |
int extent = computeHorizontalScrollExtent(); | |
int range = computeHorizontalScrollRange(); | |
int hoverViewLeft = r.left; | |
int hoverWidth = r.width(); | |
if (hoverViewLeft <= 0 && offset > 0) { | |
Timber.d(String.format("scrolling horizontally by %d", -smoothScrollAmountAtEdge)); | |
scrollBy(-smoothScrollAmountAtEdge, 0); | |
return true; | |
} | |
if (hoverViewLeft + hoverWidth >= width && (offset + extent) < range) { | |
Timber.d(String.format("scrolling horizontally by %d", smoothScrollAmountAtEdge)); | |
scrollBy(smoothScrollAmountAtEdge, 0); | |
return true; | |
} | |
} | |
return false; | |
} | |
/** | |
* Special adapter that provides reorder functionality. | |
* Implementations have to provide stable ids {@link #hasStableIds()} | |
*/ | |
public static abstract class ReorderAdapter<VH extends android.support.v7.widget.RecyclerView.ViewHolder> extends Adapter<VH> { | |
/** | |
* Swap the positions of the elements with the given indices. | |
* You don't have to notify the change. | |
* This will be handled by the recyclerview. | |
* Example: | |
* <pre> | |
* {@code | |
* Object temp = cheeseList.get(fromIndex); | |
* dataList.set(fromIndex, cheeseList.get(toIndex)); | |
* dataList.set(toIndex, temp); | |
* } | |
* </pre> | |
* | |
* @param fromIndex the index | |
* @param toIndex the index | |
*/ | |
public abstract void swapElements(int fromIndex, int toIndex); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment