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

1、簡介

前段時間須要一個旋轉木馬效果用於展現圖片,因而第一時間在github上找了一圈,找了一個還不錯的控件,可是使用起來有點麻煩,始終以爲很不爽,因此尋思着本身作一個輪子。想起旋轉畫廊的效果不是和橫向滾動列表很是類似嗎?那麼是否能夠利用RecycleView實現呢?git

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

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

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

就是這麼簡單:ide

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

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

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

Adapter的定義與ListView的Adapter用法相似。動畫

重點來看LayoutManagethis

LinearLayoutManager與其餘幾個佈局管理器都是繼承了該類,從而實現了對每一個Item的佈局。那麼咱們也能夠經過自定義LayoutManager來實現旋轉畫廊的效果。google

看下要實現的效果: spa

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

2、自定義LayoutManager

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

  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的繪製順序進行調整。

3、重寫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的橫豎滾動事件還能夠作出更多有趣的效果。

最後,奉上源碼

相關文章
相關標籤/搜索