RecyclerView進階之層疊列表(上)

大頭呆發表於2017-12-12

前言

上週五寫了篇仿夸克瀏覽器底部工具欄,相信看過的同學還有印象吧。在文末我丟擲了一個問題,夸克瀏覽器底部工具欄只是單層層疊的ViewGroup,如何實現類似Android系統通知欄的多級層疊列表呢?

RecyclerView進階之層疊列表(上)
不過當時僅僅有了初步的思路:recyclerView+自定義layoutManager,所以週末又把自定義layoutManager狠補了一遍。終於大致實現了這個效果(當然細節有待優化( ̄. ̄))。老樣子,先來看看效果吧:

RecyclerView進階之層疊列表(上)
實際使用時可能不需要頂部層疊,所以還有單邊效果,看起來更自然些:

RecyclerView進階之層疊列表(上)
怎麼樣,乍一看是不是非常形(神)似呢?以上的效果都是自定義layoutManager實現的,所以只要一行程式碼就能把普通的RecyclerView替換成這種層疊列表:

mRecyclerView.setLayoutManager(new OverFlyingLayoutManager());
複製程式碼

好了廢話不多說,直接來分析下怎麼實現吧。以下的主要內容就是幫你從學會到熟悉自定義layoutManager

概述

先簡單說下自定義layoutManager的步驟吧,其實很多文章都講過,適合沒接觸的同學:

  • 實現generateDefaultLayoutParams()方法,生成自己所定義擴充套件的LayoutParams
  • onLayoutChildren()中實現初始列表中各個itemView的位置
  • scrollVerticallyBy()scrollHorizontallyBy()中處理橫向和縱向滾動,還有view的回收複用。

個人理解就是:layoutManager就相當於自定義ViewGroup中把onMeasure()onlayout()scrollTo()等方法獨立出來,單獨交給它來做。實際表現也是類似:onLayoutChildren()作用就是測量放置itemView

初始化列表

我們先實現自己的佈局引數:

  @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
    return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    }

複製程式碼

也就是不實現,自帶的RecyclerView.LayoutParams繼承自ViewGroup.MarginLayoutParams,已經夠用了。通過檢視原始碼,最終這個方法返回的佈局引數物件會設定給:

holder.itemView.setLayoutParams(rvLayoutParams);
複製程式碼

然後實現onLayoutChildren(),在裡面要把所有itemView沒滑動前自身應該在的位置都記錄並放置一遍: 定義兩個集合:

  // 用於儲存item的位置資訊
    private SparseArray<Rect> allItemRects = new SparseArray<>();
    // 用於儲存item是否處於可見狀態的資訊
    private SparseBooleanArray itemStates = new SparseBooleanArray();
複製程式碼

把所有View虛擬地放置一遍,記錄下每個view的位置資訊,因為此時並沒有把View真正到recyclerview中,也是不可見的:

   private void calculateChildrenSiteVertical(RecyclerView.Recycler recycler, RecyclerView.State state) {
    // 先把所有的View先從RecyclerView中detach掉,然後標記為"Scrap"狀態,表示這些View處於可被重用狀態(非顯示中)。
     detachAndScrapAttachedViews(recycler);
        for (int i = 0; i < getItemCount(); i++) {
            View view = recycler.getViewForPosition(i);
            // 測量View的尺寸。
            measureChildWithMargins(view, 0, 0);
            //去除ItemDecoration部分
            calculateItemDecorationsForChild(view, new Rect());
            int width = getDecoratedMeasuredWidth(view);
            int height = getDecoratedMeasuredHeight(view);

            Rect mTmpRect = allItemRects.get(i);
            if (mTmpRect == null) {
                mTmpRect = new Rect();
            }
            mTmpRect.set(0, totalHeight, width, totalHeight + height);
            totalHeight += height;
            // 儲存ItemView的位置資訊
            allItemRects.put(i, mTmpRect);
            // 由於之前呼叫過detachAndScrapAttachedViews(recycler),所以此時item都是不可見的
            itemStates.put(i, false);
        }

        addAndLayoutViewVertical(recycler, state, 0);
    }
複製程式碼

然後我們開始真正地新增View到RecyclerView中。為什麼不在記錄位置的時候新增呢?因為後新增的view如果和前面新增的view重疊,那麼後新增的view會覆蓋前者,和我們想要實現的層疊的效果是相反的,所以需要正向記錄位置資訊,然後根據位置資訊反向新增View:

   private void addAndLayoutViewVertical(RecyclerView.Recycler recycler, RecyclerView.State state) {
        int displayHeight = getWidth() - getPaddingLeft() - getPaddingRight();//計算recyclerView可以放置view的高度
        //反向新增
        for (int i = getItemCount() - 1; i >= 0; i--) {
            // 遍歷Recycler中儲存的View取出來
            View view = recycler.getViewForPosition(i);
            //因為剛剛進行了detach操作,所以現在可以重新新增
            addView(view); 
            //測量view的尺寸
            measureChildWithMargins(view, 0, 0); 
            int width = getDecoratedMeasuredWidth(view); // 計算view實際大小,包括了ItemDecorator中設定的偏移量。
            int height = getDecoratedMeasuredHeight(view);
            //呼叫這個方法能夠調整ItemView的大小,以除去ItemDecorator距離。
            calculateItemDecorationsForChild(view, new Rect());
             Rect mTmpRect = allItemRects.get(i);//取出我們之前記錄的位置資訊
            if (mTmpRect.bottom > displayHeight) {
                //排到底了,後面統一置底
                layoutDecoratedWithMargins(view, 0, displayHeight - height, width, displayHeight);
            } else {
                //按原位置放置
                 layoutDecoratedWithMargins(view, 0,  mTmpRect.top, width, mTmpRect.bottom);
            }
        Log.e(TAG, "itemCount = " + getChildCount());
    }
複製程式碼

這樣一來,編譯執行,介面上已經能看到列表了,就是它還不能滾動,只能停留在頂部。

處理滾動

先設定允許縱向滾動:

 @Override
    public boolean canScrollVertically() {
        // 返回true表示可以縱向滑動
        return orientation == OrientationHelper.VERTICAL;
    }
複製程式碼

處理滾動原理其實很簡單:

  1. 手指在螢幕上滑動,系統告訴我們一個滑動的距離
  2. 我們根據這個距離判斷我們列表內部各個view的實際變化,然後和onLayoutChildren()一樣重新佈局就行
  3. 返回告訴系統我們滑動了多少,如果返回0,就說明滑到邊界了,就會有一個邊緣的波紋效果。
 @Override
    public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
        //列表向下滾動dy為正,列表向上滾動dy為負,這點與Android座標系保持一致。
        //dy是系統告訴我們手指滑動的距離,我們根據這個距離來處理列表實際要滑動的距離
        int tempDy = dy;
        //最多滑到總距離減去列表距離的位置,即可滑動的總距離是列表內容多餘的距離
        if (verticalScrollOffset <= totalHeight - getVerticalSpace()) {
            //將豎直方向的偏移量+dy
            verticalScrollOffset += dy;
        }
        if (verticalScrollOffset > totalHeight - getVerticalSpace()) {
            verticalScrollOffset = totalHeight - getVerticalSpace();
            tempDy = 0;//滑到底部了,就返回0,說明到邊界了
        } else if (verticalScrollOffset < 0) {
            verticalScrollOffset = 0;
            tempDy = 0;//滑到頂部了,就返回0,說明到邊界了
        }
        //重新佈局位置、顯示View
        addAndLayoutViewVertical(recycler, state, verticalScrollOffset); 
        return tempDy;
    }
複製程式碼

上面說了,滾動其實就是根據滑動距離重新佈局的過程,和onLayoutChildren()中的初始化佈局沒什麼兩樣。我們擴充套件布局方法,傳入偏移量,這樣onLayoutChildren()呼叫時只要傳0就行了:

  private void addAndLayoutViewVertical(RecyclerView.Recycler recycler, RecyclerView.State state, int offset) {
      
        int displayHeight = getVerticalSpace();
        for (int i = getItemCount() - 1; i >= 0; i--) {
            // 遍歷Recycler中儲存的View取出來
            View view = recycler.getViewForPosition(i);
            addView(view); // 因為剛剛進行了detach操作,所以現在可以重新新增
            measureChildWithMargins(view, 0, 0); // 通知測量view的margin值
            int width = getDecoratedMeasuredWidth(view); // 計算view實際大小,包括了ItemDecorator中設定的偏移量。
            int height = getDecoratedMeasuredHeight(view);

            Rect mTmpRect = allItemRects.get(i);
            //呼叫這個方法能夠調整ItemView的大小,以除去ItemDecorator。
            calculateItemDecorationsForChild(view, new Rect());

            int bottomOffset = mTmpRect.bottom - offset;
            int topOffset = mTmpRect.top - offset;
            if (bottomOffset > displayHeight) {//滑到底了
                layoutDecoratedWithMargins(view, 0, displayHeight - height, width, displayHeight);
            } else {
                if (topOffset <= 0 ) {//滑到頂了
                    layoutDecoratedWithMargins(view, 0, 0, width, height);
                } else {//中間位置
                    layoutDecoratedWithMargins(view, 0, topOffset, width, bottomOffset);
                }
            }
        Log.e(TAG, "itemCount = " + getChildCount());
    }
複製程式碼

好了,這樣就能滾動了。

小結

因為自定義layoutManager內容比較多,所以我分成了上下篇來講。到這裡基礎效果實現了,但是這個RecyclerView還沒有實現回收複用(參看addAndLayoutViewVertical末尾列印),還有邊緣的層疊巢狀動畫和視覺處理也都留到下篇說了。看了上面的內容,實現橫向滾動也是很簡單的,感興趣的自己去github上看下實現吧!

Github地址

RecyclerView進階之層疊列表(上)

PS:下篇內容請戳:

RecyclerView進階之層疊列表(下)

相關文章