1. 程式人生 > >RecyclerView自定義LayoutManager,打造不規則佈局

RecyclerView自定義LayoutManager,打造不規則佈局

本文已授權微信公眾號:鴻洋(hongyangAndroid)在微信公眾號平臺原創首發。

RecyclerView的時代

自從google推出了RecyclerView這個控制元件, 鋪天蓋地的一頓叫好, 開發者們也都逐漸從ListView,GridView等控制元件上轉移到了RecyclerView上, 那為什麼RecyclerView這麼受開發者的青睞呢? 一個主要的原因它的高靈活性, 我們可以自定義點選事件, 隨意切換顯示方式, 自定義item動畫, 甚至連它的佈局方式我們都可以自定義.

吐吐嘈

誇完了RecyclerView, 我們再來吐槽一下大家在工作中各種奇葩需求, 大家在日常工作中肯定會遇到各種各種的奇葩需求, 這裡沒就包括奇形怪狀的需求的UI. 站在我們開發者的角度, 看到這些奇葩的UI, 心中無數只草泥馬呼嘯崩騰而過, 在憤憤不平的同時還不得不老老實實的去找解決方案… 好吧, 吐槽這麼多, 其實大家都沒有錯, 站在開發者的角度, 這樣的需求無疑增加了我們很多工作量, 不加班怎麼能完成? 但是站在老闆的角度, 他也是希望將產品做好, 所以才會不斷的思考改需求.

效果展示

開始進入正題, 今天我們的主要目的還是來自定義一個LayoutManager, 實現一個奇葩的UI, 這樣的一個佈局我也是從我的一個同學的需求那看到的, 我們先來看看效果.

當然了, 效果不是很優雅, 主要是配色問題, 配色都是隨機的, 所以肯定沒有UI上好看. 原始需求是一個死的佈局, 當然用自定義View的形式可以完成, 但是我認為那樣不利於擴充套件, 例如效果圖上的從每組3個變成每組9個, 還有一點很重要, 就是用RecyclerView我們還得輕鬆的利用View的複用機制. 好了, UI我們就先介紹到這, 下面我們開始一步步的實現這個效果.

自定義LayoutManager

前面說了, 我們這個效果是利用自定義RecyclerViewLayoutManager實現的, 所以, 首先我們要準備一個類讓它繼承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