从源码来看ItemTouchHelper实现RecyclerView列表的拖拽和侧滑

264

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的基础上来改进,后面再说吧。