把RecyclerView擼成 馬 蜂 窩

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

Android自定義蜂窩圖實(shí)現(xiàn)

于是我將文章中源碼下載下來看了一下,發(fā)現(xiàn)只支持7張圖,不能多不能少。而且在設(shè)計(jì)上也有一定的欠缺。不過也給我提拱了一種思路。謝謝這位作者的提供的靈感!

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

源碼地址:HiveLayoutManager

1 成果展示

首先我們先看一下我們要實(shí)現(xiàn)的目標(biāo):

靜態(tài)展示:

橫向的正六邊形布局:

臥似一張弓

縱向的正六邊形布局:

站似一棵松

插入:

南拳

刪除:

北腿

移動(dòng):

走路一陣風(fē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)行布局。那么看一看我們我們希望怎樣布局?看圖:

一個(gè)的時(shí)候在中間,很多的時(shí)候一圈圈

那么我們可以抽象的想象一下,把這種布局看成一種從內(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è)正六邊形

第一個(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的中心就很簡單了。代碼不貼了,請下載源碼查看:HiveMathUtilscalculateCenterPoint方法。

既然中心點(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層是不是也是如此呢?先看圖。

Fuck

誰能告訴我那個(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)要重寫canScrollHorizontallycanScrollVertically兩個(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)scrollHorizontallyByscrollVerticallyBy兩個(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回收掉,那么這里我們就需要在scrollVerticallyByscrollHorizontallyBy添加相關(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)步驟如下:

  1. 確定自己的布局策略
  2. 重寫onLayoutChildren方法實(shí)現(xiàn)填充布局
  3. 重寫canScrollXX方法支持滾動(dòng)
  4. 重寫scrollXXBy方法實(shí)現(xiàn)滾動(dòng)
  5. 控制滾動(dòng)范圍和邊界效果
  6. 處理滾動(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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • Android 自定義View的各種姿勢1 Activity的顯示之ViewRootImpl詳解 Activity...
    passiontim閱讀 179,323評論 25 708
  • 內(nèi)容抽屜菜單ListViewWebViewSwitchButton按鈕點(diǎn)贊按鈕進(jìn)度條TabLayout圖標(biāo)下拉刷新...
    皇小弟閱讀 47,183評論 22 665
  • 最近忙著出國選學(xué)校換專業(yè),各種糾結(jié)各種收集信息資料以圖能夠得到最優(yōu)選。可是,到頭來發(fā)現(xiàn),這個(gè)世界并不存在最優(yōu)選,不...
    嫕寧閱讀 1,410評論 0 2
  • To 小柚子: 和你的第一次見面,是和大豆一起,在建環(huán)樓下,等待著我們的第一次面試。你十分燦爛的笑容將我們面試的緊...
    外聯(lián)一家人閱讀 240評論 0 0
  • 今天我們的第一節(jié)課是語文,我們學(xué)了,口,耳,還有目,口就是嘴巴可以讓我們說話,耳就是耳朵,它可以讓我們聽聲音,目可...
    付夢緣閱讀 544評論 0 1

友情鏈接更多精彩內(nèi)容