com.devbrackets.android.recyclerext.decoration.ReorderDecoration.java Source code

Java tutorial

Introduction

Here is the source code for com.devbrackets.android.recyclerext.decoration.ReorderDecoration.java

Source

/*
 * Copyright (C) 2016 Brian Wernick
 *
 * 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.
 */

package com.devbrackets.android.recyclerext.decoration;

import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.PointF;
import android.graphics.Rect;
import android.graphics.drawable.BitmapDrawable;
import android.support.annotation.IdRes;
import android.support.annotation.Nullable;
import android.support.v4.view.animation.FastOutSlowInInterpolator;
import android.support.v7.widget.RecyclerView;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.Animation;
import android.view.animation.TranslateAnimation;

/**
 * An ItemDecoration that performs the functionality to show the reordering of
 * list items without any space between items.
 */
@SuppressWarnings("unused")
public class ReorderDecoration extends RecyclerView.ItemDecoration implements RecyclerView.OnItemTouchListener {
    public static final int NO_POSITION = -1;
    public static final int INVALID_RESOURCE_ID = 0;

    private static final float MAX_EDGE_DETECTION_THRESHOLD = 0.5f;
    private static final float DEFAULT_EDGE_SCROLL_SPEED = 0.5f;
    private static final float DEFAULT_EDGE_DETECTION_THRESHOLD = 0.01f;

    private enum DragState {
        DRAGGING, ENDED
    }

    public enum LayoutOrientation {
        VERTICAL, HORIZONTAL
    }

    public interface ReorderListener {
        /**
         * Called when the user drag event ends, informing the listener of the changed position
         *
         * @param originalPosition The position the dragged view started at
         * @param newPosition The position the dragged view should be saved as
         */
        void onItemReordered(int originalPosition, int newPosition);

        /**
         * Called when the animation for the view position has finished.  This should be used for
         * actually updating the backing data structure (e.g. calling swap on a {@link com.devbrackets.android.recyclerext.adapter.RecyclerCursorAdapter})
         *
         * @param originalPosition The position the dragged view started at
         * @param newPosition The position the dragged view should be saved as
         */
        void onItemPostReordered(int originalPosition, int newPosition);
    }

    private RecyclerView recyclerView;
    private DragState dragState = DragState.ENDED;

    private LayoutOrientation orientation = LayoutOrientation.VERTICAL;
    private boolean edgeScrollingEnabled = true;

    private float edgeDetectionThreshold = DEFAULT_EDGE_DETECTION_THRESHOLD;
    private float edgeScrollSpeed = DEFAULT_EDGE_SCROLL_SPEED;

    @Nullable
    private PointF fingerOffset;
    private BitmapDrawable dragItem;

    private int selectedDragItemPosition = NO_POSITION;
    private int selectedDragItemNewPosition = NO_POSITION;
    private Rect floatingItemStartingBounds;
    private Rect floatingItemBounds;

    private int newViewStart;

    private PointF eventPosition = new PointF(0, 0);
    private PointF floatingItemCenter = new PointF(0, 0);

    private int dragHandleId = INVALID_RESOURCE_ID;
    private ReorderListener reorderListener;

    @Nullable
    private SmoothFinishAnimationListener smoothFinishAnimationListener;

    public ReorderDecoration(RecyclerView recyclerView) {
        this.recyclerView = recyclerView;
    }

    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        if (dragItem != null) {
            dragItem.draw(c);
        }
    }

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);

        if (dragState == DragState.ENDED) {
            finishReorder(view);
            return;
        }

        int itemPosition = recyclerView.getChildAdapterPosition(view);
        if (itemPosition == selectedDragItemPosition) {
            view.setVisibility(View.INVISIBLE);
            return;
        }

        //Make sure the view is visible
        view.setVisibility(View.VISIBLE);

        //Calculate the new offsets
        updateFloatingItemCenter();
        setVerticalOffsets(view, itemPosition, floatingItemCenter, outRect);
        setHorizontalOffsets(view, itemPosition, floatingItemCenter, outRect);
    }

    /**
     * This will determine two things.
     * 1. If we need to handle the touch event
     * 2. If reordering needs to start due to dragHandle being clicked
     */
    @Override
    public boolean onInterceptTouchEvent(RecyclerView recyclerView, MotionEvent event) {
        if (dragState == DragState.DRAGGING) {
            return true;
        }

        if (dragHandleId == INVALID_RESOURCE_ID) {
            return false;
        }

        View itemView = recyclerView.findChildViewUnder(event.getX(), event.getY());
        if (itemView == null) {
            return false;
        }

        View handleView = itemView.findViewById(dragHandleId);
        if (handleView == null || handleView.getVisibility() != View.VISIBLE) {
            return false;
        }

        int[] handlePosition = new int[2];
        handleView.getLocationOnScreen(handlePosition);

        //Determine if the MotionEvent is inside the handle
        if ((event.getRawX() >= handlePosition[0] && event.getRawX() <= handlePosition[0] + handleView.getWidth())
                && (event.getRawY() >= handlePosition[1]
                        && event.getRawY() <= handlePosition[1] + handleView.getHeight())) {
            startReorder(itemView, event);
            return true;
        }

        return false;
    }

    @Override
    public void onTouchEvent(RecyclerView recyclerView, MotionEvent event) {
        if (dragState != DragState.DRAGGING) {
            return;
        }

        //Makes sure to perform the end reorder operations...
        switch (event.getAction()) {
        case MotionEvent.ACTION_UP:
            if (selectedDragItemPosition != NO_POSITION) {
                if (reorderListener != null) {
                    selectedDragItemNewPosition = calculateNewPosition();
                    reorderListener.onItemReordered(selectedDragItemPosition, selectedDragItemNewPosition);
                }
            }
            //Purposefully fall through

        case MotionEvent.ACTION_CANCEL:
            endReorder();
            return;
        }

        //Finds the new location
        eventPosition.x = event.getX();
        eventPosition.y = event.getY();

        //Updates the floating views bounds
        if (dragItem != null) {
            updateFloatingItemCenter();

            //Make sure the dragItem bounds are correct
            updateVerticalBounds(eventPosition, floatingItemCenter);
            updateHorizontalBounds(eventPosition, floatingItemCenter);
            dragItem.setBounds(floatingItemBounds);
        }

        //Perform the edge scrolling if necessary
        performVerticalEdgeScroll(eventPosition);
        performHorizontalEdgeScroll(eventPosition);

        recyclerView.invalidateItemDecorations();
    }

    @Override
    public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
        // Purposefully left blank
    }

    /**
     * Sets the listener to be informed of reorder events
     *
     * @param listener The ReorderListener to use
     */
    public void setReorderListener(ReorderListener listener) {
        reorderListener = listener;
        smoothFinishAnimationListener = new SmoothFinishAnimationListener(reorderListener);
    }

    /**
     * Sets the id for the view that will act as an immediate drag handle.
     * This means that once the view has been touched that the drag will be
     * started.
     *
     * @param handleId The Resource ID for the drag handle or {@link #INVALID_RESOURCE_ID}
     */
    public void setDragHandleId(@IdRes int handleId) {
        dragHandleId = handleId;
    }

    /**
     * Sets whether the items should start scrolling once the view being reordered
     * hits the edge of the containing view.
     *
     * @param enabled True to scroll once the view being reordered hits the edge
     */
    public void setEdgeScrollingEnabled(boolean enabled) {
        edgeScrollingEnabled = enabled;
    }

    /**
     * Retrieves whether the items should start scrolling once the view being reordered
     * hits the edge of the containing view.
     *
     * @return True if edge scrolling is enabled
     */
    public boolean isEdgeScrollingEnabled() {
        return edgeScrollingEnabled;
    }

    /**
     * Sets the percent amount in relation to the size of the recyclerView
     * for the edge scrolling to use.
     *
     * @param speed The percent amount [0.0 - 1.0]
     */
    public void setEdgeScrollSpeed(float speed) {
        if (edgeScrollSpeed < 0 || edgeScrollSpeed > 1) {
            return;
        }

        edgeScrollSpeed = speed;
    }

    /**
     * Retrieves the edge scroll speed
     *
     * @return [default: {@value #DEFAULT_EDGE_SCROLL_SPEED}]
     */
    public float getEdgeScrollSpeed() {
        return edgeScrollSpeed;
    }

    /**
     * Sets the percent threshold at the edges of the recyclerView to start the
     * edge scrolling.  This threshold can be between 0 (no edge) and {@value #MAX_EDGE_DETECTION_THRESHOLD}
     * (half of the recyclerView)
     *
     * @param threshold The edge scrolling threshold [0.0 - {@value #MAX_EDGE_DETECTION_THRESHOLD}]
     */
    public void setEdgeThreshold(float threshold) {
        if (threshold < 0 || threshold > MAX_EDGE_DETECTION_THRESHOLD) {
            return;
        }

        edgeDetectionThreshold = threshold;
    }

    /**
     * Retrieves the edge threshold for the edge scrolling.
     *
     * @return The current edge threshold [0.0 - {@value #MAX_EDGE_DETECTION_THRESHOLD}] [default: {@value #DEFAULT_EDGE_DETECTION_THRESHOLD}]
     */
    public float getEdgeThreshold() {
        return edgeDetectionThreshold;
    }

    /**
     * Sets the orientation of the current layout.  This will aid in the calculations for
     * edgeScrolling {@link #setEdgeScrollingEnabled(boolean)} and determining the new position
     * in the list on {@link #endReorder()}
     *
     * @param orientation The layouts orientation
     */
    public void setOrientation(LayoutOrientation orientation) {
        this.orientation = orientation;
    }

    /**
     * Retrieves the current orientation to use for edgeScrolling and position calculations.
     *
     * @return The current orientation [default: {@link LayoutOrientation#VERTICAL}]
     */
    public LayoutOrientation getOrientation() {
        return orientation;
    }

    /**
     * Manually starts the reorder process for the specified view.  This should not be used if the {@link #setDragHandleId(int)} is
     * set and should control the reordering.
     *
     * @param view The View to start reordering
     * @param startMotionEvent The MotionEvent that starts the reorder
     */
    public void startReorder(View view, @Nullable MotionEvent startMotionEvent) {
        if (dragState == DragState.DRAGGING) {
            return;
        }

        if (startMotionEvent != null) {
            fingerOffset = new PointF(startMotionEvent.getRawX(), startMotionEvent.getRawY());

            int[] rawViewLoc = new int[2];
            view.getLocationOnScreen(rawViewLoc);
            fingerOffset.x = rawViewLoc[0] - fingerOffset.x;
            fingerOffset.y = rawViewLoc[1] - fingerOffset.y;
        }

        dragState = DragState.DRAGGING;
        dragItem = createDragBitmap(view);

        selectedDragItemPosition = recyclerView.getChildAdapterPosition(view);
    }

    /**
     * Ends the reorder process.  This should only be called if {@link #startReorder(View, MotionEvent)} has been
     * manually called.
     */
    public void endReorder() {
        if (dragState != DragState.DRAGGING) {
            return;
        }

        dragState = DragState.ENDED;
        fingerOffset = null;
        dragItem = null;

        selectedDragItemPosition = NO_POSITION;
        recyclerView.invalidateItemDecorations();
    }

    /**
     * Calculates the position the item should have when it is dropped.
     *
     * @return The new position for the item
     */
    public int calculateNewPosition() {
        int itemsOnScreen = recyclerView.getLayoutManager().getChildCount();
        updateFloatingItemCenter();

        int before = 0;
        int pos = 0;
        int after = Integer.MAX_VALUE;
        for (int screenPosition = 0; screenPosition < itemsOnScreen; screenPosition++) {

            //Grabs the view at screenPosition
            View view = recyclerView.getLayoutManager().getChildAt(screenPosition);
            if (view.getVisibility() != View.VISIBLE) {
                continue;
            }

            //Makes sure we don't compare to itself
            int itemPos = recyclerView.getChildAdapterPosition(view);
            if (itemPos == selectedDragItemPosition) {
                continue;
            }

            //Performs the Vertical position calculations
            if (orientation == LayoutOrientation.VERTICAL) {
                float viewMiddleY = view.getTop() + (view.getHeight() / 2);
                if (floatingItemCenter.y > viewMiddleY && itemPos > before) {
                    before = itemPos;
                    pos = screenPosition;
                } else if (floatingItemCenter.y <= viewMiddleY && itemPos < after) {
                    after = itemPos;
                    pos = screenPosition;
                }
            }

            //Performs the Horizontal position calculations
            if (orientation == LayoutOrientation.HORIZONTAL) {
                float viewMiddleX = view.getLeft() + (view.getWidth() / 2);
                if (floatingItemCenter.x > viewMiddleX && itemPos > before) {
                    before = itemPos;
                    pos = screenPosition;
                } else if (floatingItemCenter.x <= viewMiddleX && itemPos < after) {
                    after = itemPos;
                    pos = screenPosition;
                }
            }
        }

        int newPosition;
        if (after != Integer.MAX_VALUE) {
            if (after < selectedDragItemPosition) {
                newPosition = after;
                updateNewViewStart(pos, true);
            } else {
                newPosition = after - 1;
                updateNewViewStart(pos - 1, false);
            }
        } else {
            if (before < selectedDragItemPosition) {
                before++;
                pos++;
            }

            newPosition = before;
            updateNewViewStart(pos, false);
        }

        return newPosition;
    }

    /**
     * Updates the stored position for the start of the view.  This will be the
     * top when Vertical and left when Horizontal.
     *
     * @param childPosition The position of the view in the RecyclerView
     * @param draggedUp True if the view has been moved up or to the left
     */
    private void updateNewViewStart(int childPosition, boolean draggedUp) {
        View view = recyclerView.getLayoutManager().getChildAt(childPosition);
        if (view == null) {
            return;
        }

        int start = orientation == LayoutOrientation.VERTICAL ? view.getTop() : view.getLeft();
        int viewDimen = orientation == LayoutOrientation.VERTICAL ? view.getHeight() : view.getWidth();
        viewDimen *= draggedUp ? -1 : 1;

        newViewStart = start + (view.getVisibility() == View.VISIBLE ? viewDimen : 0);
    }

    /**
     * Retrieves the new center for the bitmap representing the item being dragged
     */
    private void updateFloatingItemCenter() {
        floatingItemCenter.x = floatingItemBounds.left + (floatingItemStartingBounds.width() / 2);
        floatingItemCenter.y = floatingItemBounds.top + (floatingItemStartingBounds.height() / 2);
    }

    /**
     * Updates the vertical view offsets if the dragging view has been moved around the <code>view</code>.
     * This happens when the dragging view starts above the <code>view</code> and has been dragged
     * below it, or vice versa.
     *
     * @param view The view to compare with the dragging items current and original positions
     * @param itemPosition The position for the <code>view</code>
     * @param middle The center of the floating item
     * @param outRect The {@link Rect} to update the position in
     */
    private void setVerticalOffsets(View view, int itemPosition, PointF middle, Rect outRect) {
        if (orientation == LayoutOrientation.HORIZONTAL) {
            return;
        }

        if (itemPosition > selectedDragItemPosition && view.getTop() < middle.y) {
            float amountUp = (middle.y - view.getTop()) / (float) view.getHeight();
            if (amountUp > 1) {
                amountUp = 1;
            }

            outRect.top = -(int) (floatingItemBounds.height() * amountUp);
            outRect.bottom = (int) (floatingItemBounds.height() * amountUp);
        } else if ((itemPosition < selectedDragItemPosition) && (view.getBottom() > middle.y)) {
            float amountDown = ((float) view.getBottom() - middle.y) / (float) view.getHeight();
            if (amountDown > 1) {
                amountDown = 1;
            }

            outRect.top = (int) (floatingItemBounds.height() * amountDown);
            outRect.bottom = -(int) (floatingItemBounds.height() * amountDown);
        }
    }

    /**
     * Updates the horizontal view offsets if the dragging view has been moved around the <code>view</code>.
     * This happens when the dragging view starts before the <code>view</code> and has been dragged
     * after it, or vice versa.
     *
     * @param view The view to compare with the dragging items current and original positions
     * @param itemPosition The position for the <code>view</code>
     * @param middle The center of the floating item
     * @param outRect The {@link Rect} to update the position in
     */
    private void setHorizontalOffsets(View view, int itemPosition, PointF middle, Rect outRect) {
        if (orientation == LayoutOrientation.VERTICAL) {
            return;
        }

        if (itemPosition > selectedDragItemPosition && view.getLeft() < middle.x) {
            float amountRight = (middle.x - view.getLeft()) / (float) view.getWidth();
            if (amountRight > 1) {
                amountRight = 1;
            }

            outRect.left = -(int) (floatingItemBounds.width() * amountRight);
            outRect.right = (int) (floatingItemBounds.width() * amountRight);
        } else if ((itemPosition < selectedDragItemPosition) && (view.getRight() > middle.x)) {
            float amountLeft = ((float) view.getRight() - middle.x) / (float) view.getWidth();
            if (amountLeft > 1) {
                amountLeft = 1;
            }

            outRect.left = (int) (floatingItemBounds.width() * amountLeft);
            outRect.right = -(int) (floatingItemBounds.width() * amountLeft);
        }
    }

    /**
     * Performs the functionality to detect and initiate the scrolling of vertical
     * lists when the view being dragged has reached an end of the containing
     * {@link RecyclerView}
     *
     * @param fingerPosition The current position for the dragging finger
     */
    private void performVerticalEdgeScroll(PointF fingerPosition) {
        if (!edgeScrollingEnabled || orientation == LayoutOrientation.HORIZONTAL) {
            return;
        }

        float scrollAmount = 0;
        if (fingerPosition.y > (recyclerView.getHeight() * (1 - edgeDetectionThreshold))) {
            scrollAmount = (fingerPosition.y - (recyclerView.getHeight() * (1 - edgeDetectionThreshold)));
        } else if (fingerPosition.y < (recyclerView.getHeight() * edgeDetectionThreshold)) {
            scrollAmount = (fingerPosition.y - (recyclerView.getHeight() * edgeDetectionThreshold));
        }

        scrollAmount *= edgeScrollSpeed;

        recyclerView.scrollBy(0, (int) scrollAmount);
    }

    /**
     * Performs the functionality to detect and initiate the scrolling of horizontal
     * lists when the view being dragged has reached an end of the containing
     * {@link RecyclerView}
     *
     * @param fingerPosition The current position for the dragging finger
     */
    private void performHorizontalEdgeScroll(PointF fingerPosition) {
        if (!edgeScrollingEnabled || orientation == LayoutOrientation.VERTICAL) {
            return;
        }

        float scrollAmount = 0;
        if (fingerPosition.x > (recyclerView.getWidth() * (1 - edgeDetectionThreshold))) {
            scrollAmount = (fingerPosition.x - (recyclerView.getWidth() * (1 - edgeDetectionThreshold)));
        } else if (fingerPosition.x < (recyclerView.getWidth() * edgeDetectionThreshold)) {
            scrollAmount = (fingerPosition.x - (recyclerView.getWidth() * edgeDetectionThreshold));
        }

        scrollAmount *= edgeScrollSpeed;

        recyclerView.scrollBy((int) scrollAmount, 0);
    }

    /**
     * Updates the vertical position for the floating bitmap that represents the
     * view being dragged.
     *
     * @param fingerPosition The current position of the dragging finger
     * @param viewMiddle The center of the view being dragged
     */
    private void updateVerticalBounds(PointF fingerPosition, PointF viewMiddle) {
        if (orientation == LayoutOrientation.HORIZONTAL) {
            return;
        }

        floatingItemBounds.top = (int) fingerPosition.y;
        if (fingerOffset != null) {
            floatingItemBounds.top += fingerOffset.y;
        }

        if (floatingItemBounds.top < -viewMiddle.y) {
            floatingItemBounds.top = -(int) viewMiddle.y;
        }

        floatingItemBounds.bottom = floatingItemBounds.top + floatingItemStartingBounds.height();
    }

    /**
     * Updates the horizontal position for the floating bitmap that represents the
     * view being dragged.
     *
     * @param fingerPosition The current position of the dragging finger
     * @param viewMiddle The center of the view being dragged
     */
    private void updateHorizontalBounds(PointF fingerPosition, PointF viewMiddle) {
        if (orientation == LayoutOrientation.VERTICAL) {
            return;
        }

        floatingItemBounds.left = (int) fingerPosition.x;
        if (fingerOffset != null) {
            floatingItemBounds.left += fingerOffset.x;
        }

        if (floatingItemBounds.left < -viewMiddle.x) {
            floatingItemBounds.left = -(int) viewMiddle.x;
        }

        floatingItemBounds.right = floatingItemBounds.left + floatingItemStartingBounds.width();
    }

    /**
     * Generates the Bitmap that will be used to represent the view being dragged across the screen
     *
     * @param view The view to create the drag bitmap from
     * @return The bitmap representing the drag view
     */
    private BitmapDrawable createDragBitmap(View view) {
        floatingItemStartingBounds = new Rect(view.getLeft(), view.getTop(), view.getRight(), view.getBottom());
        floatingItemBounds = new Rect(floatingItemStartingBounds);

        Bitmap bitmap = Bitmap.createBitmap(floatingItemStartingBounds.width(), floatingItemStartingBounds.height(),
                Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(bitmap);
        view.draw(canvas);

        BitmapDrawable retDrawable = new BitmapDrawable(view.getResources(), bitmap);
        retDrawable.setBounds(floatingItemBounds);

        return retDrawable;
    }

    /**
     * Animates the dragged views position to the final resting position
     *
     * @param view The view to animate
     */
    private void finishReorder(View view) {
        if (smoothFinishAnimationListener != null) {
            smoothFinishAnimationListener.setPositions(selectedDragItemPosition, selectedDragItemNewPosition);
        }

        selectedDragItemPosition = NO_POSITION;
        view.setVisibility(View.VISIBLE);

        //Performs the ending animation
        if (recyclerView.getChildAdapterPosition(view) == selectedDragItemNewPosition) {
            selectedDragItemNewPosition = NO_POSITION;
            int startYDelta = orientation == LayoutOrientation.VERTICAL ? floatingItemBounds.top - newViewStart : 0;
            int startXDelta = orientation == LayoutOrientation.HORIZONTAL ? floatingItemBounds.left - newViewStart
                    : 0;

            SmoothFinishAnimation anim = new SmoothFinishAnimation(startYDelta, startXDelta,
                    smoothFinishAnimationListener);
            view.startAnimation(anim);
        }
    }

    /**
     * Used to animate the final position for the dragged view so that it doesn't pop when
     * dragged to the bottom of the list.
     */
    private static class SmoothFinishAnimation extends TranslateAnimation {
        private static final int DURATION = 100; //milliseconds

        public SmoothFinishAnimation(int startYDelta, int startXDelta, AnimationListener listener) {
            super(startXDelta, 0, startYDelta, 0);
            setAnimationListener(listener);
            setup();
        }

        private void setup() {
            setDuration(DURATION);
            setInterpolator(new FastOutSlowInInterpolator());
        }
    }

    /**
     * Listens to the {@link com.devbrackets.android.recyclerext.decoration.ReorderDecoration.SmoothFinishAnimation}
     * and properly informs the {@link #reorderListener} when the animation is complete
     */
    private static class SmoothFinishAnimationListener implements Animation.AnimationListener {
        private int startPosition;
        private int endPosition;

        @Nullable
        private ReorderListener listener;

        public SmoothFinishAnimationListener(@Nullable ReorderListener listener) {
            this.listener = listener;
        }

        public void setPositions(int startPosition, int endPosition) {
            this.startPosition = startPosition;
            this.endPosition = endPosition;
        }

        @Override
        public void onAnimationStart(Animation animation) {
            //Purposefully left blank
        }

        @Override
        public void onAnimationEnd(Animation animation) {
            if (listener != null) {
                listener.onItemPostReordered(startPosition, endPosition);
            }
        }

        @Override
        public void onAnimationRepeat(Animation animation) {
            //Purposefully left blank
        }
    }
}