一、簡介
前段時間需要一個旋轉木馬效果用於展示圖片,於是第一時間在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());複製程式碼
就是這麼簡單:
- 設定LayoutManager
- 設定Adapter(繼承RecyclerView.Adapter)
其中,LayoutManager用於指定佈局管理器,官方已經提供了幾個佈局管理器,可以滿足大部分需求:
- LinearLayoutManger:提供了豎向和橫向線性佈局(可實現ListView功能)
- GridLayoutManager:表格佈局(可實現GridView功能)
- StaggeredGridLayoutManager:瀑布流佈局
Adapter的定義與ListView的Adapter用法類似。
重點來看LayoutManage。
LinearLayoutManager與其他幾個佈局管理器都是繼承了該類,從而實現了對每個Item的佈局。那麼我們也可以通過自定義LayoutManager來實現旋轉畫廊的效果。
看下要實現的效果:
二、自定義LayoutManager
首先,我們來看看,自定義LayoutManager是什麼樣的流程:
- 計算每個Item的位置,並對Item佈局。重寫onLayoutChildren()方法
處理滑動事件(包括橫向和豎向滾動、滑動結束、滑動到指定位置等)
i.橫向滾動:重寫scrollHorizontallyBy()方法
ii.豎向滾動:重寫scrollVerticallyBy()方法
iii.滑動結束:重寫onScrollStateChanged()方法
iiii.指定滾動位置:重寫scrollToPosition()和smoothScrollToPosition()方法
- 重用和回收Item
- 重設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的橫豎滾動事件還可以做出更多有趣的效果。
最後,奉上原始碼。