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

  最近楼主在忙碌于自己的毕设项目,在毕设当中需要实现一个滑动卡片的效果,楼主花了一点时间自己实现了一下,使用是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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,444评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,421评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,036评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,363评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,460评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,502评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,511评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,280评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,736评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,014评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,190评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,848评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,531评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,159评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,411评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,067评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,078评论 2 352

推荐阅读更多精彩内容