由旋轉畫廊,看自定義RecyclerView.LayoutManager

開發的貓發表於2017-09-21

一、簡介

前段時間需要一個旋轉木馬效果用於展示圖片,於是第一時間在github上找了一圈,找了一個還不錯的控制元件,但是使用起來有點麻煩,始終覺得很不爽,所以尋思著自己做一個輪子。想起旋轉畫廊的效果不是和橫向滾動列表非常相似嗎?那麼是否可以利用RecycleView實現呢?

RecyclerView是google官方在support.v7中提供的一個控制元件,是ListView和GridView的升級版。該控制元件具有高度靈活、高度解耦的特性,並且還提供了新增、刪除、移動的動畫支援,分分鐘讓你作出漂亮的列表、九宮格、瀑布流。相信使用過該控制元件的人必定愛不釋手。

先來看下如何簡單的使用RecyclerView


RecyclerView listView = (RecyclerView)findViewById(R.id.lsit);
listView.setLayoutManager(new LinearLayoutManager(this, LinearLayoutManager.VERTICAL, false));
listView.setAdapter(new Adapter());複製程式碼

就是這麼簡單:

  1. 設定LayoutManager
  2. 設定Adapter(繼承RecyclerView.Adapter)

其中,LayoutManager用於指定佈局管理器,官方已經提供了幾個佈局管理器,可以滿足大部分需求:

  • LinearLayoutManger:提供了豎向和橫向線性佈局(可實現ListView功能)
  • GridLayoutManager:表格佈局(可實現GridView功能)
  • StaggeredGridLayoutManager:瀑布流佈局

Adapter的定義與ListView的Adapter用法類似。

重點來看LayoutManage

LinearLayoutManager與其他幾個佈局管理器都是繼承了該類,從而實現了對每個Item的佈局。那麼我們也可以通過自定義LayoutManager來實現旋轉畫廊的效果。

看下要實現的效果:

旋轉畫廊.gif
旋轉畫廊.gif

二、自定義LayoutManager

首先,我們來看看,自定義LayoutManager是什麼樣的流程:

  1. 計算每個Item的位置,並對Item佈局。重寫onLayoutChildren()方法
  2. 處理滑動事件(包括橫向和豎向滾動、滑動結束、滑動到指定位置等)

    i.橫向滾動:重寫scrollHorizontallyBy()方法

    ii.豎向滾動:重寫scrollVerticallyBy()方法

    iii.滑動結束:重寫onScrollStateChanged()方法

    iiii.指定滾動位置:重寫scrollToPosition()和smoothScrollToPosition()方法

  3. 重用和回收Item
  4. 重設Adapter 重寫onAdapterChanged()方法

接下來,就來實現這個流程

第一步,定義CoverFlowLayoutManager繼承RecyclerView.LayoutManager


public class CoverFlowLayoutManger extends RecyclerView.LayoutManager {
    @Override
    public RecyclerView.LayoutParams generateDefaultLayoutParams() {
        return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    }
}複製程式碼

繼承LayoutManager後,會強制要求必須實現generateDefaultLayoutParams()方法,提供預設的Item佈局引數,設定為Wrap_Content,由Item自己決定。

第二步,計算Item的位置和佈局,並根據顯示區域回收出界的Item

i.計算Item位置
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    //如果沒有item,直接返回
    //跳過preLayout,preLayout主要用於支援動畫
    if (getItemCount() <= 0 || state.isPreLayout()) {
        mOffsetAll = 0;
        return;
    }
    mAllItemFrames.clear(); //mAllItemFrame儲存了所有Item的位置資訊
    mHasAttachedItems.clear(); //mHasAttachedItems儲存了Item是否已經被新增到控制元件中

    //得到子view的寬和高,這裡的item的寬高都是一樣的,所以只需要進行一次測量
    View scrap = recycler.getViewForPosition(0);
    addView(scrap);
    measureChildWithMargins(scrap, 0, 0);
    //計算測量佈局的寬高
    mDecoratedChildWidth = getDecoratedMeasuredWidth(scrap);
    mDecoratedChildHeight = getDecoratedMeasuredHeight(scrap);
    //計算第一個Item X軸的起始位置座標,這裡第一個Item居中顯示
    mStartX = Math.round((getHorizontalSpace() - mDecoratedChildWidth) * 1.0f / 2);
    //計算第一個Item Y軸的啟始位置座標,這裡為控制元件豎直方向居中
    mStartY = Math.round((getVerticalSpace() - mDecoratedChildHeight) *1.0f / 2);

    float offset = mStartX; //item X軸方向的位置座標
    for (int i = 0; i < getItemCount(); i++) { //儲存所有item具體位置
        Rect frame = mAllItemFrames.get(i);
        if (frame == null) {
            frame = new Rect();
        }
        frame.set(Math.round(offset), mStartY, Math.round(offset + mDecoratedChildWidth), mStartY + mDecoratedChildHeight);
        mAllItemFrames.put(i, frame); //儲存位置資訊
        mHasAttachedItems.put(i, false);
        //計算Item X方向的位置,即上一個Item的X位置+Item的間距
        offset = offset + getIntervalDistance();
    }

    detachAndScrapAttachedViews(recycler);

    layoutItems(recycler, state, SCROLL_RIGHT); //佈局Item

    mRecycle = recycler; //儲存回收器
    mState = state; //儲存狀態
}複製程式碼

以上,我們為Item的佈局做了準備,計算了Item的寬高,以及首個Item的起始位置,並根據設定的Item間,計算每個Item的位置,並儲存了下來。

接下來,來看看layoutItems()方法做了什麼。

ii.佈局和回收Item
private void layoutItems(RecyclerView.Recycler recycler,
                             RecyclerView.State state, int scrollDirection) {
        if (state.isPreLayout()) return;

    Rect displayFrame = new Rect(mOffsetAll, 0, mOffsetAll + getHorizontalSpace(), getVerticalSpace()); //獲取當前顯示的區域

    //回收或者更新已經顯示的Item
    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        int position = getPosition(child);

        if (!Rect.intersects(displayFrame, mAllItemFrames.get(position))) {
            //Item沒有在顯示區域,就說明需要回收
            removeAndRecycleView(child, recycler); //回收滑出螢幕的View
            mHasAttachedItems.put(position, false);
        } else { //Item還在顯示區域內,更新滑動後Item的位置
            layoutItem(child, mAllItemFrames.get(position)); //更新Item位置
            mHasAttachedItems.put(position, true);
        }
    }

    for (int i = 0; i < getItemCount(); i++) {
        if (Rect.intersects(displayFrame, mAllItemFrames.get(i)) &&
                !mHasAttachedItems.get(i)) { //載入可見範圍內,並且還沒有顯示的Item
            View scrap = recycler.getViewForPosition(i);
            measureChildWithMargins(scrap, 0, 0);
            if (scrollDirection == SCROLL_LEFT || mIsFlatFlow) {
                //向左滾動,新增的Item需要新增在最前面
                addView(scrap, 0);
            } else { //向右滾動,新增的item要新增在最後面
                addView(scrap);
            }
            layoutItem(scrap, mAllItemFrames.get(i)); //將這個Item佈局出來
            mHasAttachedItems.put(i, true);
        }
    }
}

private void layoutItem(View child, Rect frame) {
    layoutDecorated(child,
            frame.left - mOffsetAll,
            frame.top,
            frame.right - mOffsetAll,
            frame.bottom);
        child.setScaleX(computeScale(frame.left - mOffsetAll)); //縮放
        child.setScaleY(computeScale(frame.left - mOffsetAll)); //縮放
}複製程式碼

第一個方法:在layoutItems()中
mOffsetAll記錄了當前控制元件滑動的總偏移量,一開始mOffsetAll為0。

在第一個for迴圈中,先判斷已經顯示的Item是否已經超出了顯示範圍,如果是,則回收改Item,否則更新Item的位置。

在第二個for迴圈中,遍歷了所有的Item,然後判斷Item是否在當前顯示的範圍內,如果是,將Item新增到控制元件中,並根據Item的位置資訊進行佈局。

第二個方法:在layoutItem()中
呼叫了父類方法layoutDecorated對Item進行佈局,其中mOffsetAll為整個旋轉控制元件的滑動偏移量。

佈局好後,對根據Item的位置對Item進行縮放,中間最大,距離中間越遠,Item越小。

第三步,處理滑動事件

i. 處理橫向滾動事件
由於旋轉畫廊只需橫向滾動,所以這裡只處理橫向滾動事件複製程式碼
@Override
public boolean canScrollHorizontally() {
    return true;
}

@Override
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler,
                                RecyclerView.State state) {
    if (mAnimation != null && mAnimation.isRunning()) mAnimation.cancel();
    int travel = dx;
    if (dx + mOffsetAll < 0) {
        travel = -mOffsetAll;
    } else if (dx + mOffsetAll > getMaxOffset()){
        travel = (int) (getMaxOffset() - mOffsetAll);
    }
    mOffsetAll += travel; //累計偏移量
    layoutItems(recycler, state, dx > 0 ? SCROLL_RIGHT : SCROLL_LEFT);
    return travel;
}複製程式碼

首先,需要告訴RecyclerView,我們需要接收橫向滾動事件。
當使用者滑動控制元件時,會回撥scrollHorizontallyBy()方法對Item進行重新佈局。

我們先忽略第一句程式碼,mAnimation用於處理滑動停止後Item的居中顯示。

然後,我們判斷了滑動距離dx,加上之前已經滾動的總偏移量mOffsetAll,是否超出所有Item可以滑動的總距離(總距離= Item個數 * Item間隔),對滑動距離進行邊界處理,並將實際滾動的距離累加到mOffsetAll中。

當dx>0時,控制元件向右滾動,即<--;當dx<0時,控制元件向左滾動,即-->複製程式碼

接著,呼叫先前已經寫好的佈局方法layoutItems(),對Item進行重新佈局。

最後,返回實際滑動的距離。

ii.處理滑動結束事件,將Item居中顯示
@Override
public void onScrollStateChanged(int state) {
    super.onScrollStateChanged(state);
    switch (state){
        case RecyclerView.SCROLL_STATE_IDLE:
            //滾動停止時
            fixOffsetWhenFinishScroll();
            break;
        case RecyclerView.SCROLL_STATE_DRAGGING:
            //拖拽滾動時
            break;
        case RecyclerView.SCROLL_STATE_SETTLING:
            //動畫滾動時
            break;
    }
}

private void fixOffsetWhenFinishScroll() {
    //計算滾動了多少個Item
    int scrollN = (int) (mOffsetAll * 1.0f / getIntervalDistance()); 
    //計算scrollN位置的Item超出控制元件中間位置的距離
    float moreDx = (mOffsetAll % getIntervalDistance());
    if (moreDx > (getIntervalDistance() * 0.5)) { //如果大於半個Item間距,則下一個Item居中
        scrollN ++;
    }
    //計算最終的滾動距離
    int finalOffset = (int) (scrollN * getIntervalDistance());
    //啟動居中顯示動畫
    startScroll(mOffsetAll, finalOffset);
    //計算當前居中的Item的位置
    mSelectPosition = Math.round (finalOffset * 1.0f / getIntervalDistance());
}複製程式碼

通過onScrollStateChanged()方法,可以監聽到控制元件的滾動狀態,這裡我們只需處理滑動停止事件。

在fixOffsetWhenFinishScroll()中,getIntervalDistance()方法用於獲取Item的間距。
根據滾動的總距離除以Item的間距計算出總共滾動了多少個Item,然後啟動居中顯示動畫。

private void startScroll(int from, int to) {
    if (mAnimation != null && mAnimation.isRunning()) {
        mAnimation.cancel();
    }
    final int direction = from < to ? SCROLL_RIGHT : SCROLL_LEFT;
    mAnimation = ValueAnimator.ofFloat(from, to);
    mAnimation.setDuration(500);
    mAnimation.setInterpolator(new DecelerateInterpolator());
    mAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            mOffsetAll = Math.round((float) animation.getAnimatedValue());
            layoutItems(mRecycle, mState, direction);
        }
    });
}複製程式碼

動畫很簡單,從滑動停止的位置,不斷重新整理Item佈局,直到滾動到最終位置。

iii.處理指定位置滾動事件
@Override
public void scrollToPosition(int position) {
    if (position < 0 || position > getItemCount() - 1) return;
    mOffsetAll = calculateOffsetForPosition(position);
    if (mRecycle == null || mState == null) {
        //如果RecyclerView還沒初始化完,先記錄下要滾動的位置
        mSelectPosition = position;
    } else {
        layoutItems(mRecycle, mState, 
                    position > mSelectPosition ? SCROLL_RIGHT : SCROLL_LEFT);
    }
}

@Override
public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, int position) {
    if (position < 0 || position > getItemCount() - 1) return;
    int finalOffset = calculateOffsetForPosition(position);
    if (mRecycle == null || mState == null) {
        //如果RecyclerView還沒初始化完,先記錄下要滾動的位置
        mSelectPosition = position;
    } else {
        startScroll(mOffsetAll, finalOffset);
    }
}複製程式碼

scrollToPosition()用於不帶動畫的Item直接跳轉
smoothScrollToPosition()用於帶動畫Item滑動

也很簡單,計算要跳轉Item的所在位置需要滾動的距離,如果不需要動畫,則直接對Item進行佈局,否則啟動滑動動畫。

第四,處理重新設定Adapter

當重新呼叫RecyclerView的setAdapter時,需要對LayoutManager的所有狀態進行重置

@Override
public void onAdapterChanged(RecyclerView.Adapter oldAdapter,  
                             RecyclerView.Adapter newAdapter) {
    removeAllViews();
    mRecycle = null;
    mState = null;
    mOffsetAll = 0;
    mSelectPosition = 0;
    mLastSelectPosition = 0;
    mHasAttachedItems.clear();
    mAllItemFrames.clear();
}複製程式碼

清空所有的Item,已經所有存放的位置資訊和狀態。

最後RecyclerView會重新呼叫onLayoutChildren()進行佈局。

以上,就是自定義LayoutManager的流程,但是,為了實現旋轉畫廊的功能,只自定義了LayoutManager是不夠的。旋轉畫廊中,每個Item是有重疊部分的,因此會有Item繪製順序的問題,如果不對Item的繪製順序進行調整,將出現中間Item被旁邊Item遮擋的問題。

為了解決這個問題,需要重寫RecyclerView的getChildDrawingOrder()方法,對Item的繪製順序進行調整。

三、重寫RecyclerView

這裡簡單看下如何如何改變Item的繪製順序,具體可以檢視原始碼複製程式碼

public class RecyclerCoverFlow extends RecyclerView {
    public RecyclerCoverFlow(Context context) {
        super(context);
        init();
    }

    public RecyclerCoverFlow(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
        init();
    }

    public RecyclerCoverFlow(Context context, @Nullable AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        init();
    }

    private void init() {
        ......

        setChildrenDrawingOrderEnabled(true); //開啟重新排序

        ......
    }

    @Override
    protected int getChildDrawingOrder(int childCount, int i) {
        //計算正在顯示的所有Item的中間位置
        int center = getCoverFlowLayout().getCenterPosition()
                - getCoverFlowLayout().getFirstVisiblePosition();
        if (center < 0) center = 0;
        else if (center > childCount) center = childCount;
        int order;
        if (i == center) {
            order = childCount - 1;
        } else if (i > center) {
            order = center + childCount - 1 - i;
        } else {
            order = i;
        }
        return order;
    }
}複製程式碼

首先,需要呼叫setChildrenDrawingOrderEnabled(true); 開啟重新排序功能。

接著,在getChildDrawingOrder()中,childCount為當前已經顯示的Item數量,i為item的位置。
旋轉畫廊中,中間位置的優先順序是最高的,兩邊item隨著遞減。因此,在這裡,我們通過以上定義的LayoutManager計算了當前顯示的Item的中間位置,然後對Item的繪製進行了重新排序。

最後將計算出來的順序優先順序返回給RecyclerView進行繪製。

總結

以上,通過旋轉畫廊控制元件,我們過了一遍自定義LayoutManager的流程。當然RecyclerView的強大遠遠不至於此,結合LayoutManager的橫豎滾動事件還可以做出更多有趣的效果。

最後,奉上原始碼

相關文章