一起擼個簡單粗暴的Tv應用主介面的網格佈局控制元件(上)

請叫我大蘇發表於2018-04-30

這一篇是真的隔了好久了~~,也終於可以喘口氣來好好寫部落格了,這段時間實在是忙不過來了,迭代太緊。好,廢話不多說,進入今天的主題。

效果

當貝市場.gif

TvGridLayout示例

圖一是Tv應用:當貝市場的主頁

圖二是我們自己擼的簡單粗暴的 Tv 應用主介面網格控制元件:TvGridLayout 的示例

今天這篇就不講原始碼,不講原理了,來講講怎麼簡單粗暴的擼個網格控制元件出來。

如果要你實現類似當貝市場主頁的這種佈局,你會怎麼做?頂部的 Tab 欄先不管,就每個 Tab 下的卡位列表是不止一屏的,注意看,在同一個 Tab 下是可以左右切屏的;而且每個 Tab,每一屏下的卡位樣式、大小是不一樣的;

以前在 Github clone 別人開源的主頁網格佈局的專案時,發現,他們好多都是將網格的佈局寫死的,就直接在 xml 中寫死第一個卡位小卡位,第二個卡位中卡位...

寫死肯定是不行的,那麼多 Tab,每個 Tab 下還可能會是多屏的,所以最好是要能夠根據佈局資料來動態計算網格的位置和大小。

實現

你問我為啥不用系統自帶的 GridLayout 實現,為啥要自己擼一個?

原因1:我忘記了,忘記有這個控制元件了~~

原因2:事後大概過了下 GridLayout 基本使用,發現它比較適用於卡位樣式是固定的場景,比如某個 Tab 下個網格佈局,每個卡位的位置、大小都是固定的,那麼用它就很容易實現。

原因3:反正我就是想自己擼一個~

好了,開始分析,要怎麼來擼這麼一個網格控制元件呢?

第一步:定義佈局資料結構

  • ElementEntity

首先,第一步,因為我們的網格控制元件是要支援根據佈局資料來動態計算每個卡位的大小、位置資訊的,那麼佈局資料就需要提供每個卡位的位置資訊以及每屏的橫縱,所以每個卡位的資料結構可以像下面這麼定義:

public class ElementEntity implements Serializable {
    private int x;//卡位座標
    private int y;//卡位座標
    private int row;//卡位長度
    private int column;//卡位寬度

    private String imgUrl;
}
複製程式碼

因為我們擼的網格控制元件是要動態來計算卡位的大小、位置的,計算的方式有很多種,我們採取的是將當前屏按照佈局資料平均劃分成 n 個小格,統一以每個小格的左上角作為座標起點,那麼每個卡位就需要提供 x,y 的座標起點,用於計算它的位置,以及 row, column 表示當前這個卡位橫向佔據了 row 個小格,豎直方向佔據了 column 個小格。

只要每個卡位提供了這些資料,那麼就可以根據卡位各自不同的資料實現不同的卡位樣式、大小了。

  • ScreenEntity

然後卡位是屬於每個 Tab 下的其中一屏裡的,所以每一屏的所有卡位構成一組卡位列表,不同屏卡位列表應該是獨立的,所以每一屏的資料結構可以這麼定義:

public class ScreenEntity implements Serializable {
    private int row;//橫向劃分成幾行
    private int column;//豎直方向劃分成幾列
    //row, column 用於將當前屏平均劃分成 row * column 個小格

    private List<ElementEntity> elementList;
}
複製程式碼

即使是同一個 Tab 下的每一屏的樣式都是不一樣的,所以每一屏要平均劃分成幾個小格,由每屏自己決定。

  • MenuEntity

每個 Tab 可以表示一個選單,Tab 下有多屏的卡位,所以它的資料結構可以像下面這麼定義:

public class MenuEntity implements Serializable {
    private List<ScreenEntity> screenList;//一個Tab 下可能有多屏
}
複製程式碼
  • LayoutEntity

主頁是可能含有多個 Tab 的,所以主頁的佈局資料可以像下面這麼定義:

public class LayoutEntity {
    private List<MenuEntity> menuList;//可能含有多個 Tab 選單
}
複製程式碼
  • json

綜上,彙總一下,主頁的佈局資料結構可以是長這個樣子的:

{
    "menuList": [
        {
            "menuName": "影視娛樂",
            "screenList": [
                {
                    "row": 6,
                    "column": 4,
                    "elementList": [
                        {
                            "x": 3,
                            "y": 1,
                            "row": 3,
                            "column": 1
                        },
                        {
                            "x": 4,
                            "y": 1,
                            "row": 6,
                            "column": 1
                        },
                        {
                            "x": 2,
                            "y": 4,
                            "row": 3,
                            "column": 2
                        },
                        {
                            "x": 1,
                            "y": 1,
                            "row": 6,
                            "column": 1
                        },
                        {
                            "x": 2,
                            "y": 1,
                            "row": 3,
                            "column": 1
                        }
                    ]
                }
            ]
        }
    ]
}
複製程式碼

這第一步很關鍵,尤其是每個卡位的資料結構和每一屏的資料結構定義,因為網格佈局的動態實現就是根據這些資料來計算的。

第二步:自定義 TvGridLayout

想想,我們要擼的網格控制元件,一是要支援動態計算卡位大小、位置;二是支援卡位超出一屏,在螢幕外也能繪製,這樣當切屏時就可以直接滑到下一屏顯示了。

基於這兩點,我們就不繼承自 ViewGroup 然後全部自己寫了,簡單粗暴點,我們繼承自 FrameLayout 就行,然後只要將計算出來的卡位位置通過 FrameLayout 的 LayoutParams 來指定在絕對座標系下的位置,最後跟卡位樣式的 View 一起新增進 FrameLayout 就可以了。

好,開工:

public class TvGridLayout extends FrameLayout {
	...
    private Adapter mAdapter;
    
    public TvGridLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        init(context);
    }
    
    public void setAdapter(Adapter adapter) {
        mAdapter = adapter;
        ...
        
        	layoutChildren();//動態計算每個卡位大小、位置進行佈局
    }
    
    //卡位資訊來源
    public static abstract class Adapter { 
    	...
    }
}
複製程式碼

想想,擼了一個網格控制元件,我們要怎麼使用方便呢

這裡參考了 RecyclerView 的思路,TvGridLayout 網格控制元件就只提供純粹的佈局功能,至於每個卡位長啥樣,大小、位置等都交由 Adapter 去實現。

也就是說,要使用 TvGridLayout 網格控制元件時,我們只要像使用 RecyclerView 那樣寫一個繼承自 TvGridLayout.Adapter 的 Adapter,然後實現它的抽象方法,向 TvGridLayout 提供必要的佈局資料即可。

第三步:自定義 Adapter

那麼,TvGridLayout 需要哪些必要的佈局資料呢,換句話說,我們該怎麼來定義 Adapter 的抽象方法呢?

想想,我們的網格控制元件是支援多屏的,而每一屏下都可以有多個卡位,所以我們需要總屏數和每屏下面的卡位數量:

  • public abstract int getPageCount()
  • public abstract int getChildCount(int pageIndex)

而且每一屏的樣式是可以不一樣的,換句話說,每一屏具體要平均劃分成多少個小格,也就是幾行幾列,這些資料也是需要的,所以:

  • public abstract int getPageRow(int pageIndex)
  • public abstract int getPageColumn(int pageIndex)

大局的樣式搞定了,接下去就是每個卡位了,卡位需要什麼資訊呢?其實就三點,位置、大小、長啥樣。為了方便,我們可以將位置和大小資訊經過一層轉換後封裝起來,那麼:

  • public abstract ItemCoordinate getChildCoordinate(int pageIndex, int childIndex)
  • public abstract View getChildView(int groupPosition, int childPosition, int childW, int childH);

好,這樣一來,TvGridLayout 所需的佈局資料就都有了,使用過程中,只要繼承 TvGridLayout.Adapter 然後實現相應的抽象方法,根據我們第一步裡定義的資料結構,提供相對應的佈局資料,那麼佈局的工作就都交由 TvGridLayout 內部去實現就好了。

來看一下整個程式碼:

public static abstract class Adapter {
    public abstract int getPageRow(int pageIndex);
    public abstract int getPageColumn(int pageIndex);
    public abstract ItemCoordinate getChildCoordinate(int pageIndex, int childIndex);
    public abstract View getChildView(int groupPosition, int childPosition, int childW, int childH);
    public abstract int getChildCount(int pageIndex);
    public abstract int getPageCount();
    protected void onSwitchAdapter(Adapter newAdapter, Adapter oldAdapter) {}
}
複製程式碼

使用方式跟 RecyclerView 很類似,簡單粗暴。有一點不同的是,在 RecyclerView.Adapter 裡,我們的 item View 的大小是交由自己決定的,想多大就多大。但在這裡,item View 的大小位置都是由服務端下發的佈局資料決定的,而這些資料直接就交由 TvGridLayout 內部處理了,所以可以看到,getChildView() 方法的引數裡,我們將當前卡位的大小傳給 Adapter 了,這點跟平時使用中可能有點不一樣。

第四步:動態佈局

佈局資料的資料結構定好了,TvGridLayout 也通過 Adapter 拿到所需的佈局資料了,那麼接下去就是要根據這些資料來進行動態計算,完成佈局工作了。這些工作都是在 TvGridLayout 內部完成,觸釋出局工作的時機可以是在 setAdapter() 中,當外部傳進來一個 Adapter 時,我們就可以進行佈局工作了,方法命名為 layoutChildren()

private void layoutChildren() {
    //方便優化
    layoutChildrenOfPages(0, mAdapter.getPageCount());
}

private void layoutChildrenOfPages(int fromPage, int toPage) {
    //1. 獲取網格控制元件的寬度和高度(即每屏的大小)
	int contentWidth = mWidth - getPaddingLeft() - getPaddingRight();
	int contentHeight = mHeight - getPaddingTop() - getPaddingBottom();
    //2. 遍歷每一屏
	for (int j = fromPage; j < toPage; j++) {
        //3. 獲取第j屏的行數和列數
     	int column = mAdapter.getPageColumn(j);//列數
    	int row = mAdapter.getPageRow(j);//行數
        //4. 根據行數和列數以及網格控制元件的大小,將當前j屏平均劃分成 column * row 個小格
     	float itemWidth = (contentWidth) * 1.0f / column;//每個小格的寬度
        float itemHeight = (contentHeight) * 1.0f / row;//每個小格的高度

        int pageWidth = 0;//每屏的寬度不一定是充滿網格控制元件的寬度的,有可能當前屏寬度只有一半,所以需要記錄當前屏的寬度具體是多少
        
         //5. 遍歷當前j屏下的每個卡位
        for (int i = 0; i < mAdapter.getChildCount(j); i++) {
            //6. 獲取當前卡位的位置、大小資訊
            ItemCoordinate childCoordinate = mAdapter.getChildCoordinate(j, i);
            if (childCoordinate == null) {
                //7. 如果當前卡位沒有對應的位置大小資訊
                continue;
            }
            int pointStartX = childCoordinate.start.x;
            int pointStartY = childCoordinate.start.y;
            int pointEndX = childCoordinate.end.x;
            int pointEndY = childCoordinate.end.y;

            //8. 根據卡位的佈局資訊(位置,長度)計算卡位的大小
            int width = (int) ((pointEndX - pointStartX) * itemWidth);
            int height = (int) ((pointEndY - pointStartY) * itemHeight);
            
            //9. 根據卡位的佈局資訊(位置,長度)計算卡位的位置,直接計算處於父控制元件座標系下的絕對位置
            int marginLeft = (int) (pointStartX * itemWidth + contentWidth * j);
            int marginTop = (int) (pointStartY * itemHeight);

            if (marginLeft < 0) {
                marginLeft = 0;
            }
            if (marginTop < 0) {
                marginTop = 0;
            }

            //10. 獲取卡位的樣式,想長啥樣,Adapter 自己決定
            View view = mAdapter.getChildView(j, i, width, height);
            if (view == null) {
                //11. 如果當前位置的卡位沒有配置,那麼就不參與佈局中
                continue;
            }

            //12. 通過 LayoutParams 來進行佈局,引數傳進卡位大小,
            LayoutParams params = new LayoutParams(width - mItemSpace * 2, height - mItemSpace * 2);//扣除間距
            
            //13. 通過 leftMargin,topMargin 來決定卡位的位置
            params.topMargin = marginTop + mItemSpace;
            params.leftMargin = marginLeft + mItemSpace;
            //14. 將卡位資訊直接儲存在卡位的 LayoutParams 中,方便後續直接使用
            params.itemCoordiante = childCoordinate;
            params.pageIndex = j;

            //15. 記錄當前屏的長度,因為每一屏不一定會充滿整個父控制元件,可能一個Tab下有三屏,但第二屏只配置了一半的卡位
            int maxWidth = marginLeft + width - contentWidth * j;
            pageWidth = Math.max(pageWidth, maxWidth);
			
            //16. 記錄這個 Tab 下的網格控制元件的總長度
            int maxRight = marginLeft + width;
            mRightEdge = Math.max(mRightEdge, maxRight);
			
            //17. 記錄每一屏的第一個卡位,方便後續如果需要操作預設焦點
            if (childCoordinate.start.x == 0 && childCoordinate.start.y == 0) {
                mFirstChildOfPage.put(j, view);
            }
			
            //18. 新增進父容器中,完成佈局
            if (j == 0 && childCoordinate.start.x == 0 && childCoordinate.start.y == 0) {
                addView(view, 0, params);
            } else {
                addView(view, params);
            }   
        }
    }
}
複製程式碼

動態計算的佈局邏輯看程式碼註釋吧,註釋很詳細了~

另外,我們將卡位的位置、大小資訊封裝到 ItemCoordinate 中去了,這是為了方便使用:

static class ItemCoordinate {
	public Point start;//左上角座標
	public Point end;//右下角座標
}
複製程式碼

只要有左上角和有下角座標,就可以確定卡位的位置和大小了。另外,這裡的座標系並不是 Android 意義上的座標系,它是以每個小格為單元的座標系,並不是具體的 px 數值,畫張圖看看就容易理解了:

座標系.png

還有,我們自定義了一個 LayoutParams 繼承自 FrameLayout.LayoutParams,沒什麼特別的,就單純是為了將一些卡位的資訊直接跟卡位繫結儲存起來,方便後續需要的時候直接使用,而不至於還得自己建立一個 map 來維護管理:

private static class LayoutParams extends FrameLayout.LayoutParams {
	ItemCoordinate mItemCoordinate;//卡位的位置、大小資訊
	int pageIndex;//卡位屬於哪一屏的

	...
}
複製程式碼

第五步:初步使用

好了,到這裡,一個簡單粗暴的網格控制元件就實現了,支援根據佈局資料動態計算卡位位置、大小;支援一個 Tab 下有多屏,每屏的大小、樣式都可以由自己決定;

想想,其實實現很簡單,就是要定義好佈局資料的資料結構,然後服務端需要提供每一屏以及每一個卡位的位置、大小資訊,最後類似於 RecyclerView 的用法,使用時自己寫一個 Adapter 來提供對應資料以及卡位的 View,就沒了。

但到這裡,其實控制元件是不支援滑動的。

因為我們到這裡寫的 TvGridLayout 並沒有去處理滑動的工作,當然滑不了了,那想要讓它滑動,也特別簡單,修改一下 xml 佈局檔案,在 TvGridLayout 外層放一個 HorizontalScrollView 控制元件,那麼它就可以滑動了。

不過,這種滑動有一些不足是,滑動的策略只能按照系統的來,滑動的時長不能修改。這樣的話,可能會沒法滿足產品那刁鑽的口味。既然,網格控制元件都自己擼了,那乾脆滑動也自己實現好了,這樣想怎麼滑就怎麼滑,想滑多遠就滑多遠,想滑多久就多久,還怕伺候不好產品麼。

不過,本篇篇幅已經很長了,怎麼自己實現滑動,就放到下一篇再來講吧。

小結

最後,再總結一下我們自己擼出來的這個網格控制元件:

  • 優點:簡單、粗暴,支援多屏,支援動態設定不同屏的樣式、大小,支援動態設定卡位的位置、大小
  • 優點:等下篇講完自己擼個滑動的功能,那麼就支援想怎麼滑就怎麼滑,不怕伺候不了產品
  • 優點:支援每屏卡位不一定要全部充滿屏,屏大小不一定要充滿父控制元件
  • 缺點:不成熟、不穩定,可能存在一些問題
  • 缺點:還沒有複用之類的考量,所有屏的所有的卡位都是在設定完 setAdapter() 之後就全部繪製出來了
  • 缺點:需要服務端提供佈局資料

不管了,反正先擼個簡單、粗暴的控制元件出來再說,以後再一步步慢慢優化~

等後面找時間梳理完自定義 View 的測量、佈局、繪製流程原理,ViewGroup 的原理,焦點機制原理,這些要是都梳理清楚之後,這個控制元件肯定能得到極大的昇華的,期待中~~


QQ圖片20180316094923.jpg
最近剛開通了公眾號,想激勵自己堅持寫作下去,初期主要分享原創的Android或Android-Tv方面的小知識,感興趣的可以點一波關注,謝謝支援~~

相關文章