ItemDecoration深入解析與實戰(一)——原始碼分析

gminibird發表於2019-02-22

一 概述

ItemDecorationRecyclerView 中的一個抽象靜態內部類。

An ItemDecoration allows the application to add a special drawing and layout offset to specific item views from the adapter's data set. This can be useful for drawing dividers between items, highlights, visual grouping boundaries and more.

這是官網對 ItemDecoration 的描述,簡單來說就是可以為 RecyclerView的每一個 ItemView 進行一些特殊的繪製或者特殊的佈局。從而我們可以為 RecyclerView 新增一些實用好玩的效果,比如分割線,邊框,飾品,粘性頭部等。

此文會分析ItemDecoration 的使用及原理,然後進行一些Demo的實現,包括分割線,網格佈局的邊框,以及粘性頭部。

二 方法

1. 方法概述

ItemDecoration中的實際方法只有6個,其中有3個是過載方法,都被標註為 @deprecated,即棄用了,這些方法如下

修飾符 返回值型別 方法名 標註
void public onDraw(Canvas c, RecyclerView parent, State state)
void public onDraw(Canvas c, RecyclerView parent) @deprecated
void pulbic onDrawOver(Canvas c, RecyclerView parent, State state)
void public onDrawOver(Canvas c, RecyclerView parent) @deprecated
void public getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)
void public getItemOffsets(Rect outRect, View view, RecyclerView parent) @deprecated

2. getItemOffsets

除了 getItemOffsets 方法,其他方法的預設實現都為空,而 getItemOffsets 的預設實現方法也很簡單:

        @Deprecated
        public void getItemOffsets(Rect outRect, int itemPosition, RecyclerView parent) {
            outRect.set(0, 0, 0, 0);
        }
複製程式碼

兩個getItemOffsets方法最終都是呼叫了上面實現,就一行程式碼,如果我們自定義過 ItemDecoration 的話,就會知道,我們可以為 outRect 設定四邊的大小來為 itemView 設定一個偏移量. 這個偏移量有點類似於 View 的margin,看下面的圖1:

RecyclerView&Child.png

圖片很清晰的表示了 ItemView 的結構(該圖不是特別精確,後面會說到),這是隻有一個 Child 的情況,我們從外往裡看:

  1. 最外的邊界即 RecyclerView 的邊界
  2. 紅色部分是 RecyclerView 的 Padding,這個我們應該能理解
  3. 橙色部分是我們為 ItemView 設定的 Margin,這個相信寫過佈局都能理解
  4. 藍色部分就是我們在 getItemOffsets方法中給 outRect物件設定的值
  5. 最後的的黃色部分就是我們的 ItemView 了

總體就是說,getItemOffsets中設定的值就相當於 margin 的一個存在。"圖說無憑",接下來就結合原始碼講解一下這個圖的"依據"。首先看一下 getItemOffsets在哪裡被呼叫了:

 Rect getItemDecorInsetsForChild(View child) {
        final LayoutParams lp = (LayoutParams) child.getLayoutParams();
        ...
        final Rect insets = lp.mDecorInsets;
        insets.set(0, 0, 0, 0);
        final int decorCount = mItemDecorations.size();
        for (int i = 0; i < decorCount; i++) {
            mTempRect.set(0, 0, 0, 0);
            mItemDecorations.get(i).getItemOffsets(mTempRect, child, this, mState);  //被呼叫
            insets.left += mTempRect.left;
            insets.top += mTempRect.top;
            insets.right += mTempRect.right;
            insets.bottom += mTempRect.bottom;
        }
        lp.mInsetsDirty = false;
        return insets;
 }
複製程式碼

RecyclerView原始碼中,這是 getItemOffsets唯一被呼叫的地方,程式碼也很簡單,就是將 RecyclerView中所有的(即通過addDecoration()方法新增的) ItemDecoration 遍歷一遍,然後將我們設在 getItemOffsets 中設定的四個方向的值分別累加並儲存在insets這個Rect當中。那麼這個 insets又在哪裡被呼叫了呢,順著方法繼續跟蹤下去:

public void measureChildWithMargins(View child, int widthUsed, int heightUsed) {
    final LayoutParams lp = (LayoutParams) child.getLayoutParams();

    final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
    widthUsed += insets.left + insets.right;
    heightUsed += insets.top + insets.bottom;

    final int widthSpec = getChildMeasureSpec(getWidth(), getWidthMode(),
            getPaddingLeft() + getPaddingRight()
                    + lp.leftMargin + lp.rightMargin + widthUsed, lp.width,
            canScrollHorizontally());
    final int heightSpec = getChildMeasureSpec(getHeight(), getHeightMode(),
           getPaddingTop() + getPaddingBottom()
                    + lp.topMargin + lp.bottomMargin + heightUsed, lp.height,
            canScrollVertically());
    if (shouldMeasureChild(child, widthSpec, heightSpec, lp)) {
        child.measure(widthSpec, heightSpec);
    }
}
複製程式碼

我們看到,在 measureChildWithMargins方法中,將剛剛得到的 insets 的值與 Recyclerview 的 Padding 以及當前 ItemView 的 Margin 相加,然後作為 getChildMeasureSpec的第三個引數傳進去:

public static int getChildMeasureSpec(int parentSize, int parentMode, int padding,
    int childDimension, boolean canScroll) {
    int size = Math.max(0, parentSize - padding);
    int resultSize = 0;
    int resultMode = 0;
    //...省略部分程式碼
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}
複製程式碼

getChildMeasureSpec方法的第三個引數標註為 padding ,在方法體這個 padding 的作用就是計算出 size 這個值,這個 size是就是後面測量中 Child(ItemView) 能達到的最大值。

也就是說我們設定的 ItemView 的 Margin 以及ItemDecoration.getItemOffsets中設定的值到頭來也是跟 Parent 的 Padding 一起來計算 ItemView 的可用空間,也就印證了上面的圖片,在上面說了該圖不精確就是因為

  • parent-padding
  • layout_margin
  • insets(all outRect)

他們是一體的,並沒有劃分成一段一段這樣,圖中的outRect也應該改為insets,但是圖中的形式可以更方便我們理解。

3. onDraw

    public void onDraw(Canvas c, RecyclerView parent, State state) {
        onDraw(c, parent);
    }

    /**
     * @deprecated Override {@link #onDraw(Canvas, RecyclerView, RecyclerView.State)}
     */
    @Deprecated
    public void onDraw(Canvas c, RecyclerView parent) {
    }
複製程式碼

onDraw方法有兩個過載,一個被標註為 @deprecated,即棄用了,我們知道,如果重寫了 onDraw,就可以在我們上面的 getItemOffsets中設定的範圍內繪製,知其然還要知其所以然,我們看下原始碼裡面是怎樣實現的 #RecyclerView.java

    @Override
    public void onDraw(Canvas c) {
        super.onDraw(c);

        final int count = mItemDecorations.size();
        for (int i = 0; i < count; i++) {
            mItemDecorations.get(i).onDraw(c, this, mState);
        }
    }

複製程式碼

ReyclerViewonDraw方法中,將會把所有 DecorationonDraw方法呼叫一遍,而且會把Recyclerview#onDraw(Canvas)方法中的Canvas傳遞給Decoration#onDraw,也就是說我們在Decoration中拿到了整個 RecyclerView 的 Canvas,那麼我們基本就可以隨意繪製了,但是我們使用中會發現,我們繪製的區域如果在 ItemView 的範圍內就會被蓋住,這是為什麼呢?

由於View的繪製是先執行 draw(Canvas)再到onDraw(Canvas)的,我們複習一波自定義View的知識,看下View的繪製流程: #View.java

    public void draw(Canvas canvas) {
      
        /*
         * Draw traversal performs several drawing steps which must be executed
         * in the appropriate order:
         *
         *      1. Draw the background
         *      2. If necessary, save the canvas' layers to prepare for fading
         *      3. Draw view's content
         *      4. Draw children
         *      5. If necessary, draw the fading edges and restore layers
         *      6. Draw decorations (scrollbars for instance)
         */

        // Step 1, draw the background, if needed
        if (!dirtyOpaque) {
            drawBackground(canvas);
        }

        // skip step 2 & 5 if possible (common case)

        if (!verticalEdges && !horizontalEdges) {
            // Step 3, draw the content
            if (!dirtyOpaque) onDraw(canvas);   //註釋1

            // Step 4, draw the children
            dispatchDraw(canvas);        //註釋2
            ...
            // we're done...
            return;
        }
    }
複製程式碼

我們直接看註釋1與註釋2那段,可以看到,View的繪製是先繪製自身(onDraw呼叫),然後再繪製child,所以我們在 Decoration#onDraw中繪製的介面會被 ItemView 遮擋也是理所當然了。

所以我們在繪製中就要計算好繪製的範圍,使繪製範圍在上面彩圖中藍色區域內,即getItemOffsets設定的範圍內,避免沒有顯示或者過分繪製的情況。

4.onDrawOver

    public void onDrawOver(Canvas c, RecyclerView parent, State state) {
        onDrawOver(c, parent);
    }

    /**
     * @deprecated
     * Override {@link #onDrawOver(Canvas, RecyclerView, RecyclerView.State)}
     */
    @Deprecated
    public void onDrawOver(Canvas c, RecyclerView parent) {
    }
複製程式碼

onDrawOveronDraw非常類似,也是兩個過載,一個被棄用了,看名稱我們就基本能知道這個方法的用途,它是用於補充 onDraw 的一個方法,由於onDraw會被 ItemView 覆蓋,所以我們想要繪製一些漂浮在RecyclerView頂層的裝飾就無法實現,所以就有了這個方法,他是在 ItemView 繪製完畢後才會被呼叫的,看下原始碼的實現: #RecyclerView.java

@Override
    public void draw(Canvas c) {
        super.draw(c);

        final int count = mItemDecorations.size();
        for (int i = 0; i < count; i++) {
            mItemDecorations.get(i).onDrawOver(c, this, mState);
        }
        ...
    }
複製程式碼

super.draw(c) 就是我們在上面分析的View#draw(Canvas)方法,會呼叫一系列的繪製流程,包括onDraw(ItemDecoration的onDraw)以及dispatchDraw(ItemView的繪製),走完這些流程後才會呼叫Decoration#onDrawOver方法.

到此,我們就可以得出 onDraw>dispatchDraw(ItemView的繪製)>onDrawOver的執行流程。

5. 總結

  1. getItemOffsets用於提供一些空間(類似Margin)給 onDraw繪製
  2. onDraw方法繪製的內容如果在 ItemView 的區域則可能被覆蓋(沒效果)
  3. onDraw>dispatchDraw(ItemView的繪製)>onDrawOver從左到右執行

三 實戰

實戰將會從易到難進行幾個小的Demo練習。 由於這篇文章內容已經比較充實了,就把實戰部分放到下篇講解。

感謝你的閱讀,由於水平有限,如有錯誤懇請提醒。

相關文章