RecyclerView自定義LayoutManager,打造不規則佈局
本文已授權微信公眾號:鴻洋(hongyangAndroid)在微信公眾號平臺原創首發。
RecyclerView的時代
自從google推出了RecyclerView
這個控制元件, 鋪天蓋地的一頓叫好, 開發者們也都逐漸從ListView
,GridView
等控制元件上轉移到了RecyclerView
上, 那為什麼RecyclerView
這麼受開發者的青睞呢? 一個主要的原因它的高靈活性, 我們可以自定義點選事件, 隨意切換顯示方式, 自定義item動畫, 甚至連它的佈局方式我們都可以自定義.
吐吐嘈
誇完了RecyclerView
, 我們再來吐槽一下大家在工作中各種奇葩需求, 大家在日常工作中肯定會遇到各種各種的奇葩需求, 這裡沒就包括奇形怪狀的需求的UI. 站在我們開發者的角度, 看到這些奇葩的UI, 心中無數只草泥馬呼嘯崩騰而過, 在憤憤不平的同時還不得不老老實實的去找解決方案… 好吧, 吐槽這麼多, 其實大家都沒有錯, 站在開發者的角度, 這樣的需求無疑增加了我們很多工作量, 不加班怎麼能完成? 但是站在老闆的角度, 他也是希望將產品做好, 所以才會不斷的思考改需求.
效果展示
開始進入正題, 今天我們的主要目的還是來自定義一個LayoutManager
, 實現一個奇葩的UI, 這樣的一個佈局我也是從我的一個同學的需求那看到的, 我們先來看看效果.
當然了, 效果不是很優雅, 主要是配色問題, 配色都是隨機的, 所以肯定沒有UI上好看. 原始需求是一個死的佈局, 當然用自定義View的形式可以完成, 但是我認為那樣不利於擴充套件, 例如效果圖上的從每組3個變成每組9個, 還有一點很重要, 就是用RecyclerView
我們還得輕鬆的利用View
的複用機制. 好了, UI我們就先介紹到這, 下面我們開始一步步的實現這個效果.
自定義LayoutManager
前面說了, 我們這個效果是利用自定義RecyclerView
的LayoutManager
實現的, 所以, 首先我們要準備一個類讓它繼承RecyclerView.LayoutManager
.
public class CardLayoutManager extends RecyclerView.LayoutManager {}
定義完成後, android studio會提醒我們去實現一下RecyclerView.LayoutManager
裡的一個抽象方法,
public class CardLayoutManager extends RecyclerView.LayoutManager {
@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
}
}
這樣, 其實一個最簡單的LayoutManager
我們就完成了, 不過現在在介面上是什麼也沒有的, 因為我們還沒有對item view進行佈局. 在開始佈局之前, 還有幾個引數需要我們從構造傳遞, 一個是每組需要顯示幾個, 一個當每組的總寬度小於RecyclerView
總寬度的時候是否要居中顯示, 來重寫一下構造方法.
public class CardLayoutManager extends RecyclerView.LayoutManager {
public static final int DEFAULT_GROUP_SIZE = 5;
// ...
public CardLayoutManager(boolean center) {
this(DEFAULT_GROUP_SIZE, center);
}
public CardLayoutManager(int groupSize, boolean center) {
mGroupSize = groupSize;
isGravityCenter = center;
mItemFrames = new Pool<>(new Pool.New<Rect>() {
@Override
public Rect get() { return new Rect();}
});
}
// ...
}
ok, 在完成準備工作後, 我們就開始著手準備進行item的佈局操作了, 在RecyclerView.LayoutManager
中佈局的入口是一個叫onLayoutChildren
的方法. 我們來重寫這個方法.
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getItemCount() <= 0 || state.isPreLayout()) { return;}
detachAndScrapAttachedViews(recycler);
View first = recycler.getViewForPosition(0);
measureChildWithMargins(first, 0, 0);
int itemWidth = getDecoratedMeasuredWidth(first);
int itemHeight = getDecoratedMeasuredHeight(first);
int firstLineSize = mGroupSize / 2 + 1;
int secondLineSize = firstLineSize + mGroupSize / 2;
if (isGravityCenter && firstLineSize * itemWidth < getHorizontalSpace()) {
mGravityOffset = (getHorizontalSpace() - firstLineSize * itemWidth) / 2;
} else {
mGravityOffset = 0;
}
for (int i = 0; i < getItemCount(); i++) {
Rect item = mItemFrames.get(i);
float coefficient = isFirstGroup(i) ? 1.5f : 1.f;
int offsetHeight = (int) ((i / mGroupSize) * itemHeight * coefficient);
// 每一組的第一行
if (isItemInFirstLine(i)) {
int offsetInLine = i < firstLineSize ? i : i % mGroupSize;
item.set(mGravityOffset + offsetInLine * itemWidth, offsetHeight, mGravityOffset + offsetInLine * itemWidth + itemWidth,
itemHeight + offsetHeight);
}else {
int lineOffset = itemHeight / 2;
int offsetInLine = (i < secondLineSize ? i : i % mGroupSize) - firstLineSize;
item.set(mGravityOffset + offsetInLine * itemWidth + itemWidth / 2,
offsetHeight + lineOffset, mGravityOffset + offsetInLine * itemWidth + itemWidth + itemWidth / 2,
itemHeight + offsetHeight + lineOffset);
}
}
mTotalWidth = Math.max(firstLineSize * itemWidth, getHorizontalSpace());
int totalHeight = getGroupSize() * itemHeight;
if (!isItemInFirstLine(getItemCount() - 1)) { totalHeight += itemHeight / 2;}
mTotalHeight = Math.max(totalHeight, getVerticalSpace());
fill(recycler, state);
}
這裡的程式碼很長, 我們一點點的來分析, 首先一個detachAndScrapAttachedViews
方法, 這個方法是RecyclerView.LayoutManager
的, 它的作用是將介面上的所有item都detach掉, 並快取在scrap中,以便下次直接拿出來顯示.
接下來我們通過一下程式碼來獲取第一個item view並測量它.
View first = recycler.getViewForPosition(0);
measureChildWithMargins(first, 0, 0);
int itemWidth = getDecoratedMeasuredWidth(first);
int itemHeight = getDecoratedMeasuredHeight(first);
為什麼只測量第一個view呢? 這裡是因為在我們的這個效果中所有的item大小都是一樣的, 所以我們只要獲取第一個的大小, 就知道所有的item的大小了. 另外還有個方法getDecoratedMeasuredWidth
, 這個方法是什麼意思? 其實類似的還有很多, 例如getDecoratedMeasuredHeight
, getDecoratedLeft
… 這個getDecoratedXXX
的作用就是獲取該view以及他的decoration
的值, 大家都知道RecyclerView
是可以設定decoration
的.
繼續程式碼
int firstLineSize = mGroupSize / 2 + 1;
int secondLineSize = firstLineSize + mGroupSize / 2;
這兩句主要是來獲取每一組中第一行和第二行中item的個數.
if (isGravityCenter && firstLineSize * itemWidth < getHorizontalSpace()) {
mGravityOffset = (getHorizontalSpace() - firstLineSize * itemWidth) / 2;
} else {
mGravityOffset = 0;
}
這幾行程式碼的作用是當設定了isGravityCenter為true, 並且每組的寬度小於recyclerView的寬度時居中顯示
.
接下來的一個if...else...
在if中的是判斷當前item是否在它所在組的第一行. 為什麼要加這個判斷? 大家看效果就知道了, 因為第二行的view的起始會有一個二分之一的item寬度的偏移, 而且相對於第一行, 第二行的高度是偏移了二分之一的item高度. 至於這裡面具體的邏輯大家可以對照著效果圖去看程式碼, 這裡就不一一解釋了.
再往下, 我們記錄了item的總寬度和總高度, 並且呼叫了fill
方法, 其實在這個onLayoutChildren
方法中我們僅僅記錄了所有的item view所在的位置, 並沒有真正的去layout它, 那真正的layout肯定是在這個fill
方法中了,
private void fill(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getItemCount() <= 0 || state.isPreLayout()) { return;}
Rect displayRect = new Rect(mHorizontalOffset, mVerticalOffset,
getHorizontalSpace() + mHorizontalOffset,
getVerticalSpace() + mVerticalOffset);
// Rect rect = new Rect();
// for (int i = 0; i < getChildCount(); i++) {
// View item = getChildAt(i);
// rect.left = getDecoratedLeft(item);
// rect.top = getDecoratedTop(item);
// rect.right = getDecoratedRight(item);
// rect.bottom = getDecoratedBottom(item);
// if (!Rect.intersects(displayRect, rect)) {
// removeAndRecycleView(item, recycler);
// }
// }
for (int i = 0; i < getItemCount(); i++) {
Rect frame = mItemFrames.get(i);
if (Rect.intersects(displayRect, frame)) {
View scrap = recycler.getViewForPosition(i);
addView(scrap);
measureChildWithMargins(scrap, 0, 0);
layoutDecorated(scrap, frame.left - mHorizontalOffset, frame.top - mVerticalOffset,
frame.right - mHorizontalOffset, frame.bottom - mVerticalOffset);
}
}
}
在這裡面, 我們首先定義了一個displayRect
, 他的作用就是標記當前顯示的區域, 因為RecyclerView
是可滑動的, 所以這個區域不能簡單的是0~高度/寬度這麼一個值, 我們還要加上當前滑動的偏移量.
接下來, 我們通過getChildCount
獲取RecyclerView
中的所有子view, 並且依次判斷這些view是否在當前顯示範圍內, 如果不再, 我們就通過removeAndRecycleView
將它移除並回收掉, recycle
的作用是回收一個view, 並等待下次使用, 這裡可能會被重新繫結新的資料. 而scrap
的作用是快取一個view, 並等待下次顯示, 這裡的view會被直接顯示出來.
ok, 繼續程式碼, 又一個for迴圈, 這裡是迴圈的getItemCount
, 也就是所有的item個數, 這裡我們依然判斷它是不是在顯示區域, 如果在, 則我們通過recycler.getViewForPosition(i)
拿到這個view, 並且通過addView
新增到RecyclerView
中, 新增進去了還沒完, 我們還需要呼叫measureChildWithMargins
方法對這個view進行測量. 最後的最後我們呼叫layoutDecorated
對item view進行layout操作.
好了, 我們來回顧一下這個fill
方法都是幹了什麼工作, 首先是回收操作, 這保證了RecyclerView
的子view僅僅保留可顯示範圍內的那幾個, 然後就是將這幾個view進行佈局.
現在我們來到MainActivity
中,
mRecyclerView = (RecyclerView) findViewById(R.id.list);
mRecyclerView.setLayoutManager(new CardLayoutManager(mGroupSize, true));
mRecyclerView.setAdapter(mAdapter);
然後大家就可以看到上面的效果了, 高興ing… 不過手指在螢幕上滑動的一瞬間, 高興就會變成納悶了. 納尼? 怎麼不能滑動呢? 好吧, 是因為我們的LayoutManager
沒有處理滑動操作, 是的, 滑動操作需要我們自己來處理…
讓RecyclerView動起來
要想讓RecyclerView能滑動, 我們需要重寫幾個方法.
public boolean canScrollVertically() {}
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {}
同樣的, 因為我們的LayoutManager
還支援橫向滑動, 所以還有
public boolean canScrollHorizontally() {}
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {}
我們先來看看豎直方向上的滑動處理.
public boolean canScrollVertically() {
return true;
}
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
detachAndScrapAttachedViews(recycler);
if (mVerticalOffset + dy < 0) {
dy = -mVerticalOffset;
} else if (mVerticalOffset + dy > mTotalHeight - getVerticalSpace()) {
dy = mTotalHeight - getVerticalSpace() - mVerticalOffset;
}
offsetChildrenVertical(-dy);
fill(recycler, state);
mVerticalOffset += dy;
return dy;
}
第一個方法返回true代表著可以在這個方法進行滑動, 我們主要是來看第二個方法.
首先我們還是先呼叫detachAndScrapAttachedViews
將所有的子view快取起來, 然後一個if...else...
判斷是做邊界檢測, 接著我們呼叫offsetChildrenVertical
來做偏移, 主要程式碼中這裡的引數, 是對scrollVerticallyBy
取反, 因為在scrollVerticallyBy
引數中這個dy
在我們手指往左滑動的時候是正值, 可能是google感覺這個做更加直觀吧. 接著我們還是呼叫fill
方法來做新的子view的佈局, 最後我們記錄偏移量並返回.
這裡面的邏輯還算簡單, 橫向滑動的處理邏輯也相同, 下面給出程式碼, 就不再贅述了.
public boolean canScrollHorizontally() {
return true;
}
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
detachAndScrapAttachedViews(recycler);
if (mHorizontalOffset + dx < 0) {
dx = -mHorizontalOffset;
} else if (mHorizontalOffset + dx > mTotalWidth - getHorizontalSpace()) {
dx = mTotalWidth - getHorizontalSpace() - mHorizontalOffset;
}
offsetChildrenHorizontal(-dx);
fill(recycler, state);
mHorizontalOffset += dx;
return dx;
}
ok, 現在我們再次執行程式, 發現RecyclerView
真的可以滑動了. 到現在位置我們的自定義LayoutManager
已經實現了. 不過那個菱形咋辦呢? 算了, 直接搞一張圖片上去就行了. 其實剛開始我也是這麼想的, 不過仔細想想, 一個普通的圖片是有問題的. 我們還是要通過自定義view的方式去實現.
來搞一搞那個菱形
上面提到了, 那個菱形用圖片是有問題的, 問題出在哪呢? 先來說答案吧: 點選事件. 說到這可能有些同學已經明白了, 也有一部分還在納悶中… 我們來具體分析一下. 首先來張圖.
大家看黃色框部分, 其實第三個view的佈局是在黃色框裡面的, 那如果我們點選第一個view的黃色框裡面的區域是不是就點選到第三個view上了? 而我們的感覺確是點選在了第一個上, 所以一個普通的view在這裡是不適用的. 根據這個問題, 我們再來想想自定義這個view的思路, 是不是只要我們在dispatchTouchEvent方法中來判斷點選的位置是不是在那個菱形中, 如果不在就返回false, 讓事件可以繼續在RecyclerView往下分發
就可以了?
下面我們根據這個思路來實現這麼個view.
public class CardItemView extends View {
private int mSize;
private Paint mPaint;
private Path mDrawPath;
private Region mRegion;
public CardItemView(Context context) {
this(context, null, 0);
}
public CardItemView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CardItemView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.FILL);
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.Card, defStyleAttr, 0);
mSize = ta.getDimensionPixelSize(R.styleable.Card_size, 10);
mPaint.setColor(ta.getColor(R.styleable.Card_bgColor, 0));
ta.recycle();
mRegion = new Region();
mDrawPath = new Path();
mDrawPath.moveTo(0, mSize / 2);
mDrawPath.lineTo(mSize / 2, 0);
mDrawPath.lineTo(mSize, mSize / 2);
mDrawPath.lineTo(mSize / 2, mSize);
mDrawPath.close();
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(mSize, mSize);
}
@Override
public boolean dispatchTouchEvent(MotionEvent event) {
if (event.getAction() == MotionEvent.ACTION_DOWN) {
if (!isEventInPath(event)) { return false;}
}
return super.dispatchTouchEvent(event);
}
private boolean isEventInPath(MotionEvent event) {
RectF bounds = new RectF();
mDrawPath.computeBounds(bounds, true);
mRegion.setPath(mDrawPath, new Region((int)bounds.left,
(int)bounds.top, (int)bounds.right, (int)bounds.bottom));
return mRegion.contains((int) event.getX(), (int) event.getY());
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.TRANSPARENT);
canvas.drawPath(mDrawPath, mPaint);
}
public void setCardColor(int color) {
mPaint.setColor(color);
invalidate();
}
}
程式碼並不長, 首先我們通過Path
來規劃好我們要繪製的菱形的路徑, 然後在onDraw
方法中將這個Path
繪製出來, 這樣, 那個菱形就出來了.
我們還是重點來關注一下dispatchTouchEvent
方法, 這個方法中我們通過一個isEventInPath
來判斷是不是DOWN
事件發生在了菱形內, 如果不是則直接返回false, 不處理事件.
通過上面的分析, 我們發現其實重點是在isEventInPath
中, 這個方法咋寫的呢?
private boolean isEventInPath(MotionEvent event) {
RectF bounds = new RectF();
mDrawPath.computeBounds(bounds, true);
mRegion.setPath(mDrawPath, new Region((int)bounds.left,
(int)bounds.top, (int)bounds.right, (int)bounds.bottom));
return mRegion.contains((int) event.getX(), (int) event.getY());
}
判斷點是不是在某一個區域內, 我們是通過Region
來實現的, 首先我們通過Path.computeBounds
方法來獲取到這個path
的邊界, 然後通過Region.contains
來判斷這個點是不是在該區域內.
到現在為止, 整體的效果我們已經實現完成了, 而且點選事件我們處理的也非常棒, 如果大家有這種需求, 可以直接copy該程式碼使用, 如果沒有就當讓大家來熟悉一下如何自定義LayoutManager
了.
參考連結: https://github.com/hehonghui/android-tech-frontier/
最後給出github地址: https://github.com/qibin0506/CardLayoutManager