從原始碼來看ItemTouchHelper實現RecyclerView列表的拖拽和側滑

weixin_33912445發表於2017-05-12

RecyclerView是一個用來替換之前的ListView和GridView的控制元件,使用的時候,雖然比以前的ListView看起來麻煩,但是其實作為一個高度解耦的控制元件,複雜一點點換來極大的靈活性,豐富的可操作性,何樂而不為呢。不過今天主要說說它的一個輔助類ItemTouchHelper來實現列表的拖動滑動刪除

RecyclerView用法(ListView)

1.匯入控制元件包

compile 'com.android.support:support-v13:25.+'

2.佈局檔案加入控制元件

    <android.support.v7.widget.RecyclerView
        android:id="@+id/rv_test"
        android:layout_width="match_parent"
        android:layout_height="match_parent"></android.support.v7.widget.RecyclerView>

3.定義Adapter

public class TestAdapter extends RecyclerView.Adapter implements TouchCallbackListener {
    /**
     * 資料來源列表
     */
    private List<String> mData;

    /**
     * 構造方法傳入資料
     * @param mData
     */
    public TestAdapter(List<String> mData) {
        this.mData = mData;
    }

    /**
     * 建立用於複用的ViewHolder
     * @param parent
     * @param viewType
     * @return
     */
    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        ViewHolder vh = new ViewHolder(LayoutInflater.from(parent.getContext()).inflate(R.layout.list_item,parent,false));
        return vh;
    }

    /**
     * 對ViewHolder的控制元件進行操作
     * @param holder
     * @param position
     */
    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {
        if(holder instanceof ViewHolder){
            ViewHolder holder1 = (ViewHolder) holder;
            holder1.tv_test.setText(mData.get(position));
        }
    }

    /**
     *
     * @return 資料的總數
     */
    @Override
    public int getItemCount() {
        return mData.size();
    }

    /**
     * 長按拖拽時的回撥
     * @param fromPosition 拖拽前的位置
     * @param toPosition 拖拽後的位置
     */
    @Override
    public void onItemMove(int fromPosition, int toPosition) {
        Collections.swap(mData, fromPosition, toPosition);
        notifyItemMoved(fromPosition, toPosition);//通知Adapter更新
    }

    /**
     * 滑動時的回撥
     * @param position 滑動的位置
     */
    @Override
    public void onItemSwipe(int position) {
        mData.remove(position);
        notifyItemRemoved(position);////通知Adapter更新
    }

    /**
     * 自定義的ViewHolder內部類,必須繼承RecyclerView.ViewHolder(這裡用不用static存在爭議,沒有專門的測試,
     * 從記憶體佔用來看微乎其微,但是不知道有沒有記憶體洩露的問題)
     */
    public class ViewHolder extends RecyclerView.ViewHolder{

        private TextView tv_test;
        public ViewHolder(View itemView) {
            super(itemView);
            tv_test = (TextView) itemView.findViewById(R.id.tv_test);
        }
    }
}

這裡定義RecyclerView的Adapter介面卡,必須繼承自RecyclerView.Adapter,而且需要在內部定義ViewHolder類,這個跟我們之前使用ListView是一樣的,不過在RecyclerView裡面這個是必須實現的。還有就是這裡我並沒有用static,不影響複用,但是記憶體會不會洩漏呢?

然後裡面還有兩個在拖拽和滑動時的回撥,這裡是我們自己定義的一個介面TouchCallbackListener

TouchCallbackListener

public interface TouchCallbackListener {
    /**
     * 長按拖拽時的回撥
     * @param fromPosition 拖拽前的位置
     * @param toPosition 拖拽後的位置
     */
    void onItemMove(int fromPosition, int toPosition);
    /**
     * 滑動時的回撥
     * @param position 滑動的位置
     */
    void onItemSwipe(int position);
}

4.使用ItemTouchHelper實現上下拖拽和滑動刪除功能

ItemTouchHelper的構造方法需要傳入ItemTouchHelper.Callback來自己定義各種動作時的處理,我們自定義的類如下:

TouchCallback

public class TouchCallback extends ItemTouchHelper.Callback {
    /**
     * 自定義的監聽介面
     */
    private TouchCallbackListener mListener;

    public TouchCallback(TouchCallbackListener listener) {
        this.mListener = listener;
    }

    /**
     * 定義列表可以怎麼滑動(上下左右)
     * @param recyclerView
     * @param viewHolder
     * @return
     */
    @Override
    public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
        //上下滑動
        int dragFlag = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
        //左右滑動
        int swipeFlag = ItemTouchHelper.LEFT| ItemTouchHelper.RIGHT;
        //使用此方法生成標誌返回
        return makeMovementFlags(dragFlag, swipeFlag);
    }

    /**
     * 拖拽移動時呼叫的方法
     * @param recyclerView 控制元件
     * @param viewHolder 移動之前的條目
     * @param target 移動之後的條目
     * @return
     */
    @Override
    public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
        mListener.onItemMove(viewHolder.getAdapterPosition(), target.getAdapterPosition());
        return true;
    }

    /**
     * 滑動時呼叫的方法
     * @param viewHolder 滑動的條目
     * @param direction 方向
     */
    @Override
    public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
        mListener.onItemSwipe(viewHolder.getAdapterPosition());
    }

    /**
     * 是否允許長按拖拽
     * @return true or false
     */
    @Override
    public boolean isLongPressDragEnabled() {
        return true;
    }

    /**
     * 是否允許滑動
     * @return true or false
     */
    @Override
    public boolean isItemViewSwipeEnabled() {
        return true;
    }
}

5.使用RecyclerView繫結Adapter和ItemTouchHelper

最後在Activity中來使用RecyclerView

public class MainActivity extends AppCompatActivity{

    private RecyclerView mRecyclerView;
    private TestAdapter mTestAdapter;
    private List<String> mData;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        initData();
        mRecyclerView = (RecyclerView) findViewById(R.id.rv_test);
        mRecyclerView.setAdapter(mTestAdapter);
        //定義佈局管理器,這裡是ListView。GridLayoutManager對應GridView
        LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
        //ListView的方向,縱向
        linearLayoutManager.setOrientation(LinearLayoutManager.VERTICAL);
        mRecyclerView.setLayoutManager(linearLayoutManager);
        //新增每一行的分割線
//        mRecyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL_LIST));
        ItemTouchHelper helper = new ItemTouchHelper(new TouchCallback(mTestAdapter));
        helper.attachToRecyclerView(mRecyclerView);
    }

    /**
     * 初始化模擬資料
     */
    private void initData() {
        mData = new ArrayList<>();
        String temp;
        for(int i = 0; i < 99; ++i){
            temp = i + "*";
            mData.add(temp);
        }
        mTestAdapter = new TestAdapter(mData);
    }

6.新增分割線

RecyclerView預設每一行是沒有分割線的,如果需要分割線的話要自己去定義ItemDecoration,這個類可以為每個條目新增額外的檢視與效果,我們自己定義的程式碼如下:
DividerItemDecoration

public class DividerItemDecoration extends RecyclerView.ItemDecoration{
    private static final int[] ATTRS = new int[]{
            android.R.attr.listDivider//Android預設的分割線效果
    };
    public static final int HORIZONTAL_LIST = LinearLayoutManager.HORIZONTAL;

    public static final int VERTICAL_LIST = LinearLayoutManager.VERTICAL;

    private Drawable mDivider;

    private int mOrientation;

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

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

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

    /**
     * 縱向的列表
     * @param c
     * @param 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);
            RecyclerView v = new 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);
        }
    }

    /**
     * 橫向的列表
     * @param c
     * @param parent
     */
    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, View view, RecyclerView parent, RecyclerView.State state) {
        if(mOrientation == VERTICAL_LIST){
            outRect.set(0,0,0,mDivider.getIntrinsicHeight());
        }else {
            outRect.set(0,0,mDivider.getIntrinsicWidth(), 0);
        }
    }
}

到此就實現了一個支援長按拖拽和滑動刪除的列表,很簡單,效果就不截圖了。

ItemTouchHelper原理

實現拖拽和滑動刪除的過程的很簡單,並且還有非常流暢的動畫。只需要給ItemTouchHelper傳入一個我們自己定義的回撥即可,但是它的內部是怎麼實現的呢?來一步一步看看程式碼。

首先看看它的類定義:

public class ItemTouchHelper extends RecyclerView.ItemDecoration
        implements RecyclerView.OnChildAttachStateChangeListener

繼承自RecyclerView.ItemDecoration,跟分割線一樣,也是通過繼承這個類來給每個條目新增效果

然後從它的在外層的使用開始:

ItemTouchHelper helper = new ItemTouchHelper(new TouchCallback(mTestAdapter));
helper.attachToRecyclerView(mRecyclerView);

RecyclerView和ItemTouchHelper的關聯是ItemTouchHelper的attachToRecyclerView方法,進入這個方法:

ItemTouchHelper.attachToRecyclerView

    public void attachToRecyclerView(@Nullable RecyclerView recyclerView) {
        if (mRecyclerView == recyclerView) {
            return; // nothing to do
        }
        if (mRecyclerView != null) {
            destroyCallbacks();
        }
        mRecyclerView = recyclerView;
        if (mRecyclerView != null) {
            final Resources resources = recyclerView.getResources();
            mSwipeEscapeVelocity = resources
                    .getDimension(R.dimen.item_touch_helper_swipe_escape_velocity);
            mMaxSwipeVelocity = resources
                    .getDimension(R.dimen.item_touch_helper_swipe_escape_max_velocity);
            setupCallbacks();
        }
    }

首先判斷傳入的RecyclerView是否跟已經繫結的相等,如果相等,就直接返回,不過不相等,銷燬之前的回撥,然後將傳入的RecyclerView賦值給全域性變數,設定速率,最後呼叫setupCallbacks初始化

ItemTouchHelper.setupCallbacks

    private void setupCallbacks() {
        ViewConfiguration vc = ViewConfiguration.get(mRecyclerView.getContext());
        mSlop = vc.getScaledTouchSlop();
        mRecyclerView.addItemDecoration(this);
        mRecyclerView.addOnItemTouchListener(mOnItemTouchListener);
        mRecyclerView.addOnChildAttachStateChangeListener(this);
        initGestureDetector();
    }

前兩句是獲取TouchSlop的值,這個值用於判斷是滑動還是點選,然後給RecyclerView新增ItemDecoration(也就是自己),條目的觸控監聽,條目的關聯狀態監聽。這裡最主要的就是看看mOnItemTouchListener的實現:

ItemTouchHelper.mOnItemTouchListener

    private final OnItemTouchListener mOnItemTouchListener
            = new OnItemTouchListener() {
        @Override
        public boolean onInterceptTouchEvent(RecyclerView recyclerView, MotionEvent event) {
            mGestureDetector.onTouchEvent(event);
            if (DEBUG) {
                Log.d(TAG, "intercept: x:" + event.getX() + ",y:" + event.getY() + ", " + event);
            }
            //用於處理多點觸控
            final int action = MotionEventCompat.getActionMasked(event);
            if (action == MotionEvent.ACTION_DOWN) {
                mActivePointerId = event.getPointerId(0);
                mInitialTouchX = event.getX();
                mInitialTouchY = event.getY();
                obtainVelocityTracker();
                if (mSelected == null) {
                    final RecoverAnimation animation = findAnimation(event);
                    if (animation != null) {
                        mInitialTouchX -= animation.mX;
                        mInitialTouchY -= animation.mY;
                        endRecoverAnimation(animation.mViewHolder, true);
                        if (mPendingCleanup.remove(animation.mViewHolder.itemView)) {
                            mCallback.clearView(mRecyclerView, animation.mViewHolder);
                        }
                        select(animation.mViewHolder, animation.mActionState);
                        updateDxDy(event, mSelectedFlags, 0);
                    }
                }
            } else if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
                mActivePointerId = ACTIVE_POINTER_ID_NONE;
                select(null, ACTION_STATE_IDLE);
            } else if (mActivePointerId != ACTIVE_POINTER_ID_NONE) {
                // in a non scroll orientation, if distance change is above threshold, we
                // can select the item
                final int index = event.findPointerIndex(mActivePointerId);
                if (DEBUG) {
                    Log.d(TAG, "pointer index " + index);
                }
                if (index >= 0) {
                    checkSelectForSwipe(action, event, index);
                }
            }
            if (mVelocityTracker != null) {
                mVelocityTracker.addMovement(event);
            }
            return mSelected != null;
        }

        @Override
        public void onTouchEvent(RecyclerView recyclerView, MotionEvent event) {
            mGestureDetector.onTouchEvent(event);
            if (DEBUG) {
                Log.d(TAG,
                        "on touch: x:" + mInitialTouchX + ",y:" + mInitialTouchY + ", :" + event);
            }
            if (mVelocityTracker != null) {
                mVelocityTracker.addMovement(event);
            }
            if (mActivePointerId == ACTIVE_POINTER_ID_NONE) {
                return;
            }
            final int action = MotionEventCompat.getActionMasked(event);
            final int activePointerIndex = event.findPointerIndex(mActivePointerId);
            if (activePointerIndex >= 0) {
                checkSelectForSwipe(action, event, activePointerIndex);
            }
            ViewHolder viewHolder = mSelected;
            if (viewHolder == null) {
                return;
            }
            switch (action) {
                case MotionEvent.ACTION_MOVE: {
                    // Find the index of the active pointer and fetch its position
                    if (activePointerIndex >= 0) {
                        updateDxDy(event, mSelectedFlags, activePointerIndex);
                        moveIfNecessary(viewHolder);
                        mRecyclerView.removeCallbacks(mScrollRunnable);
                        mScrollRunnable.run();
                        mRecyclerView.invalidate();
                    }
                    break;
                }
                case MotionEvent.ACTION_CANCEL:
                    if (mVelocityTracker != null) {
                        mVelocityTracker.clear();
                    }
                    // fall through
                case MotionEvent.ACTION_UP:
                    select(null, ACTION_STATE_IDLE);
                    mActivePointerId = ACTIVE_POINTER_ID_NONE;
                    break;
                case MotionEvent.ACTION_POINTER_UP: {
                    final int pointerIndex = MotionEventCompat.getActionIndex(event);
                    final int pointerId = event.getPointerId(pointerIndex);
                    if (pointerId == mActivePointerId) {
                        // This was our active pointer going up. Choose a new
                        // active pointer and adjust accordingly.
                        final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
                        mActivePointerId = event.getPointerId(newPointerIndex);
                        updateDxDy(event, mSelectedFlags, pointerIndex);
                    }
                    break;
                }
            }
        }

這裡主要重寫了兩個方法onInterceptTouchEventonTouchEvent,先來看看onInterceptTouchEvent,攔截螢幕事觸控的事件,首先是判斷單點按下

if (action == MotionEvent.ACTION_DOWN) {
                //現在追蹤的觸控事件
                mActivePointerId = event.getPointerId(0);
                //獲取最開始按下的座標值
                mInitialTouchX = event.getX();
                mInitialTouchY = event.getY();
                //獲取速度追蹤器(此方法避免重複建立)
                obtainVelocityTracker();
                //如果選擇的條目為空
                if (mSelected == null) {
                    //查詢對應的動畫(避免重複動畫)
                    final RecoverAnimation animation = findAnimation(event);
                    //執行動畫,
                    if (animation != null) {
                        //更新初始值
                        mInitialTouchX -= animation.mX;
                        mInitialTouchY -= animation.mY;
                        //從動畫列表裡移除條目對應的動畫
                        endRecoverAnimation(animation.mViewHolder, true);
                        //從回收列表裡移除條目檢視
                        if (mPendingCleanup.remove(animation.mViewHolder.itemView)) {
                            mCallback.clearView(mRecyclerView, animation.mViewHolder);
                        }
                        //執行選擇動畫
                        select(animation.mViewHolder, animation.mActionState);
                        //更新移動距離x,y的值
                        updateDxDy(event, mSelectedFlags, 0);
                    }
                }
            }

然後是判斷取消單點抬起

else if (action == MotionEvent.ACTION_CANCEL || action == MotionEvent.ACTION_UP) {
                mActivePointerId = ACTIVE_POINTER_ID_NONE;
                select(null, ACTION_STATE_IDLE);//清除動畫

最後執行下面判斷點選狀態為空:

else if (mActivePointerId != ACTIVE_POINTER_ID_NONE) {
                // 移動距離超過了臨界值,判斷是否滑動選擇的條目
                final int index = event.findPointerIndex(mActivePointerId);
                if (DEBUG) {
                    Log.d(TAG, "pointer index " + index);
                }
                if (index >= 0) {
                    //判斷是否滑選擇的條目
                    checkSelectForSwipe(action, event, index);
                }
            }

最後如果選擇的條目不等於null,返回true,表示攔截觸控事件,接下來執行onTouchEvent方法,只看對觸控動作的判斷:

1.按下移動手指

case MotionEvent.ACTION_MOVE: {
                    // 如果點選序號大於0,表示有點選事件
                    if (activePointerIndex >= 0) {
                        //更新移動距離
                        updateDxDy(event, mSelectedFlags, activePointerIndex);
                        //移動ViewHolder
                        moveIfNecessary(viewHolder);
                        //先移除動畫
                        mRecyclerView.removeCallbacks(mScrollRunnable);
                        //執行動畫
                        mScrollRunnable.run();
                        //重繪RecyclerView
                        mRecyclerView.invalidate();
                    }
                    break;
                }

這裡來看看mScrollRunnable.run()

    final Runnable mScrollRunnable = new Runnable() {
        @Override
        public void run() {
            if (mSelected != null && scrollIfNecessary()) {
                if (mSelected != null) { //it might be lost during scrolling
                    moveIfNecessary(mSelected);
                }
                mRecyclerView.removeCallbacks(mScrollRunnable);
                //遞迴呼叫
                ViewCompat.postOnAnimation(mRecyclerView, this);
            }
        }
    };

這裡的run方法相當於是一個死迴圈,在裡面又不斷呼叫自己,不斷的執行動畫,因為選中的條目需要不停的跟隨手指的移動,直到判斷條件返回FALSE停止執行,然後回到onTouchEvent繼續判斷

2.當使用者保持按下操作,並從你的控制元件轉移到外層控制元件時,會觸發ACTION_CANCEL:

case MotionEvent.ACTION_CANCEL:
                    if (mVelocityTracker != null) {
                        //清除速度追蹤器
                        mVelocityTracker.clear();
                    }

3.抬起手指

case MotionEvent.ACTION_UP:
                    //清理選擇動畫
                    select(null, ACTION_STATE_IDLE);
                    //手指狀態置空
                    mActivePointerId = ACTIVE_POINTER_ID_NONE;
                    break;

4.多點觸控抬起

case MotionEvent.ACTION_POINTER_UP: {
                    final int pointerIndex = MotionEventCompat.getActionIndex(event);
                    final int pointerId = event.getPointerId(pointerIndex);
                    if (pointerId == mActivePointerId) {
                        //選擇一個新的手指活動點,並且更新x,y的距離
                        final int newPointerIndex = pointerIndex == 0 ? 1 : 0;
                        mActivePointerId = event.getPointerId(newPointerIndex);
                        updateDxDy(event, mSelectedFlags, pointerIndex);
                    }
                    break;
                }

根據對OnItemTouchListener的原始碼分析,我們知道了跟隨手指的動畫是怎麼來實現的,簡單來說,就是檢測手指的動作,然後不斷的重繪,最終就展現在我們面前,在長按上下拖拽時,按住的條目隨著手指移動,左右滑動時,條目“飛”出螢幕。不過在實際的專案中,這種側滑刪除的操作肯定不是直接側滑就執行刪除,需要右邊有一個刪除的按鈕來確認,這個也可以在ItemTouchHelper的基礎上來改進,後面再說吧。

相關文章