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練習。
由於這篇文章內容已經比較充實了,就把實戰部分放到下篇講解。

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

相關文章