RecyclerView系列之侧滑删除和拖拽排序

一、背景
前面已经实现了 RecyclerView 的上拉加载更多,增加 header,自定义滑动菜单,基本能满足大部分场景的样式了,就算不满足也能通过直接改部分代码轻松实现新的样式;不过这一次产品玩别的 app 时发现了一个新的交互方式,某些列表不需要复杂的操作,只需要删除操作,那么如果用那种滑动菜单的交互方式,用户就得先把菜单滑出来,再点删除按钮才能删掉,这样一来用户就多操作了一步,不如直接让用户滑动删除,他说这叫返璞归真,简约效率。
在这里插入图片描述
二、思路分析
第一想法是通过修改之前的滑动菜单逻辑实现,打算修改一下边界,再控制一下状态判定阀值,但是总感觉这样搞不太合适,感觉有更简洁干脆的方法来实现这个,去网上看了一圈果然发现api里有个现成的工具类是专门来搞这类需求的,ItemTouchHelper。因为用的是现成的原生api,所以此文是对其一些用法的记录。那么这也不需要啥思路了,因为可以直接自定义一个类继承 ItemTouchHelper.Callback,然后可以直接通过监听 onSwiped 来实现滑动删除,另外在看这个类的监听时发现还有个onMove() 方法,这个太适合做拖拽排序了,所以干脆把排序的做法也记录一下。

三、具体实现
1.首先自定义一个类继承 ItemTouchHelper.Callback,通过实现 onSwiped 方法来监听滑动的操作,在此方法里执行实际删除列表 item 的逻辑;通过实现 onMove 方法来监听拖拽换位结果,在此方法里执行实际排序 item 的逻辑;

...    
    /**
     * 返回拖拽中换位的回调
     *
     * @param recyclerView
     * @param viewHolder
     * @param target
     * @return true:换位成功
     * false:没换成功
     */
    @Override
    public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
        if (null == onItemTouchHelperCallBack) {
            return false;
        }
        return onItemTouchHelperCallBack.onMove(viewHolder, target);
    }

    /**
     * 滑动后的回调
     *
     * @param viewHolder
     * @param direction
     */
    @Override
    public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
        if (null != onItemTouchHelperCallBack) {
            onItemTouchHelperCallBack.onSwiped(viewHolder);
        }
    }
...

在 RecyclerView的adapter 里实现这两个方法:

...
    @Override
    public boolean onMove(RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
        int adapterPosition = viewHolder.getAdapterPosition();
        int adapterPosition1 = target.getAdapterPosition();
        if (adapterPosition1 <= 2) {//例如设置position小于2的item不让换
            return false;
        }
        if (getHeaderViewCount() > 0) {
            Collections.swap(mDatas, adapterPosition - 1, adapterPosition1 - 1);
        } else {
            Collections.swap(mDatas, adapterPosition, adapterPosition1);
        }
        notifyItemMoved(adapterPosition, adapterPosition1);
        return true;
    }

    @Override
    public void onSwiped(RecyclerView.ViewHolder viewHolder) {
        int adapterPosition = viewHolder.getAdapterPosition();
        mDatas.remove(getHeaderViewCount() > 0 ? adapterPosition - 1 : adapterPosition);
        notifyItemRemoved(adapterPosition);
    }
...

然后在自定义的 Callback 类里设置一下允许滑动的参数:

    /**
     * 设置允许拖动换位和滑动删除的方向
     *
     * @param recyclerView
     * @param viewHolder
     * @return
     */
    @Override
    public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
        if (isContentType(viewHolder)) {
            //dragFlags:设置为可以向上和向下拖动
            //swipeFlags:设置成可以从左向右滑动删除
            return makeMovementFlags(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.END);
        }
        return makeMovementFlags(0, 0);
    }

    private boolean isContentType(RecyclerView.ViewHolder viewHolder) {
        return viewHolder.getItemViewType() == BaseAdapter.TYPE_CONTENT_VIEW;
    }

这里需要注意的是要控制一下 header 和 footer 不能参与滑动删除和排序。
这样就实现了滑动删除和拖拽排序,这也太简单了,突然就实现了这个需求。这时产品设计上可能会有一些美化,例如滑动时要在 item 下面提示用户这是删除操作,这个可以通过实现 onChildDraw 方法,用 canvas 来画一些东西,例如在item后面显示 “delete” 文字提示用户:

    /**
     * 拖拽或滑动删除时,在item下面画点什么时用到
     */
    @Override
    public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
        super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
        View itemView = viewHolder.itemView;
        if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
            //滑动删除时在底层画文字告知用户此为删除操作
            mPaint.setStyle(Paint.Style.FILL);
            mPaint.setTextSize(40f);
            c.drawText("delete", 10, itemView.getY() + mPaint.getTextSize() + (itemView.getHeight() - mPaint.getTextSize()) / 2, mPaint);
        }
    }

这就实现了文章最开始的图中的滑动删除效果,接着再来给拖拽排序的 item 加个框框,让这个 item 更显眼一点,因为是要让这个框套在 item 上,所以应该在onChildDrawOver 方法里实现:

    /**
     * 拖拽或滑动删除时,在item上面画点什么时用到
     */
    @Override
    public void onChildDrawOver(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
        super.onChildDrawOver(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
        View itemView = viewHolder.itemView;
        if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
            if (isCurrentlyActive) {
                //拖动时画个框
                mPaint.setStyle(Paint.Style.STROKE);
                rect.set((int) mPaint.getStrokeWidth() / 2, (int) (itemView.getTop() + dY), (int) (itemView.getRight() - mPaint.getStrokeWidth() / 2), (int) (itemView.getBottom() + dY));
                c.drawRect(rect, mPaint);
            } else {
                //擦除拖动时画的框
                c.save();
                c.clipRect(rect);
                c.drawColor(Color.TRANSPARENT, PorterDuff.Mode.DST_OVER);
                c.restore();
            }
        }
    }

这样就实现了演示图中的排序效果,这里需要注意的是要在激活状态时画框,也就是手指拖拽时,在未激活状态下擦除刚才画的框,不然换完位了框还在。
下面是完整 Callback类:

/**
 * Created by Aislli on 2019/4/5 0008.
 */
public class MyItemTouchHelperCallBack extends ItemTouchHelper.Callback {
    private OnItemTouchHelperCallBack onItemTouchHelperCallBack;

    private Rect rect = new Rect();
    private Paint mPaint = new Paint();

    public MyItemTouchHelperCallBack(OnItemTouchHelperCallBack onItemTouchHelperCallBack) {
        this.onItemTouchHelperCallBack = onItemTouchHelperCallBack;
        mPaint.setColor(Color.BLUE);
        mPaint.setStrokeWidth(15f);
    }

    /**
     * 设置允许拖动换位和滑动删除的方向
     *
     * @param recyclerView
     * @param viewHolder
     * @return
     */
    @Override
    public int getMovementFlags(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
        if (isContentType(viewHolder)) {
            //dragFlags:设置为可以向上和向下拖动
            //swipeFlags:设置成可以从左向右滑动删除
            return makeMovementFlags(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.END);
        }
        return makeMovementFlags(0, 0);
    }

    private boolean isContentType(RecyclerView.ViewHolder viewHolder) {
        return viewHolder.getItemViewType() == BaseAdapter.TYPE_CONTENT_VIEW;
    }

    /**
     * 返回拖拽中换位的回调
     *
     * @param recyclerView
     * @param viewHolder
     * @param target
     * @return true:换位成功
     * false:没换成功
     */
    @Override
    public boolean onMove(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target) {
        if (null == onItemTouchHelperCallBack) {
            return false;
        }
        return onItemTouchHelperCallBack.onMove(viewHolder, target);
    }

    /**
     * 滑动后的回调
     *
     * @param viewHolder
     * @param direction
     */
    @Override
    public void onSwiped(RecyclerView.ViewHolder viewHolder, int direction) {
        if (null != onItemTouchHelperCallBack) {
            onItemTouchHelperCallBack.onSwiped(viewHolder);
        }
    }

    /**
     * 成功换位的回调(无特殊需求一般无需处理)
     */
    @Override
    public void onMoved(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, int fromPos, RecyclerView.ViewHolder target, int toPos, int x, int y) {
        super.onMoved(recyclerView, viewHolder, fromPos, target, toPos, x, y);
    }

    /**
     * 拖拽或滑动删除时,在item下面画点什么时用到
     */
    @Override
    public void onChildDraw(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
        super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
        View itemView = viewHolder.itemView;
        if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
            //滑动删除时在底层画文字告知用户此为删除操作
            mPaint.setStyle(Paint.Style.FILL);
            mPaint.setTextSize(40f);
            c.drawText("delete", 10, itemView.getY() + mPaint.getTextSize() + (itemView.getHeight() - mPaint.getTextSize()) / 2, mPaint);
        }
    }

    /**
     * 拖拽或滑动删除时,在item上面画点什么时用到
     */
    @Override
    public void onChildDrawOver(Canvas c, RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder, float dX, float dY, int actionState, boolean isCurrentlyActive) {
        super.onChildDrawOver(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive);
        View itemView = viewHolder.itemView;
        if (actionState == ItemTouchHelper.ACTION_STATE_DRAG) {
            if (isCurrentlyActive) {
                //拖动时画个框
                mPaint.setStyle(Paint.Style.STROKE);
                rect.set((int) mPaint.getStrokeWidth() / 2, (int) (itemView.getTop() + dY), (int) (itemView.getRight() - mPaint.getStrokeWidth() / 2), (int) (itemView.getBottom() + dY));
                c.drawRect(rect, mPaint);
            } else {
                //擦除拖动时画的框
                c.save();
                c.clipRect(rect);
                c.drawColor(Color.TRANSPARENT, PorterDuff.Mode.DST_OVER);
                c.restore();
            }
        }
    }

    /**
     * 拖动或滑动完回调
     *
     * @param recyclerView
     * @param viewHolder
     */
    @Override
    public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
        super.clearView(recyclerView, viewHolder);
    }

    /**
     * 是否允许滑动删除,默认允许
     *
     * @return
     */
    @Override
    public boolean isItemViewSwipeEnabled() {
        return super.isItemViewSwipeEnabled();
    }

    /**
     * 是否允许拖拽,默认允许
     *
     * @return
     */
    @Override
    public boolean isLongPressDragEnabled() {
        return super.isLongPressDragEnabled();
    }

    public interface OnItemTouchHelperCallBack {
        boolean onMove(RecyclerView.ViewHolder viewHolder, RecyclerView.ViewHolder target);

        void onSwiped(RecyclerView.ViewHolder viewHolder);
    }
}

然后绑定 RecyclerView 使用:

itemTouchHelperAdapter = new ItemTouchHelperAdapter(mDatas);
recyclerView.setLayoutManager(new LinearLayoutManager(this));
recyclerView.setAdapter(itemTouchHelperAdapter);
recyclerView.addItemDecoration(new DividerItemDecoration(this, DividerItemDecoration.VERTICAL));

ItemTouchHelper.Callback callback = new MyItemTouchHelperCallBack(itemTouchHelperAdapter);
ItemTouchHelper itemTouchHelper = new ItemTouchHelper(callback);
itemTouchHelper.attachToRecyclerView(recyclerView);

四、其它疑难杂症的处理
如果把这个滑动删除和上拉加载更多放到一起用,就得处理一些细节问题了,之前第一页数据如果不满一屏,就认作已加载完处理:

private void checkFullScreen(final RecyclerView recyclerView) {
        recyclerView.postDelayed(new Runnable() {
            @Override
            public void run() {
                if (!isFullScreen(recyclerView)) {
                    loadEnd();
                }
            }
        }, 50);
    }

    /**
     * 数据加载完成
     */
    public void loadEnd() {
        currentState = STATE_END;
        if (mLoadEndView != null) {
            addFooterView(mLoadEndView);
        } else {
            addFooterView(new View(mContext));
        }
    }

在这里插入图片描述
这种情况本来在之前是没问题的,但是如果增加了删除功能,用户就可以把这些 item 一个一个的全删除了,当剩下最后一个 footer 时,这个holder.mPosition>=mAdapter.getItemCount() 条件就成立了导致抛异常,因为这时 adapter 里的 mDatas 已经为空了:

...
    boolean validateViewHolderForOffsetPosition(ViewHolder holder) {
            ...
            if (holder.mPosition < 0 || holder.mPosition >= mAdapter.getItemCount()) {
                throw new IndexOutOfBoundsException("Inconsistency detected. Invalid view holder "
                        + "adapter position" + holder + exceptionLabel());
            }
            ...
            return true;
        }
...
...
    @Override
    public int getItemCount() {
        if (null == mDatas) {
            return 0;
        }
        return mDatas.size() + getFooterViewCount() + getHeaderViewCount();
    }

    protected int getFooterViewCount() {
        return isOpenLoadMore && !mDatas.isEmpty() ? 1 : 0;
    }
...

这个异常倒是可以解决,上面的条件成立是因为 getFooterViewCount() 里不仅判断了 isOpenLoadMore 还判断了 mDatas.isEmpty(),如果有强迫症的想处理这个异常的(虽然处理了也不是正常需求效果),也很容易处理,把 getFooterViewCount() 修改下即可:

    protected int getFooterViewCount() {
        return isOpenLoadMore /*&& !mDatas.isEmpty()*/ ? 1 : 0;
    }

这样当用户把所有的item都删完了,就成了下面这样了:
在这里插入图片描述
所以这个修改无意义,这肯定不行,太有违和感了,所以和产品商量后决定把不满一屏的情况给改成直接隐藏 Footer,第一页就填不满,肯定不能加载更多了。(什么?我们为什么要商量一把而不照别人的做?因为产品找了一圈发现别的 app 里支持加载更多的不支持删除,支持删除的不支持加载更多,抄不了哇!)
首先把负责加载更多逻辑的 BaseAdapter 里的状态增加一个不满一屏状态:

    /**
     * 不满一屏时
     */
    private void stateNoScreen(){
        if (!isOpenLoadMore) {
            return;
        }
        currentState = STATE_END;
        isOpenLoadMore = false;
        removeFooterView();
        notifyItemRemoved(getItemCount());
    }

然后再检测是否全屏时用 stateNoScreen 替换 loadEnd:

    private void checkFullScreen(final RecyclerView recyclerView) {
        recyclerView.postDelayed(new Runnable() {
            @Override
            public void run() {
                if (!isFullScreen(recyclerView)) {
                    //loadEnd();//因为增加了删除功能,改成不满一屏就去掉footer防止用户把列表给删完了出现的奇怪现象
                    stateNoScreen();
                }
            }
        }, 50);
    }

这样不满一屏时就只显示列表,不显示 footer 了,如果产品哪天想再改回显示 “到底了” 之类的,改成 loadEnd 就完事了。
好了下一个问题,还是滑动删除和加载更多结合时的出现问题,如果用户一直删除 item,删到不满一屏时下面的 footer 就会出现,就像下图这样:
在这里插入图片描述
这肯定不行,看着太奇怪了,所以每一次删除完了检测一下列表状态,看看删除这一条后 footer 是否要露出来了,还有目前的 item 是否能满屏,如果最上面一条 item不是第一条,就让列表先向下滚动一条 item 的高度以填充这个删除的坑,直到最上面一条 item 是第一条时,再删就说明数据不满一屏了,这时就把状态置为stateNoScreen(),最终效果如下图:
在这里插入图片描述
adapter:

...
    @Override
    public void onSwiped(RecyclerView.ViewHolder viewHolder) {
        int adapterPosition = viewHolder.getAdapterPosition();
        notifyItemRemoved(adapterPosition);
        mDatas.remove(getHeaderViewCount() > 0 ? adapterPosition - 1 : adapterPosition);
        //为了防止删到不满一屏时加载更多的item出现
        hiddenFooterView();
    }
...

BaseAdapter:

    protected void hiddenFooterView() {
        if (!isOpenLoadMore) {
            return;
        }
        RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
        int lastVisibleItemPosition = findLastVisibleItemPosition(layoutManager);
        if (lastVisibleItemPosition >= getItemCount() - 2 && currentState != STATE_END) {
            //之前写的是==0,现在改成<=1,因为一直滑动删除到数据不满一屏,当删到footer即将出来的那个临界点时,不一定能让第一个item完全显示,<=1会更稳
            if (findFirstVisibleItemPosition(layoutManager) <= 1){
                //不足一屏时隐藏footer
                stateNoScreen();
                return;
            }
            View viewByPosition = layoutManager.findViewByPosition(getItemCount() - 2);
            if (null == viewByPosition) return;
            int i = mRecyclerView.getBottom() - mRecyclerView.getPaddingBottom() - viewByPosition.getBottom();
            if (i > 0) {
                mRecyclerView.smoothScrollBy(0, -i);
            }
        }
    }

onSwiped 这里需要注意一下,如果有 header 存在,adapterPosition 需要减一,实际上所有操作 mDatas 的地方只要有 header 存在,adapterPosition 都需要减一再计算;好了,目前 RecyclerView 基础的常用效果差不多就这些了,有兴趣的可以看看源码哦。

源码

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值