1. 程式人生 > >深入理解 RecyclerView 系列之一:ItemDecoration

深入理解 RecyclerView 系列之一:ItemDecoration

RecyclerView 已經推出了一年多了,日常開發中也已經徹底從 ListView 遷移到了 RecyclerView,但前兩天有人在一個安卓群裡面問了個關於最頂上的 item view 加蒙層的問題,被人用 ItemDecoration 完美解決。此時我發現自己對 RecyclerView 的使用一直太過基本,更深入更強大的功能完全沒有涉及,像 ItemDecoration, ItemAnimator, SmoothScroller, OnItemTouchListener, LayoutManager 之類,以及 RecyclerView 重用 view 的原理。網上也有很多對 RecyclerView 使用的講解部落格,要麼講的內容非常少,要麼提到了高階功能,但是並沒講程式碼為什麼這樣寫,每個方法和引數的含義是什麼,像

張鴻洋的部落格,也講了 ItemDecoration 的使用,但是看了仍然雲裡霧裡,只能把他的程式碼拿來用,並不能根據自己的需求編寫自己的 ItemDecoration。

在這個系列中,我將對上述各個部分進行深入研究,目標就是看了這一系列的文章之後,開發者可以清楚快捷的根據自己的需求,編寫自己需要的各個高階模組。本系列第一篇就聚焦在:RecyclerView.ItemDecoration。本文涉及到的完整程式碼可以在 Github 獲取

TL; DR

  • getItemOffsets 中為 outRect 設定的4個方向的值,將被計算進所有 decoration 的尺寸中,而這個尺寸,被計入了 RecyclerView 每個 item view 的 padding 中
  • 在 onDraw 為 divider 設定繪製範圍,並繪製到 canvas 上,而這個繪製範圍可以超出在 getItemOffsets 中設定的範圍,但由於 decoration 是繪製在 child view 的底下,所以並不可見,但是會存在 overdraw
  • decoration 的 onDraw,child view 的 onDraw,decoration 的 onDrawOver,這三者是依次發生的
  • onDrawOver 是繪製在最上層的,所以它的繪製位置並不受限制

RecyclerView.ItemDecoration

這個類包含三個方法 1

  • onDraw(Canvas c, RecyclerView parent, State state)
  • onDrawOver(Canvas c, RecyclerView parent, State state)
  • getItemOffsets(Rect outRect, View view, RecyclerView parent, State state)

getItemOffsets

if (mOrientation == VERTICAL_LIST) {
    outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
} else {
    outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
}

這個outRect設定的四個值是什麼意思呢?先來看看它是在哪裡呼叫的,它在RecyclerView中唯一被呼叫的地方就是 getItemDecorInsetsForChild(View child) 函式。

Rect getItemDecorInsetsForChild(View child) {
    final LayoutParams lp = (LayoutParams) child.getLayoutParams();
    if (!lp.mInsetsDirty) {
        return lp.mDecorInsets;
    }

    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;
}

可以看到,getItemOffsets 函式中設定的值被加到了 insets 變數中,並被該函式返回,那麼 insets 又是啥呢?

insets 是啥?

根據Inset Drawable文件,它的使用場景是:當一個view需要的背景小於它的邊界時。例如按鈕圖示較小,但是我們希望按鈕有較大的點選熱區,一種做法是使用ImageButton,設定background="@null",把圖示資源設定給src屬性,這樣ImageButton可以大於圖示,而不會導致圖示也跟著拉伸到ImageButton那麼大。那麼使用Inset drawable也能達到這樣的目的。但是相比之下有什麼優勢呢?src屬性也能設定selector drawable,所以點選態也不是問題。也許唯一的優勢就是更“優雅”吧 :)

回到正題,getItemDecorInsetsForChild 函式中會重置 insets 的值,並重新計算,計算方式就是把所有 ItemDecoration 的 getItemOffsets 中設定的值累加起來 2,而這個 insets 實際上是 RecyclerView 的 child 的 LayoutParams 中的一個屬性,它會在 getTopDecorationHeight, getBottomDecorationHeight 等函式中被返回,那麼這個 insets 的意義就很明顯了,它記錄的是所有 ItemDecoration 所需要的 3尺寸的總和。

而在 RecyclerView 的 measureChild(View child, int widthUsed, int heightUsed) 函式中,呼叫了 getItemDecorInsetsForChild,並把它算在了 child view 的 padding 中。

public void measureChild(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() + widthUsed, lp.width,
            canScrollHorizontally());
    final int heightSpec = getChildMeasureSpec(getHeight(), getHeightMode(),
            getPaddingTop() + getPaddingBottom() + heightUsed, lp.height,
            canScrollVertically());
    if (shouldMeasureChild(child, widthSpec, heightSpec, lp)) {
        child.measure(widthSpec, heightSpec);
    }
}

上面這段程式碼中呼叫 getChildMeasureSpec 函式的第三個引數就是 child view 的 padding,而這個引數就把 insets 的值算進去了。那麼現在就可以確認了,getItemOffsets 中為 outRect 設定的4個方向的值,將被計算進所有 decoration 的尺寸中,而這個尺寸,被用來計算 RecyclerView 每個 item view 的大小(包括 item view 的寬高,padding,以及這個 insets)

PoC

這一步測試主要是對 getItemOffsets 函式傳入的 outRect 引數各個值的設定,以證實上述分析的結論。

getItemOffsets測試結果

可以看到,當 left, top, right, bottom 全部設定為50時,RecyclerView 的每個 item view 各個方向的 padding 都增加了,對比各種情況,確實 getItemOffsets 中為 outRect 設定的值都將被計入 RecyclerView 每個 item view 的 padding 中。

onDraw

public void drawVertical(Canvas c, RecyclerView parent) {
    final int left = parent.getPaddingLeft();
    final int right = parent.getWidth() - parent.getPaddingRight();
    final int childCount = parent.getChildCount();
    for (int i = 0; i < childCount; i++) {
        final View child = parent.getChildAt(i);
        final RecyclerView.LayoutParams params = (RecyclerView.LayoutParams) child
                .getLayoutParams();
        final int top = child.getBottom() + params.bottomMargin +
                Math.round(ViewCompat.getTranslationY(child));
        final int bottom = top + mDivider.getIntrinsicHeight();
        mDivider.setBounds(left, top, right, bottom);
        mDivider.draw(c);
    }
}

drawVertical 是為縱向的 RecyclerView 繪製 divider,遍歷每個 child view 4 ,把 divider 繪製到 canvas 上,而 mDivider.setBounds 則設定了 divider 的繪製範圍。其中,left 設定為 parent.getPaddingLeft(),也就是左邊是 parent 也就是 RecyclerView 的左邊界加上 paddingLeft 之後的位置,而 right 則設定為了 RecyclerView 的右邊界減去 paddingRight 之後的位置,那這裡左右邊界就是 RecyclerView 的內容區域 5了。top 設定為了 child 的 bottom 加上 marginBottom 再加上 translationY,這其實就是 child view 的下邊界 6,bottom 就是 divider 繪製的下邊界了,它就是簡單地 top 加上 divider 的高度。

PoC

這一步測試主要是對 onDraw 函式中對 divider 的繪製邊界的設定。

onDraw 測試結果

可以看到,當我們把 left, right, top 7 設定得和官方樣例一樣,bottom 設定為 top + 25,注意,這裡 getItemOffsets 對 outSets 的設定只有 bottom = 50,也就是 decoration 高度為50,我們可以看到,decoration 的上半部分就繪製為黑色了,下半部分沒有繪製。而如果設定top = child.getBottom() + params.bottomMargin - 25bottom = top + 50,就會發現 child view 的底部出現了 overdraw。所以這裡我們可以得出結論:在 onDraw 為 divider 設定繪製範圍,並繪製到 canvas 上,而這個繪製範圍可以超出在 getItemOffsets 中設定的範圍,但由於 decoration 是繪製在 child view 的底下,所以並不可見,但是會存在 overdraw

onDrawOver

有一點需要注意:decoration 的 onDraw,child view 的 onDraw,decoration 的 onDrawOver,這三者是依次發生的。而由於 onDrawOver 是繪製在最上層的,所以它的繪製位置並不受限制(當然,decoration 的 onDraw 繪製範圍也不受限制,只不過不可見),所以利用 onDrawOver 可以做很多事情,例如為 RecyclerView 整體頂部繪製一個蒙層,或者為特定的 item view 繪製蒙層。這裡就不單獨進行測試了,請見下一節的整體效果。

All in together

實現的效果:除了最後一個 item view,底部都有一個高度為25的黑色 divider,為整個 RecyclerView 的頂部繪製了一個漸變的蒙層。效果圖如下:

整體效果

小結

  • getItemOffsets 中為 outRect 設定的4個方向的值,將被計算進所有 decoration 的尺寸中,而這個尺寸,被計入了 RecyclerView 每個 item view 的 padding 中
  • 在 onDraw 為 divider 設定繪製範圍,並繪製到 canvas 上,而這個繪製範圍可以超出在 getItemOffsets 中設定的範圍,但由於 decoration 是繪製在 child view 的底下,所以並不可見,但是會存在 overdraw
  • decoration 的 onDraw,child view 的 onDraw,decoration 的 onDrawOver,這三者是依次發生的
  • onDrawOver 是繪製在最上層的,所以它的繪製位置並不受限制

完整可用的 divider 程式碼

2016.10.03 更新

import android.content.res.Resources;
import android.graphics.Canvas;
import android.graphics.Rect;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.support.annotation.ColorRes;
import android.support.annotation.DimenRes;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.view.View;

public class LinearLayoutColorDivider extends RecyclerView.ItemDecoration {
    private final Drawable mDivider;
    private final int mSize;
    private final int mOrientation;

    public LinearLayoutColorDivider(Resources resources, @ColorRes int color,
            @DimenRes int size, int orientation) {
        mDivider = new ColorDrawable(resources.getColor(color));
        mSize = resources.getDimensionPixelSize(size);
        mOrientation = orientation;
    }

    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        int left;
        int right;
        int top;
        int bottom;
        if (mOrientation == LinearLayoutManager.HORIZONTAL) {
            top = parent.getPaddingTop();
            bottom = parent.getHeight() - parent.getPaddingBottom();
            final int childCount = parent.getChildCount();
            for (int i = 0; i < childCount - 1; i++) {
                final View child = parent.getChildAt(i);
                final RecyclerView.LayoutParams params =
                        (RecyclerView.LayoutParams) child.getLayoutParams();
                left = child.getRight() + params.rightMargin;
                right = left + mSize;
                mDivider.setBounds(left, top, right, bottom);
                mDivider.draw(c);
            }
        } else {
            left = parent.getPaddingLeft();
            right = parent.getWidth() - parent.getPaddingRight();
            final int childCount = parent.getChildCount();
            for (int i = 0; i < childCount - 1; i++) {
                final View child = parent.getChildAt(i);
                final RecyclerView.LayoutParams params =
                        (RecyclerView.LayoutParams) child.getLayoutParams();
                top = child.getBottom() + params.bottomMargin;
                bottom = top + mSize;
                mDivider.setBounds(left, top, right, bottom);
                mDivider.draw(c);
            }
        }
    }

    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
            RecyclerView.State state) {
        if (mOrientation == LinearLayoutManager.HORIZONTAL) {
            outRect.set(0, 0, mSize, 0);
        } else {
            outRect.set(0, 0, 0, mSize);
        }
    }
}

腳註

  1. 不算被 Deprecated 的方法 

  2. 把 left, top, right, bottom 4個屬性分別累加 

  3. 也就是在 getItemOffsets 函式中為 outRect 引數設定的4個屬性值 

  4. child view,並不是 adapter 的每一個 item,只有可見的 item 才會繪製,才是 RecyclerView 的 child view 

  5. 可以類比 CSS 的盒子模型,一個 view 包括 content, padding, margin 三個部分,content 和 padding 加起來就是 view 的尺寸,而 margin 不會增加 view 的尺寸,但是會影響和其他 view 的位置間距,但是安卓的 view 沒有 margin 的合併 

  6. bottom 就是 content 的下邊界加上 paddingBottom,而為了不“吃掉” child view 的底部邊距,所以就加上 marginBottom,而 view 還能設定 translation 屬性,用於 layout 完成之後的再次偏移,同理,為了不“吃掉”這個偏移,所以也要加上 translationY 

  7. 這裡由於並沒有對 child view 設定 translation,為了程式碼簡短,就沒有減去 translationY,實際上是需要的