把RecyclerView擼成馬蜂窩

qiyq0299 8年前發布 | 11K 次閱讀 Android開發 移動開發 RecyclerView

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的個數
0 1
1 6
2 12
3 18
…… ……
n 6n

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

3 計算View的屏幕顯示區域

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

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

第一個正六邊形

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

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

第一層的正六邊形

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

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

既然中心點可以得到了,那么再按照上一節中的方法得到每一個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 對應的相鄰邊
0 0,1
1 1,2
2 2,3
3 3,4
…… ……
p p%6,(p+1)%6

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

Fuck

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

position 對應的相鄰邊
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 就是邊上的正六邊形。然后我們在此找出了其中的規律,根據這個規律,我們便可以由(n-1)層得到n層的所有的View的顯示區域了。好,代碼不貼了。請自行下載源碼。

4 填充布局View

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

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

// 先解綁和回收所有的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 實現滑動

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

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

以 scrollVerticallyBy 為例:

@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回收掉,那么這里我們就需要在 scrollVerticallyBy 和 scrollHorizontallyBy 添加相關的處理。

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

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 方法執行完調用:

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);
    }
}

}</code></pre>

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

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

7 總結

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

實現步驟如下:

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

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

 

來自:http://www.jianshu.com/p/6c78a5a07db5

 

 本文由用戶 qiyq0299 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!