本篇文章已授權微信公眾號 dasu_Android(大蘇)獨家發布
這一篇是真的隔了好久了~~,也終於可以喘口氣來好好寫博客了,這段時間實在是忙不過來了,迭代太緊。好,廢話不多說,進入今天的主題。
效果


圖一是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 數值,畫張圖看看就容易理解了:

還有,我們自定義了一個 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 的原理,焦點機制原理,這些要是都梳理清楚之后,這個控件肯定能得到極大的升華的,期待中~~

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