RecyclerView 扩展(三) - 使用ItemTouchHelper和LayoutManager实现滑动卡片效果

2,563 阅读6分钟

  最近楼主在忙碌于自己的毕设项目,在毕设当中需要实现一个滑动卡片的效果,楼主花了一点时间自己实现了一下,使用是ItemTouchHelperLayoutManager方式实现的。我们先来看一下效果:

  上面的效果说难也不难,说不难呢,但是这里面又有很多的小细节需要注意。   有人说,这动画很好做啊,使用ViewPager就可以实现了,这是没错的,但是ViewPager一直有一个诟病--那就是View的复用性不高。考虑到性能,RecyclerView自然是当之无愧的王者,既然我们学过RecyclerView,为什么不尝试着实现的呢?

1. 效果分析

  看着这个动画麻烦,其实我们将它分为两个部分实现就非常简单了。首先,每个ItemView是叠加样式展现的,这个效果在我们常用到的LayoutManger没有这种样式,所以得需要我们自定义一个LayoutManager来实现一个这种样式。这是其一。   其二,滑动切换的效果怎么实现呢?还记得我们之前分析过ItemTouchHelper这个类吗?这个类的作用是用来实现侧滑删除以及长按拖动的效果的,而这里切换卡片的效果就相当于侧滑删除,只不过是侧滑时做的动画不一样。这里的动画主要包括卡片的位移和角度变化,而ItemTouchHelper怎么实现根据手指滑动来做相应的动画呢?答案就在onChildDraw方法里面。   其实,我们从ItemTouchHelperonChildDraw方法里面就知道,原生只是做了水平位置的变化,所以,我们可以重写这个方法,从而加上我们想要的动画。   这样来分析,这个动画是不是非常简单呢?接下来,我们从看看代码吧。

2. LayoutManager

  自定义LayoutManager的相关知识,我在RecyclerView 源码分析(七) - 自定义LayoutManager及其相关组件的源码分析文章里面已经详细的解释了,这里我就不重复了。我们直接来看代码吧,关键代码在于onLayoutChildren方法里面:

    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
        final int layoutCount = Math.min(getItemCount(), mMaxVisibleCount);
        detachAndScrapAttachedViews(recycler);
        for (int i = layoutCount - 1; i >= 0; i--) {
            final View view = recycler.getViewForPosition(i);
            addView(view);
            measureChildWithMargins(view, 0, 0);
            int widthSpace = getWidth() - getDecoratedMeasuredWidth(view);
            int heightSpace = getHeight() - getDecoratedMeasuredHeight(view);
            layoutDecoratedWithMargins(view, widthSpace / 2, heightSpace / 2,
                    widthSpace / 2 + getDecoratedMeasuredWidth(view),
                    heightSpace / 2 + getDecoratedMeasuredHeight(view));
            // 给每个ItemView设置scale
            view.setScaleX((float) Math.pow(DEFAULT_SCALE, i));
            view.setScaleY((float) Math.pow(DEFAULT_SCALE, i));
            if (i == 0) {
                view.setOnTouchListener(new View.OnTouchListener() {
                    @Override
                    public boolean onTouch(View v, MotionEvent event) {
                        RecyclerView.ViewHolder childViewHolder = mRecyclerView.getChildViewHolder(v);
                        if (event.getActionMasked() == MotionEvent.ACTION_DOWN) {
                            // 这里需要手动告诉ItemTouchHelper可以侧滑
                            mItemTouchHelper.startSwipe(childViewHolder);
                        }
                        return false;
                    }
                });
            } else {
                // 由于ItemView会复用,所以一定要设置null
                view.setOnTouchListener(null);
            }
        }
    }

  相信上面的代码大家都能看的懂,这里我就不逐行的解释了。但是有一点需要我们特别注意:

        for (int i = layoutCount - 1; i >= 0; i--) {
          // ······
        }

  这里我们是倒着添加View,也就是一个ItemView虽然在RecyclerView的内部index为0,但是在Adapter中,却是layoutCount - 1,这个在我们自定义ItemTouchHelper.Callback时,会有很大的作用。

3.ItemTouchHelper.Callback

  关于ItemTouchHelper的知识,我在RecyclerView 扩展(二) - 手把手教你认识ItemTouchHelper文章里面已经详细的解释过了,所以在这里我也不重复了。我们直接来看实现代码,关键在onChildDraw方法:

    @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);
        final View itemView = viewHolder.itemView;
        if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
            float ratio = dX / getThreshold(recyclerView, viewHolder);
            if (ratio > 1) {
                ratio = 1;
            } else if (ratio < -1) {
                ratio = -1;
            }
            // 跟着角度旋转
            itemView.setRotation(ratio * 15);
            for (int i = 0; i < mMaxVisibleCount - 1; i++) {
                // 下面的ItemView跟着手指缩放
                View child = recyclerView.getChildAt(i);
                final float currentScale = (float) Math.pow(DEFAULT_SCALE, 2 - i);
                final float nextScale = currentScale / DEFAULT_SCALE;
                final float scale = (nextScale - currentScale);
                child.setScaleX(Math.min(1, currentScale + scale * Math.abs(ratio)));
                child.setScaleY(Math.min(1, currentScale + scale * Math.abs(ratio)));
            }
        }
    }

  上面代码的作用我在注释已经解释比较清楚了,这里就不解释了。不过这里还需要一点:

            for (int i = 0; i < mMaxVisibleCount - 1; i++) {
                 // ······
            }

  这里我缩放的也是0 ~ mMaxVisibleCount - 1的ItemView,请记住,这个不是ItemViewAdapter中的position,而是ItemViewRecyclerView内部的index值。在前面的LayoutManager中,我已经解释过,这俩是反着的。所以这里应该是0 ~ mMaxVisibleCount - 1。   整个实现就是这么的简单,其实还有坑没有说,比如说:

    @Override
    public void clearView(RecyclerView recyclerView, RecyclerView.ViewHolder viewHolder) {
        super.clearView(recyclerView, viewHolder);
        viewHolder.itemView.setRotation(0f);
    }

  在clearView方法里面必须进行重置,因为ItemView是复用的,不重置的话会出问题的。   在比如说,必须重写isItemViewSwipeEnabled方法(虽然不重写也没有问题,但是官方文档建议重写):

    @Override
    public boolean isItemViewSwipeEnabled() {
        return false;
    }

4. 跟SwipeRefreshLayout事件冲突

  使用上面代码来实现效果之后,我们会发现一个问题,如果将RecyclerView放在SwipeRefreshLayout内部,会出现事件冲突。   我简单的描述一下事件冲突的情况:当我们左右滑动时,这是正常的,每个ItemView都是正常的侧换;但是一旦上下滑动时,正常来说应该是SwipeRefreshLayout滑动,但是实际上还是ItemView在侧滑。   关于解决方案的话,我有两种方案:1. 重写SwipeRefreshLayoutonInterceptTouchEvent方法,进行事件拦截,让事件不能传递到ItemView中;2. 取消手动调用ItemTouchHelperstartSwipe方法,让ItemTouchHelper自己来判断是否符合侧滑的条件。   这里,我特别的说明一下第一种方法。为什么要特别说明第一种方法呢?因为此方法有很大的问题:1. 会重写SwipeRefreshLayout,这个造成了不必要的工作,这是其一;2. 重写了SwipeRefreshLayout会破坏SwipeRefreshLayout的结构,这个才是最大的缺点。   为什么重写SwipeRefreshLayout会破坏它的结构呢?我们可以从SwipeRefreshLayout的源码看出来,SwipeRefreshLayout不会主动的拦截事件,因为SwipeRefreshLayout是通过嵌套滑动机制来实现滑动,如果我们在onInterceptTouchEvent方法里面进行事件拦截,就违背了SwipeRefreshLayout的设计。所以,第一种方法是特别不推荐的!!!   其次,我们来看看第二种方案的实现方式,第二种方案非常简单,归根结底就是两句话:

  1. Callback里面不要重写isItemViewSwipeEnabled方法,
  2. LayoutManager里面不要在每个ItemViewOnTouchListener里面调用ItemTouchHelperstartSwipe方法。

  我在这里简单的解释第二种方式为什么这样做就不会冲突了,不过要了解为什么不冲突,必须得了解以前为什么会冲突。   SwipeRefreshLayout本身不会拦截事件,所以所有的事件都可以传递到RecyclerView里面的每个ItemView里面。因为我们在OnTouchListener调用ItemTouchHelperstartSwipe表示选中了一个ItemView可以侧滑,从而导致后面事件都会被该ItemView消费,进而导致了事件冲突。   而取消startSwipe方法的调用,让ItemTouchHelper自己来选中一个可以侧滑的ItemView,ItemTouchHelper本身就处理了上下滑和左右滑的冲突的(如果没有处理,RecyclerView的上下滑跟ItemView的侧滑会冲突)。这就是第二种方式的原理。

5. 源码

  为了方便大家的理解,我将自己的Demo代码上传到github,供大家参考:SlideCardDemo