我們都知道RecyclerView可以通過將LayoutManager設置為StaggeredGridLayoutManager來實現瀑布流的效果。默認的還有LinearLayoutManager用于實現線性布局,GridLayoutManager用于實現網格布局。
然而RecyclerView可以做的不僅限于此,通過重寫LayoutManager我們可以按自己的意愿實現更為復雜的效果。而且將控件與其顯示效果解耦之后我們就可以動態的改變其顯示效果。
設想有這么一個界面,以列表形式展示了一系列的數據,點擊一個按鈕后以網格形勢顯示另一組數據。傳統的做法可能是在同一布局下設置了一個listview和一個gridview然后通過按鈕點擊事件切換他們的visiblity屬性。而如果使用recyclerview的話你只需通過setAdapter方法改變數據,setLayoutManager方法改變樣式即可,這樣不僅簡化了布局也實現了邏輯上的簡潔。
下面我們就來介紹怎么通過重寫一個LayoutManager來實現一個弧形的recycylerview以及另一個會隨著滾動在指定位置縮放的recyclerview。并實現類似viewpager的回彈效果。
項目地址 Github
通常重寫一個LayoutManager可以分為以下幾個步驟
- 指定默認的LayoutParams
- 測量并記錄每個item的信息
- 回收以及放置各個item
- 處理滾動
指定默認的 LayoutParams
當你繼承LayoutManager之后,有一個必須重寫的方法
generateDefaultLayoutParams()
這個方法指定了每一個子view默認的LayoutParams,并且這個LayoutParams會在你調用getViewForPosition()返回子view前應用到這個子view。
@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
}
測量并記錄每個 item 的信息
接下來我們需要重寫onLayoutChildren()這個方法。這是LayoutManager的主要入口,他會在初始化布局以及adapter數據發生改變(或更換adapter)的時候調用。所以我們在這個方法中對我們的item進行測量以及初始化。
在貼代碼前有必要先提一下,recycler有兩種緩存的機制,scrap heap 以及recycle pool。相比之下scrap heap更輕量一點,他會直接將當前的view緩存而不通過adapter,當一個view被detach之后就會暫存進scrap heap。而recycle pool所存儲的view,我們一般認為里面存的是錯誤的數據(這個view之后需要拿出來重用顯示別的位置的數據),所以這里面的view會被傳給adapter進行數據的重新綁定,一般,我們將子view從其parent view中remove之后會將其存入recycler pool中。
當界面上我們需要顯示一個新的view時,recycler會先檢查scrap heap中position相匹配的view,如果有,則直接返回,如果沒有recycler會從recycler pool中取一個合適的view,將其傳遞給adapter,然后調用adapter的bindViewHolder()方法,綁定數據之后將其返回。
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
if (getItemCount() == 0) {
detachAndScrapAttachedViews(recycler);
offsetRotate = 0;
return;
}
//calculate the size of child
if (getChildCount() == 0) {
View scrap = recycler.getViewForPosition(0);
addView(scrap);
measureChildWithMargins(scrap, 0, 0);
mDecoratedChildWidth = getDecoratedMeasuredWidth(scrap);
mDecoratedChildHeight = getDecoratedMeasuredHeight(scrap);
startLeft = contentOffsetX == -1?(getHorizontalSpace() - mDecoratedChildWidth)/2: contentOffsetX;
startTop = contentOffsetY ==-1?0: contentOffsetY;
mRadius = mDecoratedChildHeight;
detachAndScrapView(scrap, recycler);
}
//record the state of each items
float rotate = firstChildRotate;
for (int i = 0; i < getItemCount(); i++) {
itemsRotate.put(i,rotate);
itemAttached.put(i,false);
rotate+= intervalAngle;
}
detachAndScrapAttachedViews(recycler);
fixRotateOffset();
layoutItems(recycler,state);
}```
getItemCount()方法會調用adapter的getItemCount()方法,所以他獲取到的是數據的總數,而getChildCount()方法則是獲取當前已添加了的子View的數量。
因為在這個項目中所有view的大小都是一樣的,所以就只測量了position為0的view的大小。itemsRotate用于記錄初始狀態下,每一個item的旋轉角度,offsetRotate是旋轉的偏移角度,每個item的旋轉角加上這個偏移角度便是最后顯示在界面上的角度,滑動過程中我們只需對應改變offsetRotate即可,itemAttached則用于記錄這個item是否已經添加到當前界面。
####回收以及放置各個 item
```Java
private void layoutItems(RecyclerView.Recycler recycler,
RecyclerView.State state){
if(state.isPreLayout()) return;
//remove the views which out of range
for(int i = 0;i<getChildCount();i++){
View view = getChildAt(i);
int position = getPosition(view);
if(itemsRotate.get(position) - offsetRotate>maxRemoveDegree
|| itemsRotate.get(position) - offsetRotate< minRemoveDegree){
itemAttached.put(position,false);
removeAndRecycleView(view,recycler);
}
}
//add the views which do not attached and in the range
int begin = getCurrentPosition() - MAX_DISPLAY_ITEM_COUNT / 2;
int end = getCurrentPosition() + MAX_DISPLAY_ITEM_COUNT / 2;
if(begin<0) begin = 0;
if(end > getItemCount()) end = getItemCount();
for(int i=begin;i<end;i++){
if(itemsRotate.get(i) - offsetRotate<= maxRemoveDegree
&& itemsRotate.get(i) - offsetRotate>= minRemoveDegree){
if(!itemAttached.get(i)){
View scrap = recycler.getViewForPosition(i);
measureChildWithMargins(scrap, 0, 0);
addView(scrap);
float rotate = itemsRotate.get(i) - offsetRotate;
int left = calLeftPosition(rotate);
int top = calTopPosition(rotate);
scrap.setRotation(rotate);
layoutDecorated(scrap, startLeft + left, startTop + top,
startLeft + left + mDecoratedChildWidth, startTop + top + mDecoratedChildHeight);
itemAttached.put(i,true);
}
}
}
}```
prelayout是recyclerview繪制動畫的階段,因為這個項目不需要處理動畫所以直接return。這里先是將當前已添加的子view中超出范圍的那些remove掉并添加進recycle pool,(是的,只要調用removeAndRecycleView就行了),然后將所有item中還沒有attach的view進行測量后,根據當前角度運用一下初中數學知識算出x,y坐標后添加到當前布局就行了。
```Java
private int calLeftPosition(float rotate){
return (int) (mRadius * Math.cos(Math.toRadians(90 - rotate)));
}
private int calTopPosition(float rotate){
return (int) (mRadius - mRadius * Math.sin(Math.toRadians(90 - rotate)));
}```
####處理滾動
現在我們的LayoutManager已經能按我們的意愿顯示一個弧形的列表了,只是少了點生氣。接下來我們就讓他滾起來!
```Java
@Override
public boolean canScrollHorizontally() {
return true;
}```
看名字就知道這個方法是用于設定能否橫向滾動的,對應的還有canScrollVertically()這個方法。
```Java
@Override
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
int willScroll = dx;
float theta = dx/DISTANCE_RATIO; // the angle every item will rotate for each dx
float targetRotate = offsetRotate + theta;
//handle the boundary
if (targetRotate < 0) {
willScroll = (int) (-offsetRotate*DISTANCE_RATIO);
}
else if (targetRotate > getMaxOffsetDegree()) {
willScroll = (int) ((getMaxOffsetDegree() - offsetRotate)*DISTANCE_RATIO);
}
theta = willScroll/DISTANCE_RATIO;
offsetRotate+=theta; //increase the offset rotate so when re-layout it can recycle the right views
//re-calculate the rotate x,y of each items
for(int i=0;i<getChildCount();i++){
View view = getChildAt(i);
float newRotate = view.getRotation() - theta;
int offsetX = calLeftPosition(newRotate);
int offsetY = calTopPosition(newRotate);
layoutDecorated(view, startLeft + offsetX, startTop + offsetY,
startLeft + offsetX + mDecoratedChildWidth, startTop + offsetY + mDecoratedChildHeight);
view.setRotation(newRotate);
}
//different direction child will overlap different way
layoutItems(recycler, state);
return willScroll;
}```
如果是處理縱向滾動請重寫scrollVerticallyBy這個方法。
在這里將滑動的距離按一定比例轉換成滑動對應的角度,按滑動的角度重新繪制當前的子view,最后再調用一下layoutItems處理一下各個item的回收。
到這里一個弧形(圓形)的LayoutManager就寫好了。滑動放大的layoutManager的實現與之類似,在中心點scale時最大,距離中心x坐標做差后取絕對值再轉換為對應scale即可。
```Java
private float calculateScale(int x){
int deltaX = Math.abs(x-(getHorizontalSpace() - mDecoratedChildWidth) / 2);
float diff = 0f;
if((mDecoratedChildWidth-deltaX)>0) diff = mDecoratedChildWidth-deltaX;
return (maxScale-1f)/mDecoratedChildWidth * diff + 1;
}```
###Bonuses
####添加回彈
如果想實現類似于viewpager可以鎖定到某一頁的效果要怎么做?一開始想到對scrollHorizontallyBy()中的dx做手腳,但最后實現的效果很不理想。又想到重寫并實現smoothScrollToPosition方法,然后給recyclerview設置滾動監聽器在IDLE狀態下調用smoothScrollToPosition。但最后滾動到的位置總會有偏移。
最后查閱API后發現recyclerView有一個smoothScrollBy方法,他會根據你給定的偏移量調用scrollHorizontallyBy以及scrollVerticallyBy。
所以我們可以重寫一個OnScrollListener,然后給我們的recyclerView添加滾動監聽器就可以了。
```Java
public class CenterScrollListener extends RecyclerView.OnScrollListener{
private boolean mAutoSet = true;
@Override
public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
super.onScrollStateChanged(recyclerView, newState);
final RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
if(!(layoutManager instanceof CircleLayoutManager) && !(layoutManager instanceof ScrollZoomLayoutManager)){
mAutoSet = true;
return;
}
if(!mAutoSet){
if(newState == RecyclerView.SCROLL_STATE_IDLE){
if(layoutManager instanceof ScrollZoomLayoutManager){
final int scrollNeeded = ((ScrollZoomLayoutManager) layoutManager).getOffsetCenterView();
recyclerView.smoothScrollBy(scrollNeeded,0);
}else{
final int scrollNeeded = ((CircleLayoutManager)layoutManager).getOffsetCenterView();
recyclerView.smoothScrollBy(scrollNeeded,0);
}
}
mAutoSet = true;
}
if(newState == RecyclerView.SCROLL_STATE_DRAGGING || newState == RecyclerView.SCROLL_STATE_SETTLING){
mAutoSet = false;
}
}
}```
```Java
recyclerView.addOnScrollListener(new CenterScrollListener());```
還需要在自定義的LayoutManager添加一個獲取滾動偏移量的方法
```Java
public int getCurrentPosition(){
return Math.round(offsetRotate / intervalAngle);
}
public int getOffsetCenterView(){
return (int) ((getCurrentPosition()*intervalAngle-offsetRotate)*DISTANCE_RATIO);
}```
完整代碼已上傳 [Github](https://github.com/leochuan/CustomLayoutManager)
### 參考資料
[Building a RecyclerView LayoutManager](http://wiresareobsolete.com/2014/09/building-a-recyclerview-layoutmanager-part-1/)