RecyclerView 擴展(三) - 使用ItemTouchHelper和LayoutManager實現(xiàn)滑動卡片效果

??最近樓主在忙碌于自己的畢設(shè)項目,在畢設(shè)當(dāng)中需要實現(xiàn)一個滑動卡片的效果,樓主花了一點時間自己實現(xiàn)了一下,使用是ItemTouchHelperLayoutManager方式實現(xiàn)的。我們先來看一下效果:


??上面的效果說難也不難,說不難呢,但是這里面又有很多的小細節(jié)需要注意。
??有人說,這動畫很好做啊,使用ViewPager就可以實現(xiàn)了,這是沒錯的,但是ViewPager一直有一個詬病--那就是View的復(fù)用性不高??紤]到性能,RecyclerView自然是當(dāng)之無愧的王者,既然我們學(xué)過RecyclerView,為什么不嘗試著實現(xiàn)的呢?

1. 效果分析

??看著這個動畫麻煩,其實我們將它分為兩個部分實現(xiàn)就非常簡單了。首先,每個ItemView是疊加樣式展現(xiàn)的,這個效果在我們常用到的LayoutManger沒有這種樣式,所以得需要我們自定義一個LayoutManager來實現(xiàn)一個這種樣式。這是其一。
??其二,滑動切換的效果怎么實現(xiàn)呢?還記得我們之前分析過ItemTouchHelper這個類嗎?這個類的作用是用來實現(xiàn)側(cè)滑刪除以及長按拖動的效果的,而這里切換卡片的效果就相當(dāng)于側(cè)滑刪除,只不過是側(cè)滑時做的動畫不一樣。這里的動畫主要包括卡片的位移和角度變化,而ItemTouchHelper怎么實現(xiàn)根據(jù)手指滑動來做相應(yīng)的動畫呢?答案就在onChildDraw方法里面。
??其實,我們從ItemTouchHelperonChildDraw方法里面就知道,原生只是做了水平位置的變化,所以,我們可以重寫這個方法,從而加上我們想要的動畫。
??這樣來分析,這個動畫是不是非常簡單呢?接下來,我們從看看代碼吧。

2. LayoutManager

??自定義LayoutManager的相關(guān)知識,我在RecyclerView 源碼分析(七) - 自定義LayoutManager及其相關(guān)組件的源碼分析文章里面已經(jīng)詳細的解釋了,這里我就不重復(fù)了。我們直接來看代碼吧,關(guān)鍵代碼在于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設(shè)置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可以側(cè)滑
                            mItemTouchHelper.startSwipe(childViewHolder);
                        }
                        return false;
                    }
                });
            } else {
                // 由于ItemView會復(fù)用,所以一定要設(shè)置null
                view.setOnTouchListener(null);
            }
        }
    }

??相信上面的代碼大家都能看的懂,這里我就不逐行的解釋了。但是有一點需要我們特別注意:

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

??這里我們是倒著添加View,也就是一個ItemView雖然在RecyclerView的內(nèi)部index為0,但是在Adapter中,卻是layoutCount - 1,這個在我們自定義ItemTouchHelper.Callback時,會有很大的作用。

3.ItemTouchHelper.Callback

??關(guān)于ItemTouchHelper的知識,我在RecyclerView 擴展(二) - 手把手教你認(rèn)識ItemTouchHelper文章里面已經(jīng)詳細的解釋過了,所以在這里我也不重復(fù)了。我們直接來看實現(xiàn)代碼,關(guān)鍵在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;
            }
            // 跟著角度旋轉(zhuǎn)
            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)));
            }
        }
    }

??上面代碼的作用我在注釋已經(jīng)解釋比較清楚了,這里就不解釋了。不過這里還需要一點:

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

??這里我縮放的也是0 ~ mMaxVisibleCount - 1的ItemView,請記住,這個不是ItemViewAdapter中的position,而是ItemViewRecyclerView內(nèi)部的index值。在前面的LayoutManager中,我已經(jīng)解釋過,這倆是反著的。所以這里應(yīng)該是0 ~ mMaxVisibleCount - 1。
??整個實現(xiàn)就是這么的簡單,其實還有坑沒有說,比如說:

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

??在clearView方法里面必須進行重置,因為ItemView是復(fù)用的,不重置的話會出問題的。
??在比如說,必須重寫isItemViewSwipeEnabled方法(雖然不重寫也沒有問題,但是官方文檔建議重寫):

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

4. 跟SwipeRefreshLayout事件沖突

??使用上面代碼來實現(xiàn)效果之后,我們會發(fā)現(xiàn)一個問題,如果將RecyclerView放在SwipeRefreshLayout內(nèi)部,會出現(xiàn)事件沖突。
??我簡單的描述一下事件沖突的情況:當(dāng)我們左右滑動時,這是正常的,每個ItemView都是正常的側(cè)換;但是一旦上下滑動時,正常來說應(yīng)該是SwipeRefreshLayout滑動,但是實際上還是ItemView在側(cè)滑。
??關(guān)于解決方案的話,我有兩種方案:1. 重寫SwipeRefreshLayoutonInterceptTouchEvent方法,進行事件攔截,讓事件不能傳遞到ItemView中;2. 取消手動調(diào)用ItemTouchHelperstartSwipe方法,讓ItemTouchHelper自己來判斷是否符合側(cè)滑的條件。
??這里,我特別的說明一下第一種方法。為什么要特別說明第一種方法呢?因為此方法有很大的問題:1. 會重寫SwipeRefreshLayout,這個造成了不必要的工作,這是其一;2. 重寫了SwipeRefreshLayout會破壞SwipeRefreshLayout的結(jié)構(gòu),這個才是最大的缺點。
??為什么重寫SwipeRefreshLayout會破壞它的結(jié)構(gòu)呢?我們可以從SwipeRefreshLayout的源碼看出來,SwipeRefreshLayout不會主動的攔截事件,因為SwipeRefreshLayout是通過嵌套滑動機制來實現(xiàn)滑動,如果我們在onInterceptTouchEvent方法里面進行事件攔截,就違背了SwipeRefreshLayout的設(shè)計。所以,第一種方法是特別不推薦的?。?!
??其次,我們來看看第二種方案的實現(xiàn)方式,第二種方案非常簡單,歸根結(jié)底就是兩句話:

  1. Callback里面不要重寫isItemViewSwipeEnabled方法,
  2. LayoutManager里面不要在每個ItemViewOnTouchListener里面調(diào)用ItemTouchHelperstartSwipe方法。

??我在這里簡單的解釋第二種方式為什么這樣做就不會沖突了,不過要了解為什么不沖突,必須得了解以前為什么會沖突。
??SwipeRefreshLayout本身不會攔截事件,所以所有的事件都可以傳遞到RecyclerView里面的每個ItemView里面。因為我們在OnTouchListener調(diào)用ItemTouchHelperstartSwipe表示選中了一個ItemView可以側(cè)滑,從而導(dǎo)致后面事件都會被該ItemView消費,進而導(dǎo)致了事件沖突。
??而取消startSwipe方法的調(diào)用,讓ItemTouchHelper自己來選中一個可以側(cè)滑的ItemView,ItemTouchHelper本身就處理了上下滑和左右滑的沖突的(如果沒有處理,RecyclerView的上下滑跟ItemView的側(cè)滑會沖突)。這就是第二種方式的原理。

5. 源碼

??為了方便大家的理解,我將自己的Demo代碼上傳到github,供大家參考:SlideCardDemo

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容