RecyclerView定製:通用ItemDecoration及全展開RecyclerView的實現

看書的小蝸牛發表於2019-02-28

Android L面世之後,Google就推薦在開發專案中使用RecyclerView來取代ListView,因為RecyclerView的靈活性跟效能都要比ListView更強,但是,帶來的問題也不少,比如:列表分割線都要開發者自己控制,再者,RecyclerView的測量與佈局的邏輯都委託給了自己LayoutManager來處理,如果需要對RecyclerView進行改造,相應的也要對其LayoutManager進行定製。本文主要就以以下場景給出RecyclerView使用參考:

RecyclerView的幾種常用場景

  • 如何實現帶分割線的列表式RecyclerView
  • 如何實現帶分割線網格式RecyclerView
  • 如何實現全展開的列表式RecyclerView(比如:巢狀到ScrollView中使用)
  • 如何實現全展開的網格式RecyclerView(比如:巢狀到ScrollView中使用)

先看一下實現樣式,為了方便控制,邊界的均不設定分割線,方便定製,如果需要可以採用Padding或者Margin來實現。 Github連線RecyclerItemDecoration

RecyclerView定製:通用ItemDecoration及全展開RecyclerView的實現
網格式列表樣式

RecyclerView定製:通用ItemDecoration及全展開RecyclerView的實現
全展開的網格式列表

RecyclerView定製:通用ItemDecoration及全展開RecyclerView的實現
全展開的線性列表

不同場景RecyclerView實現

預設的縱向列表式RecyclerView

首先看一下最簡單的縱向線性RecyclerView,一般用以下程式碼:

    LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
    linearLayoutManager.setOrientation(LinearLayoutManager.VERTICAL);
    mRecyclerView.setLayoutManager(linearLayoutManager);複製程式碼

以上就是最簡單的線性RecyclerView的實現,但預設不帶分割線,如果想要使用比如20dp的黑色作為分割線,就需要自己定製,Google為RecyclerView提供了ItemDecoration,它的作用就是為Item新增一些附屬資訊,比如:分割線,浮層等。

帶分割線的列表式RecyclerView–LinearItemDecoration

RecyclerView提供了addItemDecoration介面與ItemDecoration類用來定製分割線樣式,那麼,在RecyclerView原始碼中,是怎麼用使用ItemDecoration的呢。與普通View的繪製流程一致,RecyclerView也要經過measure->layout->draw,並且在measure、layout之後,就應該按照ItemDecoration的限制,為RecyclerView的分割線挪出空間。RecyclerView的measure跟Layout其實都是委託給自己的LayoutManager的,在LinearLayoutManager測量或者佈局時都會直接或者間接呼叫RecyclerView的measureChildWithMargins函式,而measureChildWithMargins函式會進一步找到addItemDecoration新增的ItemDecoration,通過其getItemOffsets函式獲取所需空間資訊,原始碼如下:

  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會首先通過getItemDecorInsetsForChild計算出每個child的ItemDecoration所限制的邊界資訊,之後將邊界所需的空間作為已用空間為child構造MeasureSpec,最後用MeasureSpec對child進行尺寸測量:child.measure(widthSpec, heightSpec);來看一下getItemDecorInsetsForChild函式:

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;
}複製程式碼

一般而言,不會同時設定多類ItemDecoration,太麻煩,對於普通的線性佈局列表,其實就簡單設定一個自定義ItemDecoration即可,其中outRect引數主要是控制每個Item上下左右的分割線所佔據的寬度跟高度,這個尺寸跟繪製的時候的尺寸應該對應(如果需要繪製的話),看一下LinearItemDecoration的getItemOffsets實現:

@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
    if (mOrientation == VERTICAL_LIST) {

    <!--垂直方向 ,最後一個不設定padding-->
        if (parent.getChildAdapterPosition(view) < parent.getAdapter().getItemCount()1) {
            outRect.set(0, 0, 0, mSpanSpace);
        } else {
            outRect.set(0, 0, 0, 0);
        }
    } else {
     <!--水平方向 ,最後一個不設定padding-->
        if (parent.getChildAdapterPosition(view) < parent.getAdapter().getItemCount()1) {
            outRect.set(0, 0, mSpanSpace, 0);
        } else {
            outRect.set(0, 0, 0, 0);
        }
    }
}   複製程式碼

measure跟layout之後,再來看一下RecyclerView的onDraw函式, RecyclerView在onDraw函式中會呼叫ItemDecoration的onDraw,繪製分割線或者其他輔助資訊,ItemDecoration 支援上下左右四個方向定製佔位分割線等資訊,具體要繪製的樣式跟位置都完全由開發者確定,所以自由度非常大,其實如果不是太特殊的需求的話,onDraw函式完全可以不做任何處理,僅僅用背景色就可以達到簡單的分割線的目的,當然,如果想要定製一些特殊的圖案之類的需話,就需要自己繪製,來看一下LinearItemDecoration的onDraw(只看Vertical的)

@Override
public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
    if (mOrientation == VERTICAL_LIST) {
        drawVertical(c, parent);
    } else {
       ...
    }
}複製程式碼

其實,如果不是特殊的繪製需求,比如顯示七彩的,或者圖片,完全不需要任何繪製,如果一定要繪製,注意繪製的尺寸區域跟原來getItemOffsets所限制的區域一致,繪製的區域過大不僅不會顯示出來,還會引起過度繪製的問題:

public void drawVertical(Canvas c, RecyclerView parent) {                               int totalCount = parent.getAdapter().getItemCount();               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 + mVerticalSpan;                           final int left = child.getLeft() + params.leftMargin;                   final int right = child.getRight() + params.rightMargin;                           if (!isLastRaw(parent, i, mSpanCount, totalCount))                       if (childCounti > mSpanCount) {                           drawable.setBounds(left, top, right, bottom);                           drawable.draw(c);            }        
    }        
}複製程式碼

帶分割線的網格式RecyclerView–GridLayoutItemDecoration

網格式RecyclerView的處理流程跟上面的線性列表類似,不過網格式的需要根據每個Item的位置為其設定好邊距,比如最左面的不需要左邊佔位,最右面的不需要右面的佔位,最後一行不需要底部的佔位,如下圖所示

RecyclerView定製:通用ItemDecoration及全展開RecyclerView的實現
網格式ItemDocration的限制

RecyclerView的每個childView都會通過getItemOffsets來設定自己ItemDecoration,對於網格式的RecyclerView,需要在四個方向上對其ItemDecoration進行限制,來看一下其實現類GridLayoutItemDecoration的getItemOffsets:

@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
    final int position = parent.getChildAdapterPosition(view);
    final int totalCount = parent.getAdapter().getItemCount();
    int left = (position % mSpanCount == 0) ? 0 : mHorizonSpan;
    int bottom = ((position + 1) % mSpanCount == 0) ? 0 : mVerticalSpan;
    if (isVertical(parent)) {
        if (!isLastRaw(parent, position, mSpanCount, totalCount)) {
            outRect.set(left, 0, 0, mVerticalSpan);
        } else {
            outRect.set(left, 0, 0, 0);
        }
    } else {
        if (!isLastColumn(parent, position, mSpanCount, totalCount)) {
            outRect.set(0, 0, mHorizonSpan, bottom);
        } else {
            outRect.set(0, 0, 0, bottom);
        }
    }
}複製程式碼

其實上面的程式碼就是根據RecyclerView滑動方向(橫向或者縱向)以及child的位置(是不是最後一行或者最後一列),對附屬區域進行限制,同樣,如果不是特殊的分割線樣式,通過背景就基本可以實現需求,不用特殊draw。

全展開的列表式RecyclerView–ExpandedLinearLayoutManager

RecyclerView全展開的邏輯跟分割線不同,全展開主要是跟measure邏輯相關,簡單看一下RecyclerView(v-22版本,相對簡單)的measure原始碼:

@Override
protected void onMeasure(int widthSpec, int heightSpec) {
        ...

        <!--關鍵程式碼,如果mLayout(LayoutManager)非空,就採用LayoutManager的mLayout.onMeasure-->
    if (mLayout == null) {
        defaultOnMeasure(widthSpec, heightSpec);
    } else {
        mLayout.onMeasure(mRecycler, mState, widthSpec, heightSpec);
    }

    mState.mInPreLayout = false; // clear
}複製程式碼

由以上程式碼可以看出,在為RecyclerView設定了LayoutManager之後,RecyclerView的measure邏輯其實就是委託給了它的LayoutManager,這裡以LinearLayoutManager為例,不過LinearLayoutManager原始碼裡面並沒有重寫onMeasure函式,也就是說,對於RecyclerView的線性樣式,對於尺寸的處理採用的是跟ViewGroup一樣的處理,完全由父控制元件限制,不過對於v-23裡面有了一些修改,就是增加了對wrap_content的支援。既然這樣,我們就可以把設定尺寸的時機放到LayoutManager的onMeasure中,對全展開的RecyclerView來說,其實就是將所有child測量一遍,之後將每個child需要高度或者寬度累加,看一下ExpandedLinearLayoutManager的實現:在測量child的時候,採用RecyclerView的measureChildWithMargins,該函式已經將ItemDecoration的佔位考慮進去,之後通過getDecoratedMeasuredWidth獲取真正需要佔用的尺寸。

 @Override
public void onMeasure(RecyclerView.Recycler recycler, RecyclerView.State state,
                      int widthSpec, int heightSpec) {
    final int widthMode = View.MeasureSpec.getMode(widthSpec);
    final int heightMode = View.MeasureSpec.getMode(heightSpec);
    final int widthSize = View.MeasureSpec.getSize(widthSpec);
    final int heightSize = View.MeasureSpec.getSize(heightSpec);
    int measureWidth = 0;
    int measureHeight = 0;
    int count;
    if (mMaxItemCount < 0 || getItemCount() < mMaxItemCount) {
        count = getItemCount();
    } else {
        count = mMaxItemCount;
    }
    for (int i = 0; i < count; i++) {
        int[] measuredDimension = getChildDimension(recycler, i);
        if (measuredDimension == null || measuredDimension.length != 2)
            return;
        if (getOrientation() == HORIZONTAL) {
            measureWidth = measureWidth + measuredDimension[0];
            measureHeight = Math.max(measureHeight, measuredDimension[1]);
        } else {
            measureHeight = measureHeight + measuredDimension[1];
            measureWidth = Math.max(measureWidth, measuredDimension[0]);
        }
    }
    measureHeight = measureHeight + getPaddingBottom() + getPaddingTop();
    measureWidth = measureWidth + getPaddingLeft() + getPaddingRight();
    measureHeight = heightMode == View.MeasureSpec.EXACTLY ? heightSize : measureHeight;
    measureWidth = widthMode == View.MeasureSpec.EXACTLY ? widthSize : measureWidth;
    if (getOrientation() == HORIZONTAL && measureWidth > widthSize) {
        if (widthMode == View.MeasureSpec.UNSPECIFIED) {
            setMeasuredDimension(measureWidth, measureHeight);
        } else {
            super.onMeasure(recycler, state, widthSize, heightSpec);
        }
    } else if (getOrientation() == VERTICAL && measureHeight > heightSize) {
        if (heightMode == View.MeasureSpec.UNSPECIFIED) {
            setMeasuredDimension(measureWidth, measureHeight);
        } else {
            setMeasuredDimension(measureWidth, heightSize);
        }
    } else {
        setMeasuredDimension(measureWidth, measureHeight);
    }
}


private int[] getChildDimension(RecyclerView.Recycler recycler, int position) {
    try {
        int[] measuredDimension = new int[2];
        View view = recycler.getViewForPosition(position);
        //測量childView,以便獲得寬高(包括ItemDecoration的限制)
        super.measureChildWithMargins(view, 0, 0);
        //獲取childView,以便獲得寬高(包括ItemDecoration的限制),以及邊距
        RecyclerView.LayoutParams p = (RecyclerView.LayoutParams) view.getLayoutParams();
        measuredDimension[0] = getDecoratedMeasuredWidth(view) + p.leftMargin + p.rightMargin;
        measuredDimension[1] = getDecoratedMeasuredHeight(view) + p.bottomMargin + p.topMargin;
        return measuredDimension;
    } catch (Exception e) {
        Log.d("LayoutManager", e.toString());
    }
    return null;
}複製程式碼

全展開的網格式RecyclerView–ExpandedGridLayoutManager

全展開的網格式RecyclerView的實現跟線性的十分相似,唯一不同的就是在確定尺寸的時候,不是將每個child的尺寸疊加,而是要將每一行或者每一列的尺寸疊加,這裡假定行高或者列寬都是相同的,其實在使用中這兩種場景也是最常見的,看如下程式碼,其實除了加了行與列判斷邏輯,其他基本跟上面的全展開線性的類似。

  @Override
public void onMeasure(RecyclerView.Recycler recycler, RecyclerView.State state, int widthSpec, int heightSpec) {
    final int widthMode = View.MeasureSpec.getMode(widthSpec);
    final int heightMode = View.MeasureSpec.getMode(heightSpec);
    final int widthSize = View.MeasureSpec.getSize(widthSpec);
    final int heightSize = View.MeasureSpec.getSize(heightSpec);
    int measureWidth = 0;
    int measureHeight = 0;
    int count = getItemCount();
    int span = getSpanCount();
    for (int i = 0; i < count; i++) {
        measuredDimension = getChildDimension(recycler, i);
        if (getOrientation() == HORIZONTAL) {
            if (i % span == 0 ) {
                measureWidth = measureWidth + measuredDimension[0];
            }
            measureHeight = Math.max(measureHeight, measuredDimension[1]);
        } else {
            if (i % span == 0) {
                measureHeight = measureHeight + measuredDimension[1];
            }
            measureWidth = Math.max(measureWidth, measuredDimension[0]);
        }
    }
    measureHeight = measureHeight + getPaddingBottom() + getPaddingTop();
    measureWidth = measureWidth + getPaddingLeft() + getPaddingRight();
    measureHeight = heightMode == View.MeasureSpec.EXACTLY ? heightSize : measureHeight;
    measureWidth = widthMode == View.MeasureSpec.EXACTLY ? widthSize : measureWidth;
    if (getOrientation() == HORIZONTAL && measureWidth > widthSize) {
        if (widthMode == View.MeasureSpec.UNSPECIFIED) {
            setMeasuredDimension(measureWidth, measureHeight);
        } else {
            super.onMeasure(recycler, state, widthSpec, heightSpec);
        }
    } else if (getOrientation() == VERTICAL && measureHeight > heightSize) {
        if (heightMode == View.MeasureSpec.UNSPECIFIED) {
            setMeasuredDimension(measureWidth, measureHeight);
        } else {
            super.onMeasure(recycler, state, widthSpec, heightSpec);
        }
    } else {
        setMeasuredDimension(measureWidth, measureHeight);
    }
}複製程式碼

最後附上橫向滑動效果圖:

RecyclerView定製:通用ItemDecoration及全展開RecyclerView的實現
橫向滑動

以上就是比較通用的RecyclerView使用場景及所做的相容 ,最後附上Github連結RecyclerItemDecoration,歡迎star,fork。

相關文章