2018年7月28日 星期六

RecyclerView 相關知識 -- ItemDecoration 分析

RecyclerView 是 Google 2014 年提出的一個用來代替 ListView 的元件,它帶來了很多優秀的特性,但同時也移除了一些在 ListView 中很方便的功能,分隔線就是其中之一

在 ListView 中要添加分隔線是很容易的,只需要在 xml 中增加設定一些 divider 屬性即可,但在 RecyclerView 中可沒辦法用 xml 就設置好分隔線,不過 Google 也有提供替代分案讓我們使用,那就是 ItemDecoration

什麼是 ItemDecoration

ItemDecoration 就如同字面意思一樣,是一個可以讓開發者對 RecyclerView 中的 item 作修飾的工具,而它的實現方式,其實就是提供一些關鍵 API 讓開發者實作,RecyclerView 在繪製內容時會呼叫這些 API,開發者在 API 中對 RecyclerView 的畫布添加想對 item 作修飾的內容

用講的可能還是太抽象,以下就先來看 ItemDecoration 提供了哪些 API

ItemDecoration 的關鍵 API

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

其實主要就三個重要的 API,其中 onDraw 跟 onDrawOver 很像,兩者的差別只在於 onDraw 會在 RecyclerView 繪製 item 之前呼叫,而 onDrawOver 會在繪製 item 之後呼叫,所以如果沒有計算好距離而讓內容重疊的話,在 onDraw 內繪製的內容是會被 item 給蓋掉的

Android 預先實作的好了一些 ItemDecoration 讓開發者使用,以下用官方提供的 DividerItemDecoration 當範例來解析這些 API 的用法和意義

getItemOffsets

首先是 getItemOffsets,它的作用就是讓開發者設定你的 ItemDecoration 會占用的大小,來讓系統預先幫你保留這些空間,看一下 Android 提供的 DividerItemDecoration 裡面的實作
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
        RecyclerView.State state) {
    if (mOrientation == VERTICAL) {
        outRect.set(0, 0, 0, mDivider.getIntrinsicHeight());
    } else {
        outRect.set(0, 0, mDivider.getIntrinsicWidth(), 0);
    }
}
RecyclerView 會對每個 item 呼叫 getItemOffsets,透過 outRect 可以設置對於該 item 你希望保留的空間,可以看到 DividerItemDecoration 在縱向列表時在 bottom 設置了 mDivider 的高度,因此系統在繪製 item 時會在每個 item 的 bottom 都多保留這個距離來讓 divider 繪製在內

來看一下 getItemOffsets 是在 RecyclerView 哪裡被呼叫
Rect getItemDecorInsetsForChild(View child) {
    final LayoutParams lp = (LayoutParams) child.getLayoutParams();
    if (!lp.mInsetsDirty) {
        return lp.mDecorInsets;
    }

    if (mState.isPreLayout() && (lp.isItemChanged() || lp.isViewInvalid())) {
        // changed/invalid items should not be updated until they are rebound.
        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;
}
在 getItemDecorInsetsForChild 中會對每個 ItemDecoration 呼叫一次 getItemOffsets,並且將得到的上下左右偏移量加總到 child LayoutParams 的 mDecorInsets 變數中

因此,每個 item 都記錄了所有 ItemDecoration 會用到的空間大小加總,在 LayoutManager 要進行 layout 時,會把這些空間保留下來以供 ItemDecoration 使用

onDraw

在 onDraw 裡面開發者需將 ItemDecoration 繪製在 RecyclerView 畫布上,來看一下 DividerItemDecoration 的實作
@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
    if (parent.getLayoutManager() == null) {
        return;
    }
    if (mOrientation == VERTICAL) {
        drawVertical(c, parent);
    } else {
        drawHorizontal(c, parent);
    }
}

private void drawVertical(Canvas canvas, RecyclerView parent) {
    canvas.save();
    final int left;
    final int right;
    if (parent.getClipToPadding()) {
        left = parent.getPaddingLeft();
        right = parent.getWidth() - parent.getPaddingRight();
        canvas.clipRect(left, parent.getPaddingTop(), right,
                parent.getHeight() - parent.getPaddingBottom());
    } else {
        left = 0;
        right = parent.getWidth();
    }

    final int childCount = parent.getChildCount();
    for (int i = 0; i < childCount; i++) {
        final View child = parent.getChildAt(i);
        parent.getDecoratedBoundsWithMargins(child, mBounds);
        final int bottom = mBounds.bottom + Math.round(ViewCompat.getTranslationY(child));
        final int top = bottom - mDivider.getIntrinsicHeight();
        mDivider.setBounds(left, top, right, bottom);
        mDivider.draw(canvas);
    }
    canvas.restore();
}
這邊只關注縱向列表的實作方法,首先確認 divider 的左右位置,因為對縱向列表的分隔線來說左右位置是不會變的

用 getClipToPadding 查詢 RecyclerView 是否允許繪製在 padding 的範圍中,若不允許則將 canvas 的繪製範圍用 clipRect 限縮在 padding 之內

接下來開始對每個 child 繪製它的分隔線,注意這邊的 getChildCount 並不是傳回 RecyclerView 中所有的 item 數,而是在螢幕上可見的 item 數

用 getDecoratedBoundsWithMargins 得到該 child 的邊界位置,注意這個邊界位置是已經包含了 margin 跟 ItemDecoration 的,接著考慮 View 可能有平移效果,所以再加上 TranslationY,得到的值就是 divider 的 bottom 了

這邊可能會有點奇怪為什麼得到的值是 divider bottom 而不是 top 呢?別忘了之前的 getItemOffsets,RecyclerView 已經幫我們保留了 ItemDecoration 的空間了,所以這邊的值才會是 divider 的 bottom

最後,已經得到 divider 的範圍了,就在 canvas 中繪製即可

同時使用多個 ItemDecoration

瞭解了 ItemDecoration 的概念,這邊有了個衍伸議題,RecyclerView 可不可以有多個 ItemDecoration 呢?答案是可以的,回顧上面的 getItemDecorInsetsForChild 函式就可以發現,RecyclerView 將 ItemDecoration 存在 list 中,而且在計算 mDecorInsets 時是將所有的 ItemDecoration 大小加總起來

而有多個 ItemDecoration 的話,繪製的順序也會是一個需要注意的問題,ItemDecoration 繪製的順序是照它們加入到 RecyclerView 的順序,若是有需要使用多個 ItemDecoration 時,要小心別讓後繪製的 ItemDecoration 把先前的 ItemDecoration 給蓋掉了

Reference

沒有留言:

張貼留言