前幾天我看到一篇文章很有趣:
于是我將文章中源碼下載下來看了一下,發(fā)現(xiàn)只支持7張圖,不能多不能少。而且在設(shè)計(jì)上也有一定的欠缺。不過也給我提拱了一種思路。謝謝這位作者的提供的靈感!
于是想想自己的RecyclerView系列正好要講LayoutManager了,那么我來做一個(gè)類似上面功能的LayoutManager好了。那么下面我來教大家一步一步把你的RecyclerView擼成馬蜂窩。
源碼地址:HiveLayoutManager
1 成果展示
首先我們先看一下我們要實(shí)現(xiàn)的目標(biāo):
靜態(tài)展示:
橫向的正六邊形布局:

縱向的正六邊形布局:

插入:

刪除:

移動(dòng):

滾動(dòng):

是不是心動(dòng)了。實(shí)現(xiàn)這些只需要一行代碼:
recyclerView.setLayoutManager(new HiveLayoutManager(HiveLayoutManager.VERTICAL));
正六邊形圖片的顯示,請看我的另一篇文章:正六邊形ImageView。然后關(guān)鍵就在于這個(gè)HiveLayoutManager。那么接下來教大家一步一步通過自定義LayoutManager來實(shí)現(xiàn)上面的功能。下面的都會(huì)以縱向?yàn)槔M向類似。
2 蜂窩布局策略
第一步我們先制定布局策略,然后根據(jù)我們的布局策略,確定每個(gè)View的位置,然后對View進(jìn)行布局。那么看一看我們我們希望怎樣布局?看圖:

那么我們可以抽象的想象一下,把這種布局看成一種從內(nèi)到外的線性布局。我們把一圈圈的看成層,最中心是第0層,然后外面一圈是第1層,然后依此類推,我們將其定義為floor,下面示意圖中的紅線。然后,每一層中的又有一定規(guī)律數(shù)量的View。那么我們規(guī)定最右邊的是第0個(gè),然后逆時(shí)針方向依此為1,2……我們將其定義為index,下面示意圖中的綠線。那么我們就可以為RecyclerView中每一個(gè)Data的position,確定其在蜂窩布局下的位置,該位置坐標(biāo)可以用(floor,index)表示。

那么得到position到(floor,index)的對應(yīng)關(guān)系,就要找到他們之間的規(guī)律。觀察圖上面圖片,然后讀者可以自行在紙上多畫幾層。然后我們將層數(shù)與每層包含View的個(gè)數(shù)列出,規(guī)律如下。
| 層數(shù) | 包含的View的個(gè)數(shù) |
|---|---|
| 0 | 1 |
| 1 | 6 |
| 2 | 12 |
| 3 | 18 |
| …… | …… |
| n | 6n |
這個(gè)規(guī)律很快就找到了,那么我們由position到(floor,index)的算法也很簡單了。這里就不講了,具體計(jì)算方法見源碼中HiveMathUtils中的getFloorOfPosition方法。
3 計(jì)算View的屏幕顯示區(qū)域
布局策略確定之后,我們需要計(jì)算出,具體坐標(biāo)下View在屏幕上顯示的區(qū)域。那么我們以下步驟來做:
3.1 計(jì)算第一個(gè)View的顯示區(qū)域

第一個(gè)正六邊形,我們將它放置在RecyclerView的中心,那么正六邊形的中心與RecyclerView中心重合。那么很容易計(jì)算出第一個(gè)View的顯示區(qū)域。這里不貼代碼了。有興趣的可以看源碼。
3.2 計(jì)算出第一層所有View的顯示區(qū)域

因?yàn)榈谝粚邮橇鶄€(gè)圍著第一個(gè)正六邊形的六個(gè)正六邊形,(PS:打完這句話的我自己差點(diǎn)吐了,這句話有毒!)。那么我們還是先按照第一個(gè)正六邊的思路,首先想辦法得到這六個(gè)正六邊形的中心點(diǎn),然后再按上面的方法計(jì)算View的顯示區(qū)域。
仔細(xì)觀察可以發(fā)現(xiàn),所有的中心點(diǎn),都在距離第一個(gè)正六邊中心點(diǎn) 根號3 倍邊長 為半徑的圓上。只是角度不同而已。角度的規(guī)律也很好找。那么計(jì)算出第一層里所有View的中心就很簡單了。代碼不貼了,請下載源碼查看:HiveMathUtils的calculateCenterPoint方法。
既然中心點(diǎn)可以得到了,那么再按照上一節(jié)中的方法得到每一個(gè)View的顯示區(qū)域也是輕而易舉。
3.3 計(jì)算出第n層的所有正六邊形的位置(n>1)
那么,第n層的所有View的顯示區(qū)域,我們要怎么計(jì)算呢?這里是這個(gè)布局策略計(jì)算上最難的一點(diǎn)。這估計(jì)也是為什么我看到的那篇文章中的作者只支持7個(gè)的原因吧。不過他前7個(gè)View顯示區(qū)域的獲得方法也和我完全不一樣。再讀的你也可以想象如果是你要怎么做?這里提醒一下,我們前面兩個(gè)步驟可以很大程度的復(fù)用。
好,我來講思路。比如第2層的所有View,顯然可以根據(jù)第1層的View獲得。那么看圖:

圖中第2層中的這三個(gè)橘紅色的正六邊形是不是可以根據(jù)前面的方法,通過第1層中的綠色正六邊形獲得?顯然是可以的。但是我們總不能把第一層的6個(gè)View遍歷一次,然后每次算出圍繞著它六個(gè)正六邊形的位置。然后再找出位于第2層中。所以我們要確定一個(gè)由n-1層生成n層View位置的規(guī)律。
那么看一下第1層到第2層,我們可以這樣生成:

如果我們把六邊形的每一條邊按下圖編號:

那么我們將第1層中,六邊形生成關(guān)系對應(yīng)的position和對應(yīng)相鄰邊列出來:
| position | 對應(yīng)的相鄰邊 |
|---|---|
| 0 | 0,1 |
| 1 | 1,2 |
| 2 | 2,3 |
| 3 | 3,4 |
| …… | …… |
| p | p%6,(p+1)%6 |
規(guī)律也找到了,那么我們這就可以根據(jù)第1層計(jì)算出第2層了,而且也不會(huì)重復(fù)計(jì)算。那么第2層到第3層是不是也是如此呢?先看圖。

誰能告訴我那個(gè)綠色的是什么?如果再看第4層,就會(huì)有兩個(gè)這種綠色的正六邊形。然后我們發(fā)現(xiàn),一條邊上的正六邊形分為兩種,一種是角上的,一種是中間的。那么這兩種是不一樣的。那么我們就把上圖中兩個(gè)綠色的連起來。這里不貼圖了,腦補(bǔ)。那么我們再把position和生成的對應(yīng)邊列出來,floor為對應(yīng)的層數(shù)。
| position | 對應(yīng)的相鄰邊 |
|---|---|
| 0 | 0,1 |
| 1 | 1 |
| 2 | 1,2 |
| 3 | 2 |
| 4 | 2,3 |
| 5 | 3 |
| 6 | 3,4 |
| 7 | 4 |
| 8 | 4,5 |
| …… | …… |
| p%floor==0 | p/floor%6,(p/floor+1)%6 |
| p%floor!=0 | (p/floor+1)%6 |
那么好,p%floor==0就是角上的正六邊形,p%floor!=0就是邊上的正六邊形。然后我們在此找出了其中的規(guī)律,根據(jù)這個(gè)規(guī)律,我們便可以由(n-1)層得到n層的所有的View的顯示區(qū)域了。好,代碼不貼了。請自行下載源碼。
4 填充布局View
既然根據(jù)上面的方法,我們已經(jīng)可以得到任何一個(gè)position上View的顯示區(qū)域,那么就來重寫onLayoutChildren方法,在里面為所有的View布局吧。
首先:獲取當(dāng)前Item的個(gè)數(shù):
// 先解綁和回收所有的ViewHolder
detachAndScrapAttachedViews(recycler);
// 獲取當(dāng)前Item的個(gè)數(shù),就是Adatper中數(shù)據(jù)的個(gè)數(shù)。
int itemCount = state.getItemCount();
// 這里我們將每個(gè)View的顯示區(qū)域信息放在Rect中,然后緩存起來,如果沒有的,在這里計(jì)算生成。
checkAllRect(itemCount);
// 遍歷所有的item
for (int i = 0; i < itemCount; i++) {
// 得到當(dāng)前position下的視圖顯示區(qū)域
RectF bounds = getBounds(i);
// 通過recycler得到該位置上的View,Recycler負(fù)責(zé)是否使用舊的還是生成新的View。
View view = recycler.getViewForPosition(i);
// 然后我們將得到的View添加到Recycler中
addView(view);
// 然后測量View帶Margin的的尺寸
measureChildWithMargins(view, 0, 0);
// 然后layout帶Margin的View,將View放置到對應(yīng)的位置
layoutDecoratedWithMargins(view, (int) bounds.left, (int) bounds.top, (int) bounds.right, (int) bounds.bottom);
}
那么這樣我們就可以把所有的View添加到RecyclerView上,并且布局到對應(yīng)的位置上了。
但是,現(xiàn)在我們的RecyclerView還不能滑動(dòng)。而且是將所有的Item都生成了View,并添加進(jìn)來了,只是不能滑動(dòng)我們還看不到,那些出了邊界的我們看不到。要想將看不到部分的View不現(xiàn)實(shí),判斷一下就可以。這里我不貼代碼了,有興趣的看源碼。源碼已經(jīng)做了處理。
5 實(shí)現(xiàn)滑動(dòng)
實(shí)現(xiàn)滑動(dòng)要重寫canScrollHorizontally和canScrollVertically兩個(gè)方法。canScrollHorizontally控制是否可以水平滑動(dòng),canScrollVertically控制是否可以垂直滑動(dòng)。這兩個(gè)方法默認(rèn)返回false。因?yàn)槲覀冞@里要上下左右都可以滑動(dòng),那么我們這兩個(gè)方法都返回true。
這樣做了之后,我們發(fā)現(xiàn)我們在滑動(dòng)的時(shí)候,RecyclerView旁邊會(huì)出現(xiàn)邊界效果,但是我們里面的View卻沒有動(dòng)。那么要實(shí)現(xiàn)里面View的滑動(dòng),就要實(shí)現(xiàn)scrollHorizontallyBy和scrollVerticallyBy兩個(gè)方法。scrollHorizontallyBy是控制水平滾動(dòng)的,scrollVerticallyBy是控制垂直滾動(dòng)的。
以scrollVerticallyBy為例:
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
// 使用該方法垂直移動(dòng)RecyclerView中所有的View
offsetChildrenVertical(-dy);
return dy ;
}
scrollHorizontallyBy方法類似。這里不貼代碼了。但是這樣會(huì)發(fā)現(xiàn)可以無限滑動(dòng)。我們希望的是我滑到?jīng)]有View了就不能滑動(dòng)了。那么這樣我們需要一些處理來實(shí)現(xiàn)。通過控制offsetChildrenVertical方法傳入的值來控制滾動(dòng)的距離,以及控制scrollVerticallyBy的返回值來控制是否觸發(fā)邊界效果,返回值為0觸發(fā)RecyclerView的邊界效果。這里具體代碼不貼了,請自行下載源碼查看。
然后,這樣之后還會(huì)又一個(gè)bug,就是當(dāng)我們執(zhí)行添加,刪除Item的時(shí)候,所有View都會(huì)復(fù)位。那么這樣我們就需要在每次滑動(dòng)的時(shí)候,記錄累計(jì)滑動(dòng)距離,并在添加布局View的時(shí)候加上這個(gè)偏移量布局。
6 滾動(dòng)過程中View的回收和填充
在滾動(dòng)過程中我們希望將新劃入的View添加進(jìn)來,將滑出的View回收掉,那么這里我們就需要在scrollVerticallyBy和scrollHorizontallyBy添加相關(guān)的處理。
我們將該操作封裝到scrapOutSetViews方法中,并在offsetChildrenVertical方法之后調(diào)用:
private void scrapOutSetViews(RecyclerView.Recycler recycler) {
// 獲得當(dāng)前View的個(gè)數(shù)
int count = getChildCount();
for (int i = count - 1; i >= 0; i--) {
// 遍歷每個(gè)View,然后是不是和RecyclerView的邊界相交
View view = getChildAt(i);
if (!RectF.intersects(new RectF(0, 0, getWidth(), getHeight()), new RectF(view.getLeft(), view.getTop(), view.getRight(), view.getBottom()))) {
// 根據(jù)view得到對應(yīng)的position
int position = getPosition(view);
// 清除該位置顯示的標(biāo)志為,表示該位置上的View沒有顯示在界面上
booleanMap.clear(position);
// 如果不相交,回收這個(gè)View
detachAndScrapView(view, recycler);
}
}
}
滑動(dòng)的時(shí)候填充新進(jìn)入的View,這里我們將之前onLayoutChildren中填充的部分抽離出一個(gè)fill方法來,并加入?yún)^(qū)域過濾,然后在scrapOutSetViews方法執(zhí)行完調(diào)用:
private void fill(RecyclerView.Recycler recycler, RecyclerView.State state) {
int itemCount = state.getItemCount();
if (itemCount <= 0) {
return;
}
checkAllRect(itemCount);
for (int i = 0; i < itemCount; i++) {
RectF bounds = getBounds(i);
// layoutState.offsetX和layoutState.offsetY中保存了RecyclerView滑動(dòng)的累積偏移量。
bounds.offset(layoutState.offsetX, layoutState.offsetY);
// 在沒有顯示在界面上,并且和RecyclerView的區(qū)域有交集則填充并布局View
if (!booleanMap.get(i) && RectF.intersects(bounds, new Rect(0, 0, getWidth(), getHeight())) {
View view = recycler.getViewForPosition(i);
addView(view);
measureChildWithMargins(view, 0, 0);
layoutDecoratedWithMargins(view, (int) bounds.left, (int) bounds.top, (int) bounds.right, (int) bounds.bottom);
}
}
}
實(shí)現(xiàn)到這里,基本上功能都全了。
注意:本文中的代碼并非源碼,我只拿出了部分關(guān)鍵代碼,有興趣的歡迎下載查看源碼。
7 總結(jié)
重寫一個(gè)LayoutManager的需求并不大,系統(tǒng)為我們提供的那幾個(gè)LayoutManager基本上已經(jīng)覆蓋了99%的RecyclerView的需求,但是現(xiàn)在,即使我們遇到這1%,也不用慫了!那么最后我來總結(jié)一下自定義LayoutManager的心得吧。
實(shí)現(xiàn)步驟如下:
- 確定自己的布局策略
- 重寫onLayoutChildren方法實(shí)現(xiàn)填充布局
- 重寫canScrollXX方法支持滾動(dòng)
- 重寫scrollXXBy方法實(shí)現(xiàn)滾動(dòng)
- 控制滾動(dòng)范圍和邊界效果
- 處理滾動(dòng)中View的回收和填充
注意recycler.getViewForPosition(i)方法只會(huì)從緩存中或者新生成一個(gè)View,并不會(huì)檢查是否已經(jīng)顯示,所以自行過濾顯示的狀態(tài)。不在同一position填充View,這種情況很難用肉眼發(fā)現(xiàn)。因?yàn)檫@兩個(gè)View是重疊的,肉眼看不到,但確實(shí)存在。
8 最后
最后,我想說,祝大家中秋節(jié)快樂!月餅節(jié)快樂!
這篇文章寫了6個(gè)小時(shí),中秋節(jié)呢,如果覺得不錯(cuò),打個(gè)賞唄。我現(xiàn)在窮的連月餅都吃不上。要哭臉.png
即使不打賞,我也會(huì)堅(jiān)強(qiáng)的說謝謝閱讀! 23333333333333333