自定義ViewGroup原來如此簡單?手把手帶你寫一個流式布局!

? Android開發(fā)中,總會遇到這樣和那樣的需求。雖然官方已經(jīng)給我們提供了豐富的ViewGroupView的實現(xiàn),但是總有沒法滿足需求的時候。這個時候我們該怎么辦呢? 首先遇事不決可以先Google一下,看看有無現(xiàn)成的輪子。如果有輪子,那么恭喜,扒來改改就好啦。如果沒有輪子,那能咋辦,只能自己造輪子咯。其實使用輪子更多時候是追求穩(wěn)定和節(jié)約時間,我們還是需要對輪子的原理有一定的了解的。

? 流式布局在Android開發(fā)中使用的場景應(yīng)該還是比較多的,比如標(biāo)簽展示、搜索歷史記錄展示等等。這種樣式的布局Android目前是沒有原生的ViewGroup的,當(dāng)然你要找輪子肯定也是很容易找到的,不過今天我還是想以自定義ViewGroup的方式來實現(xiàn)這么一個容器。

什么是ViewGroup

? 首先我們得弄清楚ViewGroup是什么,還有它的職責(zé)。

? ViewGroup繼承自View,并實現(xiàn)了ViewManagerViewParent接口。按照官方的定義,ViewGroup是一個特別的View,它可以容納其他的View,它實現(xiàn)了一系列添加和刪除View的方法。同時ViewGroup還定義了LayoutParamsLayoutParams會影響ViewViewGroup的位置和大小相關(guān)屬性。

? ViewGroup也是個抽象類,需要我們重寫onLayout方法,當(dāng)然僅僅重寫這么一個方法是不夠的。ViewGroup本身只是實現(xiàn)了容納View能力,實現(xiàn)一個ViewGroup我們需要完成對自身的測量、對child的測量、child的布局等一系列的操作。

onMeasure

? 這是自定義View實現(xiàn)的一個非常重要的方法,不管我們是自定義View也好,還是自定義ViewGroup都需要實現(xiàn)它。這個方法來自于ViewViewGroup本身沒有去處理這個方法。這個方法會傳遞兩個參數(shù),分別是widthMeasureSpecheightMeasureSpec。這兩個數(shù)值其實是個混合的信息,他們包含了具體的寬高數(shù)值和寬高的模式。這里需要說一下MeasureSpec。

MeasureSpec

? MeasureSpecView的內(nèi)部類,他是父容器給孩子傳遞的布局信息的一個壓縮體。上文提到的傳遞的數(shù)值,其實是通過MeasureSpecmakeMeasureSpec方法生成的:

public static class MeasureSpec {
        private static final int MODE_SHIFT = 30;
        private static final int MODE_MASK  = 0x3 << MODE_SHIFT;

        @IntDef({UNSPECIFIED, EXACTLY, AT_MOST})
        @Retention(RetentionPolicy.SOURCE)
        public @interface MeasureSpecMode {}

        public static final int UNSPECIFIED = 0 << MODE_SHIFT;
        public static final int EXACTLY     = 1 << MODE_SHIFT;
        public static final int AT_MOST     = 2 << MODE_SHIFT;

        public static int makeMeasureSpec(@IntRange(from = 0, to = (1 << MeasureSpec.MODE_SHIFT) - 1) int size,
                                          @MeasureSpecMode int mode) {
            if (sUseBrokenMakeMeasureSpec) {
                return size + mode;
            } else {
                return (size & ~MODE_MASK) | (mode & MODE_MASK);
            }
        }
  //...

? 其實MeasureSpec代表一個32位的int值,高2位表示SpecMode,低30位表示SpecSize,我們可以分別通過getModegetSize獲取對應(yīng)的信息。表示什么信息算是搞清楚了,那么這些信息又是如何確認(rèn)的呢?

? 在ViewGroup中有個getChildMeasureSpec方法,這個方法的實現(xiàn)基本可以解答我們的疑問

public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
    int specMode = MeasureSpec.getMode(spec);
    int specSize = MeasureSpec.getSize(spec);
    int size = Math.max(0, specSize - padding);
    int resultSize = 0;
    int resultMode = 0;
    switch (specMode) {
    // Parent has imposed an exact size on us
    case MeasureSpec.EXACTLY:
        if (childDimension >= 0) {
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            // Child wants to be our size. So be it.
            resultSize = size;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size. It can't be
            // bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        }
        break;
    // Parent has imposed a maximum size on us
    case MeasureSpec.AT_MOST:
        if (childDimension >= 0) {
            // Child wants a specific size... so be it
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            // Child wants to be our size, but our size is not fixed.
            // Constrain child to not be bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size. It can't be
            // bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        }
        break;
    // Parent asked to see how big we want to be
    case MeasureSpec.UNSPECIFIED:
        if (childDimension >= 0) {
            // Child wants a specific size... let him have it
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } else if (childDimension == LayoutParams.MATCH_PARENT) {
            // Child wants to be our size... find out how big it should
            // be
            resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
            resultMode = MeasureSpec.UNSPECIFIED;
        } else if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size.... find out how
            // big it should be
            resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
            resultMode = MeasureSpec.UNSPECIFIED;
        }
        break;
    }
    //noinspection ResourceType
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}

? 代碼長度還是有點(diǎn)長,但是邏輯并不復(fù)雜。spec參數(shù)為ViewGroup的相關(guān)信息,padding則為ViewGroup的leftPadding+rightPadding+childLeftMargin+childRightMargin+usedWidth,childDimension為child的LayoutParams中指定的寬高信息。

? child的具體的MeasureSpec會受到父容器的影響,也和自身的布局信息有關(guān),具體如下:

  • 如果child的LayoutParams指定了固定的寬高,如100dp,則最終onMeasure被傳遞的size就是指定的寬高,mode則是MeasureSpec.EXACTLY
  • 如果child的寬高信息為MATCH_PARENT,這時候傳遞的size通常為父容器的寬高,mode則會和父容器的mode保持一致。
  • 如果child的寬高信息為WRAP_CONTENT,這時候傳遞的size也一樣是父容器的寬高,如果父容器的mode是MeasureSpec.UNSPECIFIED,則傳遞的mode是MeasureSpec.UNSPECIFIED,否則為MeasureSpec.AT_MOST。

? 這個specMode,簡單的來說EXACTLY就代表寬高信息是比較確認(rèn)的,AT_MOST則是會告訴你一個最大寬度,實際寬度由你自己確認(rèn),UNSPECIFIED也是會告訴你一個父容器寬度,你也可以設(shè)置為任意高度。

onMeasure方法里應(yīng)該做什么

? 上面說了一堆關(guān)于MeasureSpec的,現(xiàn)在再來說一下onMeasure方法里應(yīng)該做什么。

? 如果是自定義View,我們需要根據(jù)父容器傳遞的MeasureSpec來確認(rèn)自身的寬高。如果是MeasureMode是EXACTLY,則這個View的寬高就是傳遞過來的size,如果是AT_MOST和UNSPECIFIED,則需要我們自行處理了。在我們計算得到了一個想要的寬高信息后,需要調(diào)用setMeasuredDimension的方法來保存信息。

? 如果是自定義ViewGroup,那我們需要做的事情可能就要多一點(diǎn)了,首先我們也還是一樣,需要確認(rèn)ViewGroup自身的寬高信息,如果都是EXACTLY拿很好辦,直接設(shè)置對應(yīng)的size即可。如果想要支持WRAP_CONTENT,這時候可能就會比較麻煩一點(diǎn)了。首先我們得想好一點(diǎn),這個ViewGroup是如何為child布局的。這很重要,因為不同的布局方式,child的排布不同,都會影響實際占用的空間。

? 還是以LinearLayout舉例吧,LinearLayout支持橫向排列和縱向排列,他們需要執(zhí)行的測量邏輯都是不一樣的。如果是縱向排列,則需要遍歷child,測量child,并累加他們的高度和margin,最后還要加上自身高度,這樣累加出來的數(shù)值就是WRAP_CONTENT下,自身應(yīng)該占用的高度。如果是橫向排列,則需要遍歷和累加child,并累加他們的寬度和margin等,原理都是差不多的。

? 總結(jié)一下,onMeasure方法需要ViewGroup結(jié)合父容器傳遞的MeasureSpec測量child,配合child的排布方式,確認(rèn)自身的寬高。

onLayout

? onLayout方法傳遞了5個參數(shù),changed表示自身的位置或大小是否發(fā)生了改變,剩下的分別為left,top,right,bottom,決定了他在父容器的位置。這是一個相對坐標(biāo),起點(diǎn)并不是屏幕的左上角。

? 那在這個方法里我們應(yīng)該做什么呢?如果是自定義View的時候,我們可以不用管這個方法。因為View本身沒有容納child的能力,如果是ViewGroup,這時候我們就需要為child執(zhí)行布局操作了。我們需要遍歷child,執(zhí)行它們的layout方法。通過調(diào)用layout方法,我們可以傳遞left,top,right,bottom,確定child在ViewGroup中的位置。同樣的,這也是一個相對坐標(biāo),是依賴于父容器的。

? 事實上,onLayout方法是在自身的layout方法被調(diào)用后調(diào)用的。Android整體的布局體系自上而下一層層的調(diào)用,傳遞布局信息,最終確認(rèn)了各個View在屏幕上的位置。

onDraw

? 通常來說,自定義ViewGroup并不需要重寫這個方法。這個方法用來做一些繪制操作,如果是自定義View,那我們則需要重寫這個方法,實現(xiàn)一些繪制邏輯。

Padding和Margin

? 這兩個概念還是要說一下,理解一下它們的作用和實現(xiàn)原理。

  • Padding是相對于自身而言的,它影響了自身的繪制和child的布局,是View自身的屬性。如果需要讓這個屬性生效,在繪制和布局時候,我們需要基于這個屬性的數(shù)值做一定的偏移,在測量的時候,我們也需要考慮它的數(shù)值,為最終測量結(jié)果添加上。
  • Margin是相對于父容器而言的,它影響了ViewViewGroup中的布局,它通常是由LayoutParams所定義的。有這個屬性的時候,我們在測量時候需要考慮到它,并且累加上,在布局的時候,需要根據(jù)響應(yīng)的屬性,進(jìn)行一定的偏移。

實現(xiàn)一個流式布局

? 道理都理清楚了,寫代碼就會簡單很多了。流式布局大概的效果就是添加的VIew按一行或者一列有序排列,如果一行或者一列放不下了,則換到下一行排列。下面就簡單實現(xiàn)一個流式布局來加深一下理解。

? 首先需要定義一個類,繼承自ViewGroup:

public class FlowLayout extends ViewGroup {
    public FlowLayout(Context context) {
        this(context, null);
    }

    public FlowLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
      //todo 實現(xiàn)測量邏輯
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
            //todo 實現(xiàn)child的布局邏輯
    }
}

? 因為我們需要支持margin屬性,所以我們還需要這樣一個LayoutParams。ViewGroup中已經(jīng)定義了這樣一個MarginLayoutParams,我們創(chuàng)建一個內(nèi)部類,繼承此類實現(xiàn):

public static class LayoutParams extends ViewGroup.MarginLayoutParams {
    public LayoutParams(Context c, AttributeSet attrs) {
        super(c, attrs);
    }

    public LayoutParams(int width, int height) {
        super(width, height);
    }

    public LayoutParams(ViewGroup.LayoutParams source) {
        super(source);
    }
}

? LayoutParams中還可以自己去定義一些個性化的布局參數(shù),這里就簡單處理了。同時我們還得注意以下幾個方法:

/**
 * 直接調(diào)用 {@link #addView(View view)}的時候 用來生成默認(rèn)的LayoutParams
 *
 * @return
 */
@Override
protected LayoutParams generateDefaultLayoutParams() {
    return new LayoutParams(-2, -2);
}

/**
 * {@link #addView(View child, ViewGroup.LayoutParams params)}時候,用來檢查布局參數(shù)是否正確
 *
 * @param p
 * @return
 */
@Override
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
    return p instanceof LayoutParams;
}

/**
 * 如果{@link #checkLayoutParams(ViewGroup.LayoutParams p)}返回false,會調(diào)用此方法生成LayoutParams
 *
 * @param p
 * @return
 */
@Override
protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
    if (p == null) {
        return generateDefaultLayoutParams();
    }
    return new LayoutParams(p);
}

? 注釋我都寫了,主要是用來用戶addView時候的默認(rèn)布局信息生成和檢測,如果沒處理好,可能會引起崩潰啥的。

? 接下來是測量方法:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    Log.d(TAG, "onMeasure");
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);
    if (widthMode == MeasureSpec.EXACTLY) {
        //橫向?qū)挾裙潭?        int lineMaxHeight = 0;//當(dāng)前行最高的行高
        int currentLeft = getPaddingLeft();//當(dāng)前child的起點(diǎn)left
        int currentTop = getPaddingTop();//當(dāng)前child的起點(diǎn)top
        //去除paddingLeft 和 paddingRight即為可用寬度
        int availableWidth = widthSize - getPaddingLeft() - getPaddingRight();
        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == GONE) {//gone的child 不處理
                continue;
            }
            //測量child
            measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
            int decoratedWidth = getDecoratedWidth(child);
            int decoratedHeight = getDecoratedHeight(child);
            if (currentLeft + decoratedWidth > availableWidth) {
                //寬度超了 換行
                currentLeft = decoratedWidth + getPaddingLeft();
                currentTop += lineMaxHeight;//高度加上之前的最大高度
                lineMaxHeight = decoratedHeight;
            } else {
                //如果不需要換行 只記錄當(dāng)前的最大高度。
                currentLeft += decoratedWidth;
                lineMaxHeight = Math.max(lineMaxHeight, decoratedHeight);
            }
            if (i == getChildCount() - 1) {
                //最后一個元素了 我們需要累加高度
                currentTop += lineMaxHeight;
            }
        }
        //保存寬高信息
        setMeasuredDimension(widthSize, currentTop + getPaddingBottom());
    } else if (heightMode == MeasureSpec.EXACTLY) {
        //todo 實現(xiàn)縱向固定的流式布局

    } else {
        //todo 實現(xiàn)寬高都固定的流式布局

    }
}

? 測量邏輯并不復(fù)雜,首先判斷ViewGroup的寬高模式,這里實現(xiàn)了寬度固定的流式布局的處理邏輯。我們需要遍歷所有的child,并調(diào)用測量方法確定他們的寬高。同時要注意的是child如果不可見則需要跳過。因為寬度是固定的,所以我們需要計算出自身的高度。getDecoratedWidth獲取的是child自身的寬度與自身的左右的margin的和。遍歷過程中依此排列child,如果一行排不下了,則執(zhí)行換行邏輯,并累加高度,最后得出高度,保存。

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    Log.d(TAG, "onLayout l :" + l + " t :" + t + " r :" + r + " b :" + b);
    int lineMaxHeight = 0;
    int currentLeft = getPaddingLeft();//當(dāng)前child的起點(diǎn)left
    int currentTop = getPaddingTop();//當(dāng)前child的起點(diǎn)top
    int availableWidth = r - getPaddingLeft() - getPaddingRight();
    for (int i = 0; i < getChildCount(); i++) {
        View child = getChildAt(i);
        if (child.getVisibility() == GONE) {//gone的child 不處理
            continue;
        }
        int decoratedWidth = getDecoratedWidth(child);
        int decoratedHeight = getDecoratedHeight(child);
        LayoutParams layoutParams = (LayoutParams) child.getLayoutParams();
        int childLeft, childTop;
        if (currentLeft + decoratedWidth > availableWidth) {
            //寬度超了 換行
            currentLeft = decoratedWidth + getPaddingLeft();
            currentTop += lineMaxHeight;//高度加上之前的最大高度
            lineMaxHeight = decoratedHeight;
            childLeft = getPaddingLeft() + +layoutParams.leftMargin;
            childTop = currentTop + layoutParams.topMargin;
        } else {
            //如果不需要換行 只記錄當(dāng)前的最大高度。
            childLeft = currentLeft + layoutParams.leftMargin;
            childTop = currentTop + layoutParams.topMargin;
            currentLeft += decoratedWidth;
            lineMaxHeight = Math.max(lineMaxHeight, decoratedHeight);
        }
        child.layout(childLeft, childTop,
                childLeft + child.getMeasuredWidth(), childTop + child.getMeasuredHeight());
    }
}

? onLayout方法里我也只是實現(xiàn)了寬度固定下的邏輯。邏輯和測量時候的思路一樣,在測量的時候我們已經(jīng)為每個child確認(rèn)了自身的寬高,在這里我們就只需要調(diào)用layout方法為每個child執(zhí)行布局邏輯即可。

? 最后上運(yùn)行效果,因為是demo所以樣式比較隨意,不要在意這些細(xì)節(jié)(#.#)

image-20201227182155002

自定義ViewGroup大致的流程就是這樣了,如果還有什么困惑還不解可以留言,我會用心解答。

本文在開源項目:https://github.com/Android-Alvin/Android-LearningNotes 中已收錄,里面包含不同方向的自學(xué)編程路線、面試題集合/面經(jīng)、及系列技術(shù)文章等,資源持續(xù)更新中...

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

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

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