我的CSDN: ListerCi
我的簡(jiǎn)書: 東方未曦
一、簡(jiǎn)介&示例
雖然官方提供的LinearLayoutManager和GridLayoutManager等已經(jīng)可以滿足絕大部分需求了,但是當(dāng)我們對(duì)Item的布局有特殊的需求時(shí)就需要我們自定義LayoutManager。自定義LayoutManager作為RecyclerView的一大難點(diǎn),對(duì)自定義View和RecyclerView復(fù)用機(jī)制相關(guān)的知識(shí)有一定的要求,建議各位同學(xué)打好基礎(chǔ)再學(xué)習(xí)。
首先強(qiáng)推啟艦大神的自定義LayoutManager系列博客,可以說把自定義LayoutManager講透了
RecyclerView系列之三自定義LayoutManager
RecyclerView系列之四實(shí)現(xiàn)回收復(fù)用
RecyclerView系列之五回收復(fù)用實(shí)現(xiàn)方式二
RecyclerView系列之六實(shí)現(xiàn)滾動(dòng)畫廊控件
當(dāng)然網(wǎng)上還有很多優(yōu)秀的自定義LayoutManager樣例,例如......馬蜂窩:把RecyclerView擼成馬蜂窩,當(dāng)年我第一次看到這篇博客時(shí)可謂虎軀一震。
眼瞅著各路大神都實(shí)現(xiàn)了這么優(yōu)秀的自定義LayoutManager,我琢磨許久,把RecyclerView擼成了一個(gè)轉(zhuǎn)盤,效果如下所示,今天就來講講怎么實(shí)現(xiàn)它。

二、布局與移動(dòng)
2.1 初始化時(shí)的Item布局
首先來看如何對(duì)item進(jìn)行布局,我們知道轉(zhuǎn)盤的整體是一個(gè)圓,只不過這個(gè)圓很大,只有一部分的Item可以顯示在屏幕上,因此可以設(shè)計(jì)布局如下。其中紅色框代表屏幕,在初始化時(shí),第0個(gè)Item會(huì)被布局到屏幕中央,我們稱它的角度為0。
那么怎么判斷其他Item要不要被布局到屏幕上呢?如果先計(jì)算一個(gè)Item的坐標(biāo)再去判斷是否與屏幕相交會(huì)有些麻煩,不過由于各個(gè)Item之間的角度相等,我們?nèi)菀椎玫矫總€(gè)Item與中間虛線的角度差,當(dāng)這個(gè)角度差小于某個(gè)值的時(shí)候,我們認(rèn)為它會(huì)顯示在可視區(qū)域,就可以將這個(gè)Item布局到屏幕上。

假設(shè)這個(gè)圓的半徑為Radius,Item之間的角度為Angle,初始化時(shí)我們需要將前面的幾個(gè)Item布局到屏幕上。由于Item坐標(biāo)的計(jì)算依賴于圓心,因此我們首先要得到圓心的坐標(biāo): (circleX=screenWidth/2, circleY=screenHeight/2 + Radius),之后可以通過圓心坐標(biāo)和三角函數(shù)計(jì)算每個(gè)Item中心的坐標(biāo)。
初始化時(shí)第i個(gè)Item的角度為i*Angle,因此該Item的x坐標(biāo)為circleX+sin(i*Angle)*Radius,y坐標(biāo)為circleY-cos(i*Angle)*Radius,不過Java計(jì)算三角函數(shù)時(shí)傳入的參數(shù)不是角度而是弧度,因此需要將角度轉(zhuǎn)化為弧度,最終計(jì)算坐標(biāo)的代碼如下:
float curAngle = index * mEachAngle;
int xToAdd = (int) (Math.sin(2 * Math.PI / 360 * curAngle) * mRadius);
int yToMinus = (int) (Math.cos(2 * Math.PI / 360 * curAngle) * mRadius);
int x = mCircleMidX + xToAdd;
int y = mCircleMidY - yToMinus;
2.2 移動(dòng)時(shí)的Item布局
剛剛我們計(jì)算了初始化時(shí)Item的坐標(biāo),那么Item移動(dòng)時(shí)的坐標(biāo)該如何計(jì)算呢?
由于坐標(biāo)的計(jì)算依賴于當(dāng)前Item的角度,當(dāng)轉(zhuǎn)盤移動(dòng)時(shí),所有Item的角度都會(huì)變化。因此可以通過一個(gè)值mMovedAngle保存所有Item(也就是轉(zhuǎn)盤整體)向后移動(dòng)的角度,可得第i個(gè)Item此時(shí)的角度為i * Angle - mMovedAngle,接下來看如何計(jì)算mMovedAngle的值。
由于轉(zhuǎn)盤只支持橫向移動(dòng),當(dāng)RecyclerView移動(dòng)時(shí)會(huì)傳入一個(gè)dx表示本次橫向移動(dòng)的距離。當(dāng)Radius的值很大時(shí),dx基本等于圓弧的長(zhǎng)度,所以可以通過周長(zhǎng)公式計(jì)算單次移動(dòng)的角度值。
private float convertDxToAngle(int dx) {
return (float) (360 * dx / (2 * Math.PI * mRadius));
}
將角度轉(zhuǎn)化為dx的方法如下,在邊界處理時(shí)會(huì)使用到。
private int convertAngleToDx(float angle) {
return (int) (2 * Math.PI * mRadius * angle / 360);
}
我們?cè)?code>scrollHorizontallyBy(int dx, ...)方法中更新mMovedAngle,首先進(jìn)行邊界處理。mMovedAngle的范圍是[0, (getItemCount() - 1) * Angle],假設(shè)本次移動(dòng)的角度為moveAngle ,當(dāng)mMovedAngle + moveAngle > (getItemCount() - 1) * Angle時(shí),表示滑動(dòng)到了右邊界;當(dāng)mMovedAngle + moveAngle < 0時(shí),表示滑動(dòng)到了左邊界,此時(shí)需要將多余的角度去掉并計(jì)算真正的dx,代碼如下所示。
@Override
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
float moveAngle = convertDxToAngle(dx);
int actualDx = dx;
if (mMovedAngle + moveAngle > getMaxScrollAngle()) {
moveAngle = getMaxScrollAngle() - mMovedAngle;
actualDx = convertAngleToDx(moveAngle);
} else if (mMovedAngle + moveAngle < 0) {
moveAngle = -mMovedAngle;
actualDx = convertAngleToDx(moveAngle);
}
mMovedAngle += moveAngle;
// 根據(jù)mMovedAngle對(duì)Item布局......
return actualDx;
}
private int getMaxScrollAngle() {
return (getItemCount() - 1) * mEachAngle;
}
三、具體實(shí)現(xiàn)
對(duì)于LayoutManager來說,不關(guān)心轉(zhuǎn)盤的半徑和Item的角度差,這兩個(gè)值由構(gòu)造函數(shù)傳入,讓用戶去自定義。LayoutManager的成員變量和構(gòu)造函數(shù)如下。
public class TurntableLayoutManager extends RecyclerView.LayoutManager {
private int mRadius; // 轉(zhuǎn)盤半徑
private int mEachAngle; // Item間的角度差
private int mItemWidth;
private int mItemHeight;
private int mCircleMidX; // 圓心X坐標(biāo)
private int mCircleMidY; // 圓心Y坐標(biāo)
private float mMovedAngle = 0;
public TurntableLayoutManager(int radius, int eachAngle) {
this.mRadius = radius;
this.mEachAngle = eachAngle;
}
@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT);
}
// ......
}
接下來通過onLayoutChildren(...)方法對(duì)初始化時(shí)的Item進(jìn)行布局,主要為4步:
① 由于每個(gè)Item的大小相等,先將Item的大小計(jì)算出來。
② 計(jì)算轉(zhuǎn)盤的圓心坐標(biāo),用于之后根據(jù)角度計(jì)算Item的坐標(biāo)
③ 回收當(dāng)前屏幕上的ItemView
④ 對(duì)要顯示在屏幕上的Item布局
@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
// 1. 假設(shè)每個(gè)Item大小相等, 得到item的大小
initItemSize(recycler);
// 2. 根據(jù)屏幕中心得到轉(zhuǎn)盤的圓心
int screenMidX = getWidth() / 2;
int screenMidY = getHeight() / 2;
mCircleMidX = screenMidX;
mCircleMidY = screenMidY + mRadius;
// 3. 回收屏幕上的ItemView
detachAndScrapAttachedViews(recycler);
// 4. 當(dāng)Item角度的絕對(duì)值小于50°時(shí),將其布局到屏幕上
// 這個(gè)值可以自己調(diào)整,只要顯示效果沒問題即可
for (int i = 0; i < getItemCount(); i++) {
if (Math.abs(i * mEachAngle) < 50) {
layoutViewByIndex(recycler, i);
}
}
}
private void initItemSize(RecyclerView.Recycler recycler) {
View view = recycler.getViewForPosition(0);
addView(view);
measureChildWithMargins(view, 0, 0);
mItemWidth = getDecoratedMeasuredWidth(view);
mItemHeight = getDecoratedMeasuredHeight(view);
removeAndRecycleView(view, recycler);
}
private void layoutViewByIndex(RecyclerView.Recycler recycler, int index) {
float curAngle = index * mEachAngle - mMovedAngle;
int xToAdd = (int) (Math.sin(2 * Math.PI / 360 * curAngle) * mRadius);
int yToMinus = (int) (Math.cos(2 * Math.PI / 360 * curAngle) * mRadius);
int x = mCircleMidX + xToAdd;
int y = mCircleMidY - yToMinus;
View viewForPosition = recycler.getViewForPosition(index);
addView(viewForPosition);
measureChildWithMargins(viewForPosition, 0, 0);
// 將item布局
layoutDecorated(viewForPosition, x - mItemWidth / 2, y - mItemHeight / 2,
x + mItemWidth / 2, y + mItemHeight / 2);
// 調(diào)整Item自身的旋轉(zhuǎn)角度
viewForPosition.setRotation(curAngle);
}
最后就要考慮滑動(dòng)了,其實(shí)在第2節(jié)已經(jīng)講的差不多了,只要通過已經(jīng)移動(dòng)的角度來計(jì)算當(dāng)前Item的實(shí)際角度并布局就可以了,這里先使能橫向滑動(dòng)。
@Override
public boolean canScrollHorizontally() {
return true;
}
@Override
public boolean canScrollVertically() {
return false;
}
滑動(dòng)事件在scrollHorizontallyBy(...)中處理。首先對(duì)滑動(dòng)距離dx進(jìn)行邊界處理,并轉(zhuǎn)化為角度;隨后將角度絕對(duì)值>=50的Item回收;最后對(duì)角度絕對(duì)值<50的Item進(jìn)行布局。
@Override
public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) {
float moveAngle = convertDxToAngle(dx);
int actualDx = dx;
if (mMovedAngle + moveAngle > getMaxScrollAngle()) {
moveAngle = getMaxScrollAngle() - mMovedAngle;
actualDx = convertAngleToDx(moveAngle);
} else if (mMovedAngle + moveAngle < 0) {
moveAngle = -mMovedAngle;
actualDx = convertAngleToDx(moveAngle);
}
mMovedAngle += moveAngle;
for (int i = 0; i < getChildCount(); i++) {
View view = getChildAt(i);
if (view != null) {
int position = getPosition(view);
float curAngle = position * mEachAngle + mMovedAngle;
if (Math.abs(curAngle) >= 50) {
removeAndRecycleView(view, recycler);
}
}
}
// 回收當(dāng)前屏幕上的所有ItemView
detachAndScrapAttachedViews(recycler);
for (int i = 0; i < getItemCount(); i++) {
float curAngle = i * mEachAngle - mMovedAngle;
if (Math.abs(curAngle) < 50) {
layoutViewByIndex(recycler, i);
}
}
return actualDx;
}
private int getMaxScrollAngle() {
return (getItemCount() - 1) * mEachAngle;
}
private float convertDxToAngle(int dx) {
return (float) (360 * dx / (2 * Math.PI * mRadius));
}
private int convertAngleToDx(float angle) {
return (int) (2 * Math.PI * mRadius * angle / 360);
}
寫完之后檢查一下ViewHolder的復(fù)用情況,通過在onCreateViewHolder()中打Log的方式計(jì)算,發(fā)現(xiàn)一共create了7個(gè)ViewHolder,復(fù)用情況良好。
到這里這次的自定義LayoutManager就結(jié)束了,源碼可以去github下載:RecyclerViewDemo