RecyclerView 梳理:點選&長按事件、分割線、拖曳排序、滑動刪除

小鴻洋發表於2018-07-19

RecyclerView 梳理:點選&長按事件、分割線、拖曳排序、滑動刪除

這次主要是把 RecyclerView 比較常用的基本的點,在這裡集中整理一下。從這篇文章主要梳理以下幾點:

  • 優雅的實現:item 點選事件 & item 長點選事件
  • RecyclerView 新增 divider 的標準姿勢
  • RecyclerView 實現 item 的拖曳排序和滑動刪除
  • 拖曳排序時,限制首個 item 固定的實現

先看一下最終的效果圖:

swipe and drag
drag

自從 RecyclerView 釋出以來,由於其高度的可互動性被廣泛使用。相信大家肯定對它的使用方法已經非常熟練了,今天主要是為大家總結一下較正常用法更加優雅的方式。

如果你想再回顧一下 RecyclerView 的基本使用方法,推薦鴻洋的這篇文章:
Android RecyclerView 使用完全解析 體驗藝術般的控制元件

優雅的實現:item 點選事件 & item 長點選事件

使用方式

RecyclerView 的 api 雖然沒有提供 onItemClickListener 但是提供了 addOnItemTouchListener() 方法,既然可以新增觸控監聽,那麼我們完全可以獲取觸控手勢來識別點選事件,然後通過觸控座標來判斷點選的是哪一個item。

mRecyclerView.addOnItemTouchListener(new OnRecyclerItemClickListener(mRecyclerView) {
            @Override
            public void onItemClick(RecyclerView.ViewHolder viewHolder) {
                //TODO item 點選事件
            }

            @Override
            public void onLongClick(RecyclerView.ViewHolder viewHolder) {
                //TODO item 長按事件
            }
        });
複製程式碼

其中 OnRecyclerItemClickListener 是自定義的一個觸控監聽器,程式碼如下:

public abstract class OnRecyclerItemClickListener implements RecyclerView.OnItemTouchListener{
    private GestureDetectorCompat mGestureDetectorCompat;//手勢探測器
    private RecyclerView mRecyclerView;

    public OnRecyclerItemClickListener(RecyclerView recyclerView) {
        mRecyclerView = recyclerView;
        mGestureDetectorCompat = new GestureDetectorCompat(mRecyclerView.getContext(),
                new ItemTouchHelperGestureListener());
    }

    @Override
    public boolean onInterceptTouchEvent(RecyclerView rv, MotionEvent e) {
        mGestureDetectorCompat.onTouchEvent(e);
        return false;
    }

    @Override
    public void onTouchEvent(RecyclerView rv, MotionEvent e) {
        mGestureDetectorCompat.onTouchEvent(e);
    }

    @Override
    public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) {
    }

    public abstract void onItemClick(RecyclerView.ViewHolder viewHolder);
    public abstract void onLongClick(RecyclerView.ViewHolder viewHolder);
}
複製程式碼

GestureDetectorCompat 中傳入了一個 ItemTouchHelperGestureListener,程式碼如下:

private class ItemTouchHelperGestureListener extends GestureDetector.SimpleOnGestureListener{
			//一次單獨的輕觸抬起手指操作,就是普通的點選事件
	        @Override
	        public boolean onSingleTapUp(MotionEvent e) {
	            View childViewUnder = mRecyclerView.findChildViewUnder(e.getX(), e.getY());
	            if (childViewUnder != null) {
	                RecyclerView.ViewHolder childViewHolder = mRecyclerView.getChildViewHolder(childViewUnder);
	                onItemClick(childViewHolder);
	            }
	            return true;
	        }
	
			//長按螢幕超過一定時長,就會觸發,就是長按事件
	        @Override
	        public void onLongPress(MotionEvent e) {
	            View childViewUnder = mRecyclerView.findChildViewUnder(e.getX(), e.getY());
	            if (childViewUnder != null) {
	                RecyclerView.ViewHolder childViewHolder = mRecyclerView.getChildViewHolder(childViewUnder);
	                onLongClick(childViewHolder);
	            }
	        }
	    }
複製程式碼

原理分析

上面的程式碼很簡單沒什麼複雜的地方,就是通過一個手勢探測器 GestureDetectorCompat 來探測螢幕事件,然後通過手勢監聽器 SimpleOnGestureListener 來識別手勢事件的種類,然後呼叫我們設定的對應的回撥方法。這裡值得說的是:當獲取到了 RecyclerView 的點選事件和觸控事件資料 MotionEvent,那麼如何才能知道點選的是哪一個 item 呢?

RecyclerView已經為我們提供了這樣的方法:findChildViewUnder()

我們可以通過這個方法獲得點選的 item ,同時我們呼叫 RecyclerView 的另一個方法 getChildViewHolder(),可以獲得該 item 的 ViewHolder,最後再回撥我們定義的虛方法 onItemClick() 就ok了,這樣我們就可以在外部實現該方法來獲得 item 的點選事件了。

RecyclerView 新增 divider 的標準姿勢

當你想給條目間新增 divider 時,你可能自然而然的去嘗試這種方式:

<android.support.v7.widget.RecyclerView
    android:divider="#ffff0000"
    android:dividerHeight="10dp"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
複製程式碼

其實 RecyclerView 是沒有這兩個屬性的,就算你寫上也不會有任何效果。
當然你還可以通過給 item 的最外層佈局設定一個 margin 值,甚至你還可以專門在 item 佈局中的適當地方新增一個高度/寬度為 1 的帶背景的 View 作為 divider,這兩種方法呢,確實有效果,但是不夠優雅,有時還可能帶來一些想不到的問題。

其實官方還是為我們提供了為 RecyclerView 新增分割線的方式的,那就是方法: mRecyclerView.addItemDecoration() 。該方法的引數為 RecyclerView.ItemDecoration,該類為抽象類,且官方目前並沒有提供預設的實現類,我們只能自己來實現。

使用方式

列表佈局的分割線例項:

public class DividerListItemDecoration extends RecyclerView.ItemDecoration {
    private static final int[] ATTRS = new int[]{
            android.R.attr.listDivider
    };

    public static final int HORIZONTAL_LIST = LinearLayoutManager.HORIZONTAL;

    public static final int VERTICAL_LIST = LinearLayoutManager.VERTICAL;

    private Drawable mDivider;

    private int mOrientation;

    public DividerListItemDecoration(Context context, int orientation) {
        final TypedArray a = context.obtainStyledAttributes(ATTRS);
        mDivider = a.getDrawable(0);
        a.recycle();
        setOrientation(orientation);
    }

    public DividerListItemDecoration(Context context, int orientation, int drawableId) {
        mDivider = ContextCompat.getDrawable(context, drawableId);
        setOrientation(orientation);
    }

    public void setOrientation(int orientation) {
        if (orientation != HORIZONTAL_LIST && orientation != VERTICAL_LIST) {
            throw new IllegalArgumentException("invalid orientation");
        }
        mOrientation = orientation;
    }

	//畫線 > 就是畫出你想要的分割線樣式
    @Override
    public void onDraw(Canvas c, RecyclerView parent) {
        if (mOrientation == VERTICAL_LIST) {
            drawVertical(c, parent);
        } else {
            drawHorizontal(c, parent);
        }

    }


    public void drawVertical(Canvas c, RecyclerView parent) {
        final int left = parent.getPaddingLeft();
        final int right = parent.getWidth() - parent.getPaddingRight();

        final int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View child = parent.getChildAt(i);
            android.support.v7.widget.RecyclerView v = new android.support.v7.widget.RecyclerView(parent.getContext());
            final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
                    .getLayoutParams();
            final int top = child.getBottom() + params.bottomMargin;
            final int bottom = top + mDivider.getIntrinsicHeight();
            mDivider.setBounds(left, top, right, bottom);
            mDivider.draw(c);
        }
    }

    public void drawHorizontal(Canvas c, RecyclerView parent) {
        final int top = parent.getPaddingTop();
        final int bottom = parent.getHeight() - parent.getPaddingBottom();

        final int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View child = parent.getChildAt(i);
            final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
                    .getLayoutParams();
            final int left = child.getRight() + params.rightMargin;
            final int right = left + mDivider.getIntrinsicHeight();
            mDivider.setBounds(left, top, right, bottom);
            mDivider.draw(c);
        }
    }

	//設定條目周邊的偏移量
    @Override
    public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) {
        if (mOrientation == VERTICAL_LIST) {
            outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
        } else {
            outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
        }
    }
}
複製程式碼

網格佈局分割線例項:

public class DividerGridItemDecoration extends RecyclerView.ItemDecoration {

    private static final int[] ATTRS = new int[]{android.R.attr.listDivider};
    private Drawable mDivider;
    private int lineWidth = 1;

    public DividerGridItemDecoration(Context context) {
        final TypedArray a = context.obtainStyledAttributes(ATTRS);
        mDivider = a.getDrawable(0);
        a.recycle();
    }

    public DividerGridItemDecoration(int color) {
        mDivider = new ColorDrawable(color);
    }

    public DividerGridItemDecoration() {
        this(Color.parseColor("#cccccc"));
    }

    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        drawHorizontal(c, parent);
        drawVertical(c, parent);
    }

    private int getSpanCount(RecyclerView parent) {
        // 列數
        int spanCount = -1;
        RecyclerView.LayoutManager layoutManager = parent.getLayoutManager();
        if (layoutManager instanceof GridLayoutManager) {

            spanCount = ((GridLayoutManager) layoutManager).getSpanCount();
        } else if (layoutManager instanceof StaggeredGridLayoutManager) {
            spanCount = ((StaggeredGridLayoutManager) layoutManager)
                    .getSpanCount();
        }
        return spanCount;
    }

    public void drawHorizontal(Canvas c, RecyclerView parent) {
        int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View child = parent.getChildAt(i);
            final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
                    .getLayoutParams();
            final int left = child.getLeft() - params.leftMargin;
            final int right = child.getRight() + params.rightMargin
                    + lineWidth;
            final int top = child.getBottom() + params.bottomMargin;
            final int bottom = top + lineWidth;
            mDivider.setBounds(left, top, right, bottom);
            mDivider.draw(c);
        }
    }

    public void drawVertical(Canvas c, RecyclerView parent) {
        final int childCount = parent.getChildCount();
        for (int i = 0; i < childCount; i++) {
            final View child = parent.getChildAt(i);

            final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child.getLayoutParams();
            final int top = child.getTop() - params.topMargin;
            final int bottom = child.getBottom() + params.bottomMargin;
            final int left = child.getRight() + params.rightMargin;
            final int right = left + lineWidth;

            mDivider.setBounds(left, top, right, bottom);
            mDivider.draw(c);
        }
    }

    private boolean isLastColum(RecyclerView parent, int pos, int spanCount, int childCount) {
        RecyclerView.LayoutManager layoutManager = parent.getLayoutManager();
        if (layoutManager instanceof GridLayoutManager) {
            if ((pos + 1) % spanCount == 0)// 如果是最後一列,則不需要繪製右邊
            {
                return true;
            }
        } else if (layoutManager instanceof StaggeredGridLayoutManager) {
            int orientation = ((StaggeredGridLayoutManager) layoutManager)
                    .getOrientation();
            if (orientation == StaggeredGridLayoutManager.VERTICAL) {
                if ((pos + 1) % spanCount == 0)// 如果是最後一列,則不需要繪製右邊
                {
                    return true;
                }
            } else {
                childCount = childCount - childCount % spanCount;
                if (pos >= childCount)// 如果是最後一列,則不需要繪製右邊
                    return true;
            }
        }
        return false;
    }

    private boolean isLastRaw(RecyclerView parent, int pos, int spanCount, int childCount) {
        LayoutManager layoutManager = parent.getLayoutManager();
        if (layoutManager instanceof GridLayoutManager) {
            childCount = childCount - childCount % spanCount;
            if (pos >= childCount)// 如果是最後一行,則不需要繪製底部
                return true;
        } else if (layoutManager instanceof StaggeredGridLayoutManager) {
            int orientation = ((StaggeredGridLayoutManager) layoutManager)
                    .getOrientation();
            // StaggeredGridLayoutManager 且縱向滾動
            if (orientation == StaggeredGridLayoutManager.VERTICAL) {
                childCount = childCount - childCount % spanCount;
                // 如果是最後一行,則不需要繪製底部
                if (pos >= childCount)
                    return true;
            } else
            // StaggeredGridLayoutManager 且橫向滾動
            {
                // 如果是最後一行,則不需要繪製底部
                if ((pos + 1) % spanCount == 0) {
                    return true;
                }
            }
        }
        return false;
    }

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, State state) {
        boolean b = state.willRunPredictiveAnimations();
        int itemPosition = ((RecyclerView.LayoutParams) view.getLayoutParams()).getViewLayoutPosition();
        int spanCount = getSpanCount(parent);
        int childCount = parent.getAdapter().getItemCount();
//        if (isLastRaw(parent, itemPosition, spanCount, childCount))// 如果是最後一行,則不需要繪製底部
//        {
//            outRect.set(0, 0, lineWidth, 0);
//        }
//        else if (isLastColum(parent, itemPosition, spanCount, childCount))// 如果是最後一列,則不需要繪製右邊
//        {
////            if (b){
////                outRect.set(0, 0, lineWidth, lineWidth);
////            }else {
//                outRect.set(0, 0, 0, lineWidth);
////            }
//        }
//        else {
        outRect.set(0, 0, lineWidth, lineWidth);
//        }
    }
}
複製程式碼

使用說明

上面給出的兩個例項都是最簡單的一條線的分割。這裡的分割線你是可以自由的去自定義它的,具體如何實現也不是太複雜,這裡不再做詳細介紹了,推薦一篇文章:

RecyclerView之ItemDecoration 講解及高階特性實踐

RecyclerView 實現 item 的拖曳排序和滑動刪除

下面就主要為大家梳理一下拖曳排序和滑動刪除的實現,具體實現效果看文章首部效果圖,這裡就不再重複放圖了。

實現方式

主要就要使用到 ItemTouchHelper,ItemTouchHelper 一個幫助開發人員處理拖拽和滑動刪除的實現類,它能夠讓你非常容易實現側滑刪除、拖拽的功能。(ItemTouchHelper 的使用並不僅僅侷限於 RecyclerView 的滑動刪除,你同意可以用在其他需要拖曳滑動的地方。當然,今天我們不涉及其他地方的使用)

實現的程式碼並關聯到 RecyclerView 非常簡單,程式碼如下:

ItemTouchHelper itemTouchHelper = new ItemTouchHelper(new ItemTouchHelper.Callback());
itemTouchHelper.attachToRecyclerView(mRecyclerView);
複製程式碼

程式碼很簡單,沒什麼好說的。需要我們關注的是建立 ItemTouchHelper 時傳入的引數 ItemTouchHelper.Callback() 。ItemTouchHelper 會在拖拽的時候回撥 Callback 中相應的方法,我們只需在 Callback 中實現自己的邏輯。

自定義一個類繼承實現 ItemTouchHelper.Callback 介面,需要實現以下方法:

//通過返回值來設定是否處理某次拖曳或者滑動事件
public abstract int getMovementFlags(RecyclerView recyclerView,
                ViewHolder viewHolder);

//當長按並進入拖曳狀態時,拖曳的過程中不斷的回撥此方法
public abstract boolean onMove(RecyclerView recyclerView,
                ViewHolder viewHolder, ViewHolder target);

//滑動刪除的回撥
public abstract void onSwiped(ViewHolder viewHolder, int direction);
複製程式碼

getMovementFlags() 用於設定是否處理拖拽事件和滑動事件,以及拖拽和滑動操作的方向,有以下兩種情況:

  • 如果是列表型別的 RecyclerView,拖拽只有 UP、DOWN 兩個方向
  • 如果是網格型別的則有 UP、DOWN、LEFT、RIGHT 四個方向

該方法需要編寫的程式碼如下:

@Override
public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
    if (recyclerView.getLayoutManager() instanceof GridLayoutManager) {
        int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN |
                ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
        int swipeFlags = 0;
        return makeMovementFlags(dragFlags, swipeFlags);
    } else {
        int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
        int swipeFlags = 0;
        return makeMovementFlags(dragFlags, swipeFlags);
    }
}
複製程式碼

dragFlags 是拖拽標誌,
swipeFlags 是滑動標誌,
swipeFlags 都設定為0,暫時不考慮滑動相關操作。

如果設定了相關的 dragFlags,那麼當長按 item 的時候就會進入拖拽並在拖拽過程中不斷回撥 onMove() 方法,我們就在這個方法裡獲取當前拖拽的 item 和已經被拖拽到所處位置的 item 的ViewHolder,有了這2個 ViewHolder,我們就可以交換他們的資料集並呼叫 Adapter 的notifyItemMoved 方法來重新整理 item。

@Override
public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
	//拖動的 item 的下標
    int fromPosition = viewHolder.getAdapterPosition();
	//目標 item 的下標,目標 item 就是當拖曳過程中,不斷和拖動的 item 做位置交換的條目。
    int toPosition = target.getAdapterPosition();
    if (fromPosition < toPosition) {
        for (int i = fromPosition; i < toPosition; i++) {
            Collections.swap(((RecyAdapter) mAdapter).getDataList(), i, i + 1);
        }
    } else {
        for (int i = fromPosition; i > toPosition; i--) {
            Collections.swap(((RecyAdapter) mAdapter).getDataList(), i, i - 1);
        }
    }
    mAdapter.notifyItemMoved(fromPosition, toPosition);
    return true;
}
複製程式碼

只要重寫完上面這兩個方法,RecyclerView 就能實現拖曳的效果了。是不是很簡單?但是雖然拖曳是沒什麼問題了,但是並不能達到下圖的效果,因為你正在拖曳的 item 並沒有陰影效果。

拖曳 item

那怎麼才能實現被拖曳的 item 有背景顏色加深起到強調的視覺效果呢?這是需要重寫下面兩個方法:

//當長按 item 剛開始拖曳的時候呼叫
@Override
public void onSelectedChanged(RecyclerView.ViewHolder viewHolder, int actionState) {
    if (actionState != ItemTouchHelper.ACTION_STATE_IDLE) {
		//給被拖曳的 item 設定一個深顏色背景
        viewHolder.itemView.setBackgroundColor(Color.LTGRAY);
    }
    super.onSelectedChanged(viewHolder, actionState);
}

//當完成拖曳手指鬆開的時候呼叫
@Override
public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
    super.clearView(recyclerView, viewHolder);
	//給已經完成拖曳的 item 恢復開始的背景。
	//這裡我們設定的顏色儘量和你 item 在 xml 中設定的顏色保持一致
    viewHolder.itemView.setBackgroundColor(Color.WHITE);
}
複製程式碼

這樣就能完全達到上面圖片的效果了。

滑動刪除

如何實現滑動刪除呢?我們只需要實現第三個方法 onSwipe() 就行了。程式碼如下:

@Override
public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
    int adapterPosition = viewHolder.getAdapterPosition();
    mAdapter.notifyItemRemoved(adapterPosition);
    ((RecyAdapter) mAdapter).getDataList().remove(adapterPosition);
}
複製程式碼

同時也不要忘了修改一下 getMovementFlags() 方法,以便能夠相應滑動事件:

@Override
public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
    if (recyclerView.getLayoutManager() instanceof GridLayoutManager) {
        int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN |
                ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
        int swipeFlags = 0;
        return makeMovementFlags(dragFlags, swipeFlags);
    } else {
        int dragFlags = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
		//注意:和拖曳的區別就是在這裡
        int swipeFlags = ItemTouchHelper.START | ItemTouchHelper.END;
        return makeMovementFlags(dragFlags, swipeFlags);
    }
}
複製程式碼

那目前你就能完美的實現拖曳排序和滑動刪除了。

拖曳排序,首個固定

有時我們希望首個 item 不能被拖曳排序。比如我們在新聞 App 中常見當我們進行新聞分類時,“熱門”新聞這個分類總是第一個且不能被拖曳修改,類似下面的效果:

首個固定不能被拖曳

那麼怎麼才能達到上面的效果呢?在上面我們的 Callback 類中有一個方法:

public boolean isLongPressDragEnabled() {
	return true;
}
複製程式碼

這個方法是為了告訴 ItemTouchHelper 是否需要 RecyclerView 支援長按拖拽,預設返回是 ture,理所當然我們要支援,所以我們沒有重寫,因為預設true。但是這樣做是預設全部的item都可以拖拽,怎麼實現部分item拖拽呢,在 isLongPressDragEnabled 方法的原始碼中有提示說,如果想自定義拖曳 view,那麼就使用 startDrag(ViewHolder) 方法。

**第一步:**那麼我們就先重寫 isLongPressDragEnabled() 方法,返回 false 讓它控制所有的 item 都不能拖曳。

public boolean isLongPressDragEnabled() {
	return false;
}
複製程式碼

**第二步:**我們給 RecyclerView 設定 item 的長按監聽事件,然後判斷這個 item 是不是第一個(或者最後一個,如果你不想讓最後一個被拖曳的話),如果不是我們就手動呼叫 startDrag(ViewHolder) 讓 item 開始被拖曳。
結合上面我們提供的給 item 設定點選和長按事件的方法,我們可以這樣:

mRecyclerView.addOnItemTouchListener(new OnRecyclerItemClickListener(mRecyclerView) {
    @Override
    public void onItemClick(RecyclerView.ViewHolder viewHolder) {
        //TODO:點選事件
    }

    @Override
    public void onLongClick(RecyclerView.ViewHolder viewHolder) {
		//當 item 被長按且不是第一個時,開始拖曳這個 item
        if (viewHolder.getLayoutPosition() != 0) {
            itemTouchHelper.startDrag(viewHolder);
        }
    }
});
複製程式碼

**第三步:**如果你以為上面兩步你就達到首個 item 固定不被拖曳的話,恭喜你,答對了!首個 item 確實固定不能被拖曳了,可是看看下圖,就會令你大跌眼睛:

首個固定,不能被拖曳,卻能被擠動

雖然我們通過上面兩步控制了首個 item 不能被長按拖曳,但是我們並沒有處理,別的 item 被拖曳到首個 item 的情況。那麼如何才能讓首個 item 不被擠掉呢,這個也很簡單,只需要在 Callback 的 onMove() 方法中處理首個 item 被當著目標 item 的情況就行了。

@Override
public boolean onMove(...) {
    int fromPosition = viewHolder.getAdapterPosition();
    int toPosition = target.getAdapterPosition();
	//其他地方程式碼都和上面的一樣,這個就直接省略了
	//這裡判斷如果目標 item 是首個 item,那麼就直接返回false,表示不響應此次拖曳移動
    if (toPosition == 0) {
        return false;
    }
    ...
    return true;
}
複製程式碼

好了,到這裡就大功告成了。

本文原始碼地址:github.com/OCNYang/Rec…

參考文章:
chuansong.me/n/400690551…
chuansong.me/n/400690851…
www.10tiao.com/html/227/20…

相關文章