Android自定義ViewGroup佈局進階,完整的九宮格實現

語言: CN / TW / HK

theme: smartblue highlight: agate


自定義ViewGroup九宮格

前言

在之前的文章我們複習了 ViewGroup 的測量與佈局,那麼我們這一篇效果就可以在之前的基礎上實現一個靈活的九宮格佈局。

那麼一個九宮格的 ViewGroup 如何定義,我們分解為如下的幾個步驟來實現: 1. 先計算與測量九宮格內部的子View的寬度與高度。 2. 再計算整體九宮格的寬度和高度。 3. 進行子View九宮格的佈局。 4. 對單獨的圖片和四宮格的圖片進行單獨的佈局處理 5. 對填充的子View的方式進行抽取,可以自由添加布局。 6. 對自定義屬性的抽取,設置通用的屬性。

只要在前文的基礎上掌握了 ViewGroup 的測量與佈局,其實實現起來一點都不難,甚至我們還能實現一些特別的效果。

好了,話不多説,Let's go

300.png

一、九宮格的測量

之前的文章,我們的測量方式是已經知道子 View 的具體大小了,讓我們的父佈局做寬高的適配,所以我們的邏輯順序也是先佈局,然後再測量,對 ViewGroup 的寬高做限制。

但是在我們做九宮格控件的時候,就和之前有所區別了。我們不管子 View 的寬高測量模式是怎樣的,我們都是通過九宮格控件的寬度對子 View 的寬高進行強制賦值。

```java public class AbstractNineGridLayout extends ViewGroup {

private static final int MAX_CHILDREN_COUNT = 9;  //最大的子View數量
private int horizontalSpacing = 20;  //每一個Item的左右間距
private int verticalSpacing = 20;  //每一個Item的上下間距

private int itemWidth;
private int itemHeight;

public AbstractNineGridLayout(Context context) {
    this(context, null);
}

public AbstractNineGridLayout(Context context, AttributeSet attrs) {
    this(context, attrs, 0);
}

public AbstractNineGridLayout(Context context, AttributeSet attrs, int defStyleAttr) {
    super(context, attrs, defStyleAttr);
    init(context);
}

private void init(Context context) {

    for (int i = 0; i < MAX_CHILDREN_COUNT; i++) {
        ImageView imageView = new ImageView(context);
        imageView.setLayoutParams(new ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
        imageView.setBackgroundColor(Color.RED);
        addView(imageView);
    }
}

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec) - getPaddingLeft() - getPaddingRight();
    int notGoneChildCount = getNotGoneChildCount();

    //不管什麼模式,都是指定的固定寬高
    itemWidth = (widthSize - horizontalSpacing * 2) / 3;
    itemHeight = itemWidth;

    //measureChildren內部調用measureChild,這裏我們就可以指定寬高
    measureChildren(MeasureSpec.makeMeasureSpec(itemWidth, MeasureSpec.EXACTLY),
            MeasureSpec.makeMeasureSpec(itemHeight, MeasureSpec.EXACTLY));

    if (heightMode == MeasureSpec.EXACTLY) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    } else {

        notGoneChildCount = Math.min(notGoneChildCount, MAX_CHILDREN_COUNT);
        int heightSize = ((notGoneChildCount - 1) / 3 + 1) *
                (itemHeight + verticalSpacing) - verticalSpacing + getPaddingTop() + getPaddingBottom();

        setMeasuredDimension(widthSize, heightSize);
    }
}

} ```

剛開始的時候我們在佈局初始化的時候先添加5個 mathc_parent 的9個子 View 作為測試。那麼我們在佈局的時候,就需要對寬度進行分割,並且強制性的測量每一個子 View 的寬高為 EXACTLY 模式。

測量完每一個子 View 之後,我們再動態的給 ViewGroup 設置寬高。

這樣測量之後的效果為:

image.png

image.png

為了方便查看效果,加上了測試的灰色背景,看着大小是符合預期的。接下來我們就開始佈局。

二、九宮格的佈局

在之前流式佈局的 onLayout 方法中,我們是通過動態的拿到每一個子 View 的寬度去判斷當前是否會超過總寬度,是否需要換行。

而這裏我們就無需這麼做了,因為每一個子 View 都是固定的寬度,一行就是三個,一列最多也是三個。我們直接通過子 View 的數量就可以確定當前的行數與列數。

然後我們就能行數和列數進行佈局了,具體的看代碼:

```java @Override protected void onLayout(boolean changed, int l, int t, int r, int b) {

    int childCount = getChildCount();
    int notGoneChildCount = getNotGoneChildCount();
    int position = 0;

    for (int i = 0; i < childCount; i++) {
        View child = getChildAt(i);
        if (child.getVisibility() == View.GONE) {
            continue;
        }

        int row = position / 3;    //當前子View是第幾行(索引)
        int column = position % 3; //當前子View是第幾列(索引)

        //當前需要繪製的光標的X與Y值
        int x = column * itemWidth + getPaddingLeft() + horizontalSpacing * column;
        int y = row * itemHeight + getPaddingTop() + verticalSpacing * row;

        child.layout(x, y, x + itemWidth, y + itemHeight);

        //最多隻擺放9個
        position++;
        if (position == MAX_CHILDREN_COUNT) {
            break;
        }
    }

}

```

效果為:

image.png

如果對行和列的計算不清楚的,我們可以對每一個子 View 的位置進行回顧,總共最多也就 9 個,當為第 0 個子 View 的時候,position為 0 ,那麼 position / 3 是 0,row 就是 0, position % 3 也是 0,就是第最左上角的位置了。

當為第1個子 View 的時候,position為1 ,那麼 position / 3 還是0,row就是0, position % 3是1了,就是第一排中間的位置了。

只有當View超過三個之後,position /3 就是 1 了,row為 1 之後,才是第二行的位置。依次類推就可以定位到每一個子 View 需要繪製的位置。

而 x 與 y 的值與計算邏輯,我們可以想象為需要繪製當前 View 的時候,當前畫筆需要所在的位置。加上左右和上下的間距之後,我們通過這樣的方式也可以實現 margin 的效果。還記得前文流式佈局是怎麼實現 margin 效果的嗎?殊途同歸的效果。

最後具體的 child.layout 反而是最簡單的,只需要繪製子 View 本身的寬高即可。

三、單圖片與四宮格的單獨處理。

一般來説我們需要單獨的處理一張圖片與四張圖片的邏輯。包括測量與佈局都需要單獨的處理。

一張圖片的時候,我們需要通過方法單獨的指定圖片的寬度與高度。而四張圖片我們需要固定兩行的高度即可。

```java public class AbstractNineGridLayout extends ViewGroup {

private static final int MAX_CHILDREN_COUNT = 9;  //最大的子View數量
private int horizontalSpacing = 20;  //每一個Item的左右間距
private int verticalSpacing = 20;  //每一個Item的上下間距
private boolean fourGridMode = true;  //是否支持四宮格模式
private boolean singleMode = true;  //是否支持單佈局模式
private boolean singleModeScale = true;  //是否支持單佈局模式按比例縮放
private int singleWidth;
private int singleHeight;

private int itemWidth;
private int itemHeight;


@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec) - getPaddingLeft() - getPaddingRight();
    int notGoneChildCount = getNotGoneChildCount();

    if (notGoneChildCount == 1 && singleMode) {
        itemWidth = singleWidth > 0 ? singleWidth : widthSize;
        itemHeight = singleHeight > 0 ? singleHeight : widthSize;
        if (itemWidth > widthSize && singleModeScale) {
            itemWidth = widthSize;  //單張圖片先定寬度。
            itemHeight = (int) (widthSize * 1f / singleWidth * singleHeight);  //根據寬度計算高度
        }
    } else {
        //除了單佈局模式,其他的都是指定的固定寬高
        itemWidth = (widthSize - horizontalSpacing * 2) / 3;
        itemHeight = itemWidth;
    }

    ...
}

/**
 * 設置單獨佈局的寬和高
 */
public void setSingleModeSize(int w, int h) {
    if (w != 0 && h != 0) {
        this.singleMode = true;
        this.singleWidth = w;
        this.singleHeight = h;
    }
}

} ```

測量的時候我們對單佈局進行測量,並且對超過寬度的一些佈局做等比例的縮放。然後再測量父佈局。

kotlin findViewById<AbstractNineGridLayout>(R.id.nine_grid).setSingleModeSize(dp2px(200f), dp2px(400f))

效果:

image.png

而如果是四宮格模式,我們好像也不需要重新測量,反正也是二行的高度,但是佈局的時候我們需要處理一下,不然第三個子 View 的位置就會不對了。我們只需要修改x 與 y的計算方式,它們是根據行和列動態計算你的,那麼修改行和列的計算方式即可。

```java @Override protected void onLayout(boolean changed, int l, int t, int r, int b) {

    int childCount = getChildCount();
    int notGoneChildCount = getNotGoneChildCount();
    int position = 0;

    for (int i = 0; i < childCount; i++) {
        View child = getChildAt(i);
        if (child.getVisibility() == View.GONE) {
            continue;
        }

        int row = position / 3;    //當前子View是第幾行(索引)
        int column = position % 3; //當前子View是第幾列(索引)

        if (notGoneChildCount == 4 && fourGridMode) {
            row = position / 2;
            column = position % 2;
        }

        //當前需要繪製的光標的X與Y值
        int x = column * itemWidth + getPaddingLeft() + horizontalSpacing * column;
        int y = row * itemHeight + getPaddingTop() + verticalSpacing * row;

        child.layout(x, y, x + itemWidth, y + itemHeight);

        //最多隻擺放9個
        position++;
        if (position == MAX_CHILDREN_COUNT) {
            break;
        }
    }

}

/**
 * 單獨設置是否支持四宮格模式
 */
public void setFourGridMode(boolean enable) {
    this.fourGridMode = enable;
}

```

這樣我們就可以支持四宮格的佈局模式,效果如下:

image.png

到此,我們的九宮格控件大體上是完工了,但是還不夠靈活,內部的子 View 都是我們自己 new 出來的,我們接下來就要暴露出去讓其可以自定義佈局。

四、自定義佈局的抽取

如何把填充佈局的邏輯抽取出來呢?一般分為兩種思路: 1. 每次初始化九宮格的時候就把九個佈局全部添加進來,先測量佈局了再説,然後通過暴露的方法隱藏多餘的佈局。 2. 通過一個定義一個數據適配器Adapter,內部封裝一些邏輯,讓具體實現的類去完成具體的邏輯。

兩種方法都可以,沒有好壞之分。但是使用數據適配器的方案由於內部的View會少,性能會好那麼一丟丟,總體來説差別不大。

4.1 先佈局再隱藏的思路

一般我們在抽象的九宮格類中就需要暴露這兩個重要方法,一個是填充子佈局的,一個是填充數據並且隱藏多餘的佈局。

```java //子類去實現-填充佈局文件 protected abstract void fillChildView();

//子類去實現-對佈局文件賦值數據(一般專門去給adapter去調用的)
public abstract void renderData(T data);

```

例如我們的實現類:

```java @Override protected void fillChildView() { inflateChildLayout(R.layout.item_image_grid);

    imageViews = findInChildren(R.id.iv_image, ImageView.class);
}

@Override
public void renderData(List<ImageInfo> imageInfos) {

    setSingleModeSize(imageInfos.get(0).getImageViewWidth(), imageInfos.get(0).getImageViewHeight());

    setDisplayCount(imageInfos.size());

    for (int i = 0; i < imageInfos.size(); i++) {
        String url = imageInfos.get(i).getThumbnailUrl();

        ImageView imageView = imageViews[i];

        //使用自定義的Loader加載
        mImageLoader.onDisplayImage(getContext(), imageView, url);

        //點擊事件
        setClickListener(imageView, i, imageInfos);
    }
}

```

重點是填充的方法 inflateChildLayout 分為兩種情況,一種是佈局都一樣的情況,一種是根據索引填充不同的佈局情況。

```java /* * 可以為每一個子佈局加載對應的佈局文件(不同的文件) / protected void inflateChildLayoutCustom(ViewGetter viewGetter) { removeAllViews(); for (int i = 0; i < MAX_CHILDREN_COUNT; i++) { addView(viewGetter.getView(i)); } }

/**
 * 一般用這個方法填充佈局,每一個小布局的佈局文件(相同的文件)
 */
protected void inflateChildLayout(int layoutId) {
    removeAllViews();
    for (int i = 0; i < MAX_CHILDREN_COUNT; i++) {
        LayoutInflater.from(getContext()).inflate(layoutId, this);
    }
}

```

而我們設置數據的方法中調用的 setDisplayCount 方法則是隱藏多餘的控件的。

java /** * 設置顯示的數量 */ public void setDisplayCount(int count) { int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { getChildAt(i).setVisibility(i < count ? VISIBLE : GONE); } }

效果:

image.png

4.2 數據適配器的思路

而使用數據適配器的方案,就無需每次上來就先填充9個子佈局,而是通過Adapter動態的配置當前需要填充的數量,並且創建對應的子 View 和綁定對應的子 View 的數據。

聽起來是不是很像RV的Apdater,沒錯就是參考它的實現方式。

我們先創建一個基類的Adapter:

```java public static abstract class Adapter {

    //返回總共子View的數量
    public abstract int getItemCount();

    //根據索引創建不同的佈局類型,如果都是一樣的佈局則不需要重寫
    public int getItemViewType(int position) {
        return 0;
    }

    //根據類型創建對應的View佈局
    public abstract View onCreateItemView(Context context, ViewGroup parent, int itemType);

    //可以根據類型或索引綁定數據
    public abstract void onBindItemView(View itemView, int itemType, int position);

}

```

然後我們需要暴露一個方法,設置Adapter,設置完成之後我們就可以添加對應的佈局了。

```java public void setAdapter(Adapter adapter) { mAdapter = adapter; inflateAllViews(); }

private void inflateAllViews() {
    removeAllViewsInLayout();

    if (mAdapter == null || mAdapter.getItemCount() == 0) {
        return;
    }

    int displayCount = Math.min(mAdapter.getItemCount(), MAX_CHILDREN_COUNT);

    //單佈局處理
    if (singleMode && displayCount == 1) {
        View view = mAdapter.onCreateItemView(getContext(), this, -1);
        addView(view);
        requestLayout();
        return;
    }

    //多佈局處理
    for (int i = 0; i < displayCount; i++) {
        int itemType = mAdapter.getItemViewType(i);

        View view = mAdapter.onCreateItemView(getContext(), this, itemType);
        view.setLayoutParams(new ViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT));
        addView(view);
    }
    requestLayout();
}

```

需要注意的是我們再測量的佈局的時候,如果沒有 Adpter 或者沒有子佈局的時候,我們需要單獨處理一下九宮格ViewGroup的高度。

```java @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec) - getPaddingLeft() - getPaddingRight();
    int notGoneChildCount = getNotGoneChildCount();

    if (mAdapter == null || mAdapter.getItemCount() == 0 || notGoneChildCount == 0) {
        setMeasuredDimension(widthSize, 0);
        return;
    }

    ...
}

```

那麼如何綁定佈局呢?在我們 onLayout完成之後我們就可以綁定數據了。

```java @Override protected void onLayout(boolean changed, int l, int t, int r, int b) {

    ...

    performBind();

}

/**
 * 佈局完成之後綁定對應的數據到對應的ItemView
 */
private void performBind() {

    if (mAdapter == null || mAdapter.getItemCount() == 0) {
        return;
    }

    post(() -> {

        for (int i = 0; i < getNotGoneChildCount(); i++) {
            int itemType = mAdapter.getItemViewType(i);
            View view = getChildAt(i);

            mAdapter.onBindItemView(view, itemType, i);
        }

    });

}

```

具體的實現就是在 Adapter 中實現了。

例如我們創建一個最簡單的圖片九宮格適配器。

```java public class ImageNineGridAdapter extends AbstractNineGridLayout.Adapter { private List mDatas = new ArrayList<>();

public ImageNineGridAdapter(List<String> data) {
    mDatas.addAll(data);
}

@Override
public int getItemCount() {
    return mDatas.size();
}

@Override
public View onCreateItemView(Context context, ViewGroup parent, int itemType) {
    return LayoutInflater.from(context).inflate(R.layout.item_img, parent, false);
}

@Override
public void onBindItemView(View itemView, int itemType, int position) {

    itemView.findViewById(R.id.iv_img).setBackgroundColor(Color.RED);
}

} ```

在Activity中設置對應的數據適配器:

java findViewById<AbstractNineGridLayout>(R.id.nine_grid).run { setSingleModeSize(dp2px(200f), dp2px(400f)) setAdapter(ImageNineGridAdapter(imgs)) }

我們就能得到同樣的效果:

image.png

如果想九宮格內使用不同的佈局,不同的索引展示不同的邏輯,都可以很方便的實現:

```java public class ImageNineGridAdapter extends AbstractNineGridLayout.Adapter { private List mDatas = new ArrayList<>();

public ImageNineGridAdapter(List<String> data) {
    mDatas.addAll(data);
}

@Override
public int getItemViewType(int position) {
    if (position == 1) {
        return 10;
    } else {
        return 0;
    }
}

@Override
public int getItemCount() {
    return mDatas.size();
}

@Override
public View onCreateItemView(Context context, ViewGroup parent, int itemType) {
    if (itemType == 0) {
        return LayoutInflater.from(context).inflate(R.layout.item_img, parent, false);
    } else {
        return LayoutInflater.from(context).inflate(R.layout.item_img_icon, parent, false);
    }

}

@Override
public void onBindItemView(View itemView, int itemType, int position) {

    if (itemType == 0) {
        itemView.findViewById(R.id.iv_img).setBackgroundColor(position == 0 ? Color.RED : Color.YELLOW);
    }

}

} ```

效果:

image.png

到這裏我們的控件就基本上能實現大部分業務需求了,接下來我會對一些屬性與配置進行抽取,並開源上傳到雲端。

後記

總的來説,只要理解了ViewGroup的測量與佈局之後,像類似的效果都可以實現,如果想要一些特殊的寬高與效果,大家完全可以自行修改。

如果想看類型微信微博的那種列表,可以看看我之前的文章【傳送門】。裏面有完整的實現流程。

關於本文的內容如果想查看源碼可以點擊這裏 【傳送門】。你也可以關注我的這個Kotlin項目,我有時間都會持續更新。

具體的組件等我整理一下,我開源到Maven上面。待續...

後續的文章可能會講一下 ViewGroup 的事件處理,之前講過View的事件處理,為什麼又説ViewGroup,因為他們還是有區別的,和 View 的事件處理相比多了幾種分類,常用幾種分類大致如下:一種是自己滾動的,一種是事件攔截與分發的,一種是內部協調滾動的,每種又分不同的實現方式,待續...

慣例,我如有講解不到位或錯漏的地方,希望同學們可以指出交流。

如果感覺本文對你有一點點的啟發,還望你能點贊支持一下,你的支持是我最大的動力。

Ok,這一期就此完結。

本文正在參加「金石計劃 . 瓜分6萬現金大獎」

「其他文章」