把RecyclerView擼成 馬 蜂 窩 [復制鏈接]

2016-9-19 14:44
柴澤建_Jack 閱讀:2651 評論:17 贊:0
Tag:  AndroidRecyclerView

前幾天我看到一篇文章很有趣:

Android自定義蜂窩圖實現

于是我將文章中源碼下載下來看了一下,發現只支持7張圖,不能多不能少。而且在設計上也有一定的欠缺。不過也給我提拱了一種思路。

于是想想自己的RecyclerView系列正好要講LayoutManager了,那么我來做一個類似上面功能的LayoutManager好了。那么下面我來教大家一步一步把你的RecyclerView擼成馬蜂窩。

源碼地址:HiveLayoutManager

1 成果展示

首先我們先看一下我們要實現的目標:

靜態展示:

橫向的正六邊形布局:

縱向的正六邊形布局:

插入:

刪除:

移動:

滾動:

是不是心動了。實現這些只需要一行代碼:

1
recyclerView.setLayoutManager(new HiveLayoutManager(HiveLayoutManager.VERTICAL));

正六邊形圖片的顯示,請看我的另一篇文章:正六邊形ImageView。然后關鍵就在于這個HiveLayoutManager。那么接下來一步一步通過自定義LayoutManager來實現上面的功能。下面的都會以縱向為例。橫向類似。

2 蜂窩布局策略

第一步我們先制定布局策略,然后根據我們的布局策略,確定每個View的位置,然后對View進行布局。那么看一看我們我們希望怎樣布局?看圖:

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

那么得到position(floor,index)的對應關系,就要找到他們之間的規律。觀察圖上面圖片,然后讀者可以自行在紙上多畫幾層。然后我們將層數與每層包含View的個數列出,規律如下。

層數包含的View的個數
01
16
212
318
…………
n6n

這個規律很快就找到了,那么我們由position(floor,index)的算法也很簡單了。這里就不講了,具體計算方法見源碼中HiveMathUtils中的getFloorOfPosition方法。

3 計算View的屏幕顯示區域

布局策略確定之后,我們需要計算出,具體坐標下View在屏幕上顯示的區域。那么我們以下步驟來做:

3.1 計算第一個View的顯示區域

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

3.2 計算出第一層所有View的顯示區域

因為第一層是六個圍著第一個正六邊形的六個正六邊形,(PS:打完這句話的我自己差點吐了,這句話有毒!)。那么我們還是先按照第一個正六邊的思路,首先想辦法得到這六個正六邊形的中心點,然后再按上面的方法計算View的顯示區域。

仔細觀察可以發現,所有的中心點,都在距離第一個正六邊中心點 根號3 倍邊長 為半徑的圓上。只是角度不同而已。角度的規律也很好找。那么計算出第一層里所有View的中心就很簡單了。代碼不貼了,請下載源碼查看:HiveMathUtilscalculateCenterPoint方法。

既然中心點可以得到了,那么再按照上一節中的方法得到每一個View的顯示區域也是輕而易舉。

3.3 計算出第n層的所有正六邊形的位置(n>1)

那么,第n層的所有View的顯示區域,我們要怎么計算呢?這里是這個布局策略計算上最難的一點。這估計也是為什么我看到的那篇文章中的作者只支持7個的原因吧。不過他前7個View顯示區域的獲得方法也和我完全不一樣。再讀的你也可以想象如果是你要怎么做?這里提醒一下,我們前面兩個步驟可以很大程度的復用。

好,我來講思路。比如第2層的所有View,顯然可以根據第1層的View獲得。那么看圖:

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

那么看一下第1層到第2層,我們可以這樣生成:

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

那么我們將第1層中,六邊形生成關系對應的position和對應相鄰邊列出來:

position對應的相鄰邊
00,1
11,2
22,3
33,4
…………
pp%6,(p+1)%6

規律也找到了,那么我們這就可以根據第1層計算出第2層了,而且也不會重復計算。那么第2層到第3層是不是也是如此呢?先看圖。

誰能告訴我那個綠色的是什么?如果再看第4層,就會有兩個這種綠色的正六邊形。然后我們發現,一條邊上的正六邊形分為兩種,一種是角上的,一種是中間的。那么這兩種是不一樣的。那么我們就把上圖中兩個綠色的連起來。這里不貼圖了,腦補。那么我們再把position和生成的對應邊列出來,floor為對應的層數。

position對應的相鄰邊
00,1
11
21,2
32
42,3
53
63,4
74
84,5
…………
p%floor==0p/floor%6,(p/floor+1)%6
p%floor!=0(p/floor+1)%6

那么好,p%floor==0就是角上的正六邊形,p%floor!=0就是邊上的正六邊形。然后我們在此找出了其中的規律,根據這個規律,我們便可以由(n-1)層得到n層的所有的View的顯示區域了。好,代碼不貼了。請自行下載源碼。

4 填充布局View

既然根據上面的方法,我們已經可以得到任何一個position上View的顯示區域,那么就來重寫onLayoutChildren方法,在里面為所有的View布局吧。

首先:獲取當前Item的個數:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 先解綁和回收所有的ViewHolder
detachAndScrapAttachedViews(recycler);
// 獲取當前Item的個數,就是Adatper中數據的個數。
int itemCount = state.getItemCount();
// 這里我們將每個View的顯示區域信息放在Rect中,然后緩存起來,如果沒有的,在這里計算生成。
checkAllRect(itemCount);
// 遍歷所有的item
for (int i = 0; i < itemCount; i++) {
// 得到當前position下的視圖顯示區域
RectF bounds = getBounds(i);
// 通過recycler得到該位置上的View,Recycler負責是否使用舊的還是生成新的View。
View view = recycler.getViewForPosition(i);
// 然后我們將得到的View添加到Recycler中
addView(view);
// 然后測量View帶Margin的的尺寸
measureChildWithMargins(view, 0, 0);
// 然后layout帶Margin的View,將View放置到對應的位置
layoutDecoratedWithMargins(view, (int) bounds.left, (int) bounds.top, (int) bounds.right, (int) bounds.bottom);
}

那么這樣我們就可以把所有的View添加到RecyclerView上,并且布局到對應的位置上了。

但是,現在我們的RecyclerView還不能滑動。而且是將所有的Item都生成了View,并添加進來了,只是不能滑動我們還看不到,那些出了邊界的我們看不到。要想將看不到部分的View不現實,判斷一下就可以。這里我不貼代碼了,有興趣的看源碼。源碼已經做了處理。

5 實現滑動

實現滑動要重寫canScrollHorizontallycanScrollVertically兩個方法。canScrollHorizontally控制是否可以水平滑動,canScrollVertically控制是否可以垂直滑動。這兩個方法默認返回false。因為我們這里要上下左右都可以滑動,那么我們這兩個方法都返回true。

這樣做了之后,我們發現我們在滑動的時候,RecyclerView旁邊會出現邊界效果,但是我們里面的View卻沒有動。那么要實現里面View的滑動,就要實現scrollHorizontallyByscrollVerticallyBy兩個方法。scrollHorizontallyBy是控制水平滾動的,scrollVerticallyBy是控制垂直滾動的。

scrollVerticallyBy為例:

1
2
3
4
5
6
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
// 使用該方法垂直移動RecyclerView中所有的View
offsetChildrenVertical(-dy);
return dy ;
}

scrollHorizontallyBy方法類似。這里不貼代碼了。但是這樣會發現可以無限滑動。我們希望的是我滑到沒有View了就不能滑動了。那么這樣我們需要一些處理來實現。通過控制offsetChildrenVertical方法傳入的值來控制滾動的距離,以及控制scrollVerticallyBy的返回值來控制是否觸發邊界效果,返回值為0觸發RecyclerView的邊界效果。這里具體代碼不貼了,請自行下載源碼查看。

然后,這樣之后還會又一個bug,就是當我們執行添加,刪除Item的時候,所有View都會復位。那么這樣我們就需要在每次滑動的時候,記錄累計滑動距離,并在添加布局View的時候加上這個偏移量布局。

6 滾動過程中View的回收和填充

在滾動過程中我們希望將新劃入的View添加進來,將滑出的View回收掉,那么這里我們就需要在scrollVerticallyByscrollHorizontallyBy添加相關的處理。

我們將該操作封裝到scrapOutSetViews方法中,并在offsetChildrenVertical方法之后調用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private void scrapOutSetViews(RecyclerView.Recycler recycler) {
// 獲得當前View的個數
int count = getChildCount();
for (int i = count - 1; i >= 0; i--) {
// 遍歷每個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()))) {
// 根據view得到對應的position
int position = getPosition(view);
// 清除該位置顯示的標志為,表示該位置上的View沒有顯示在界面上
booleanMap.clear(position);
// 如果不相交,回收這個View
detachAndScrapView(view, recycler);
}
}
}

滑動的時候填充新進入的View,這里我們將之前onLayoutChildren中填充的部分抽離出一個fill方法來,并加入區域過濾,然后在scrapOutSetViews方法執行完調用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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滑動的累積偏移量。
bounds.offset(layoutState.offsetX, layoutState.offsetY);
// 在沒有顯示在界面上,并且和RecyclerView的區域有交集則填充并布局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);
}
}
}

實現到這里,基本上功能都全了。

注意:本文中的代碼并非源碼,我只拿出了部分關鍵代碼,有興趣的歡迎下載查看源碼。

7 總結

重寫一個LayoutManager的需求并不大,系統為我們提供的那幾個LayoutManager基本上已經覆蓋了99%的RecyclerView的需求,但是現在,即使我們遇到這1%,也不用慫了!那么最后我來總結一下自定義LayoutManager的心得吧。

實現步驟如下:

  1. 確定自己的布局策略
  2. 重寫onLayoutChildren方法實現填充布局
  3. 重寫canScrollXX方法支持滾動
  4. 重寫scrollXXBy方法實現滾動
  5. 控制滾動范圍和邊界效果
  6. 處理滾動中View的回收和填充

注意recycler.getViewForPosition(i)方法只會從緩存中或者新生成一個View,并不會檢查是否已經顯示,所以自行過濾顯示的狀態。不在同一position填充View,這種情況很難用肉眼發現。因為這兩個View是重疊的,肉眼看不到,但確實存在。

8 最后

最后,我想說,祝大家中秋節快樂!月餅節快樂!

這篇文章寫了6個小時,中秋節呢,如果覺得不錯,打個賞唄。我現在窮的連月餅都吃不上。要哭臉.png

謝謝閱讀!

分享到:
我來說兩句
facelist
您需要登錄后才可以評論 登錄 | 立即注冊
所有評論(17)
mu5532123 2016-9-19 19:03
表示圖片都看不到
回復
柴澤建_Jack 2016-9-20 10:49
mu5532123: 表示圖片都看不到
啊?我這里可以呀?你是用的電腦嗎?
回復
MrlLee 2016-9-20 11:40
mu5532123: 表示圖片都看不到
可以了~
回復
mu5532123 2016-9-20 17:37
MrlLee: 可以了~
嗯嗯~
回復
mu5532123 2016-9-20 17:38
柴澤建_Jack: 啊?我這里可以呀?你是用的電腦嗎?
是的
回復
柴澤建_Jack 2016-9-20 23:51
mu5532123: 是的
可以了就好
回復
EverK 2016-9-22 16:09
position與對應的相鄰邊的關系沒看懂
回復
總以為 2016-9-26 15:33
感覺邏輯很復雜的樣子
回復
柴澤建_Jack 2016-9-26 23:45
EverK: position與對應的相鄰邊的關系沒看懂
嗯...我已經不知道再怎么解釋了。position位置上的六邊形要對應生成相鄰邊上的六邊形。0 就要生成它 0,1號邊上的六邊形。
回復
柴澤建_Jack 2016-9-26 23:45
總以為: 感覺邏輯很復雜的樣子
嗯...不瞞你說,卻是挺復雜
回復
EverK 2016-9-27 12:57
從第二層開始.position的定義是什么呢
回復
xzao123 2016-10-8 11:36
太牛了
回復
nzw31 2016-10-10 09:49
good
回復
chandler2 2016-10-13 08:50
厲害
回復
tony_gibson 2016-10-13 16:38
厲害!
回復
哇哦 2016-10-13 17:30
666
回復

領先的中文移動開發者社區
18620764416
7*24全天服務
意見反饋:[email protected]

掃一掃關注我們

Powered by Discuz! X3.2© 2001-2019 Comsenz Inc.( 粵ICP備15117877號 )

两码中特期期