Android自定義View:ViewGroup(三)

zeroXuan發表於2019-05-05

自定義ViewGroup本質是什麼?

自定義ViewGroup本質上就幹一件事——layout

layout

我們知道ViewGroup是一個組合View,它與普通的基本View(只要不是ViewGroup,都是基本View)最大的區別在於,它可以容納其他View,這些View既可以是基本View,也可以ViewGroup,但是在我們的ViewGroup眼中,不管是View還是ViewGroup,它們都抽象成了一個普通的View,ViewGroup的最最根本的職責就是,在自己內部,給它們每一個人找一個合適的位置,也就是呼叫它們的如下方法:

public void layout(int left, int top, int right, int bottom)
複製程式碼

如圖所示:

Viewgroup_layout

這個方法,既確定了子View的位置,也確定了子View的大小,請注意,這個大小是由我們的ViewGroup最後決定的分給該子View的螢幕區域大小

一般情況下,ViewGroup在設定這個大小時,會考慮子View的自身要求的,也就是它們measured的大小(getMeasuredWidth , getMeasuredHeight),通常最後給每個子View設定的大小就是它們所要求的大小,但這不是絕對的。

假如有一個二愣子性格的ViewGroup,它宣稱:“我所有的子View的大小都必須是30*30的尺寸!”,這種SB的ViewGroup在呼叫每個子View的layout方法時,通過讓bottom-top=right-left=30,就把所有的子View最後佔據的螢幕區域設定為30*30了,不管各個子View所要求的大小是多少,此時都沒有任何用處了。

當然,除了有特殊需求,我相信沒人願意用這種ViewGroup的,這裡我們可以知道,我們自定義ViewGroup,大體上有兩條路可選:

  • 一條就是讓這個ViewGroup滿足我們開發中的特定需求,這個時候,你可以隨心所欲地去定義ViewGroup,反正我也只是自己用,不打算給別人用的。
  • 另一條就是自定義一個ViewGroup,提供給更多的人使用,這個時候,你就要遵守一些基本的規矩,讓你的ViewGroup符合使用者的使用習慣和期望,這樣大家才能願意用你的ViewGroup。

    **那麼使用者使用一個ViewGroup最基本的期望是什麼?**我想,應該是使用者放入這個ViewGroup中的子View,layout出來的尺寸和每個子View measured的尺寸相符。只有這樣,才能確保使用者的每個子View順利完成自己的互動任務。

對於上面的圖,有兩點非常容易讓人產生誤解,需要解釋一下:

  • 關於left、right、top、bottom。它們都是座標值,既然是座標值,就要明確座標系,這個座標系是什麼?我們知道,這些值都是ViewGroup設定的,那麼,這個座標系自然也是由ViewGroup決定的了。這個座標系就是以ViewGroup左上角為原點,向右x,向下y構建起來的。

    ViewGroup的左上角又在哪裡呢?我們知道,在ViewGroup的parent(也是ViewGroup)眼中,我們的ViewGroup就是一個普通的View,parent也會呼叫我們的ViewGroup的如下方法:

    //注意,這個layout方法是ViewGroup的parent在layout我們的ViewGroup,
    //不要和我們的ViewGroup layout自己的子View搞混了。
    public void layout(int left, int top, int right, int bottom)
    複製程式碼

    此時,我們ViewGroup的左上角,就是在parent的座標系內的點(left,top)。好奇的你可能又問,假如我們的ViewGroup沒有parent,它的左上角在螢幕上的位置又該如何確定?系統控制的Window都有一個DecorView,我們所能建立的View也好,ViewGroup也好,都是它的兒子、孫子、重孫、重重孫......,所以不用擔心我們的ViewGroup沒有parent,至於DecorView左上角在螢幕上的位置,是由系統幫我們決定的,我們不用操那麼多心。

    由此我們看到,Google建立的這一套座標系統非常的高效,只要確定DecorView左上角在螢幕上的位置,那麼,所有的View在螢幕上的相對位置都可以精準地確定。

  • 第二點就是上圖中代表ViewGroup的那個方框。

    • 那麼這個方框是什麼意思?
    • 是代表ViewGroup的大小嗎?
    • 如果是的話,這個大小是不是ViewGroup在onMeasure方法中設定的各個子View大小的和?

    正確的答案是,這個方框是ViewGroup的parent在layout我們的ViewGroup時,給ViewGroup設定的大小,parent呼叫我們的ViewGroup的如下layout方法:

    /注意,這個layout方法是ViewGroup的parent在layout我們的ViewGroup,
    //不要和我們的ViewGroup layout自己的子View搞混了。
    public void layout(int left, int top, int right, int bottom)
    複製程式碼

    上圖中,代表ViewGroup的方框的寬是上述方法中的right-left,方框的高是bottom-top。我們一般將這個寬高稱為 availableWidthavailableHeight(請記住這兩個值,下面還要用到),它們表示的是我們的ViewGroup總共可以獲得的螢幕區域大小(請仔細體會available的含義)。

    那麼問題來了,假如我們的ViewGroup的parent是二球貨,給我們的ViewGroup設定的寬高小於我們的ViewGroup measured的寬高,讓我們的ViewGroup怎麼優雅地layout自己的子View 呢?

    答案是:我們的ViewGroup在layout自己的子View時,想怎麼layout就怎麼layout,可以diao,也可以不diao parent給自己設定的尺寸。

    為什麼是這樣呢?既然可以不diao這個尺寸,為什麼我們的ViewGroup還要辛苦地在onMeasure方法中計算每一個子View的寬高,還二乎乎地將它們的尺寸加起來,告訴它的parent呢?

ViewGroup如何優雅的Layout

ViewGroup在自己的layout方法中,獲得了parent給自己設定的尺寸大小,即 availableWidthavailableHeight這個值相當於parent告訴ViewGroup:“請以你的左上角為圓點,向右為x,向下為y的座標系,給你的每一個子View確定位置和大小。我可以向你保證,這個座標系中的點P1(0,0)、點P2(availableWidth,0)、點P3(0,availableHeight)、點P4(availableWidth,availableHeight)組成的方框區域內的子View都可以獲得在手機螢幕(這裡指硬體意義上的螢幕)上展示自己的機會。這個方框之外的子View,能不能在手機螢幕上展示自己,我就管不了了。”

從這裡我們看到,parent給我們的ViewGroup設定的尺寸,並不一定就完全對應著手機螢幕上的一塊相同大小的區域,在有些情況下,parent給我們的ViewGroup設定的這個尺寸可能比整個手機螢幕還大。但是,parent仍然向我們保證,在該區域內layout的子View,都能獲得在手機螢幕上展示自己的機會,parent是如何做到這一點的呢?答案是:通過parent的scroll功能。這裡我們不詳細敘述scroll,如果你不是很理解,請檢視相關資料。

好奇的我們可能要問:“假如我是一個ViewGroup,我把一個子View的一部分layout在了parent給定的區域內,另一部分超出了該區域,這個子View是不是最多隻能獲得部分展示自己的機會?”不用懷疑,答案是:Yes!

你可能還要問:“那些完全被layout在parent限定的區域之外的子View怎麼辦呢?它們難道就該在無邊黑暗中永不見天日嗎?”這確實有點殘酷,所以,作為一個ViewGroup,你可以有三個選擇:

  • 選擇一:很簡單,不要將子View 放到這個區域之外,萬事大吉! 如果這個ViewGroup的子View數量太多,parent給限定的區域實在放不下它們怎麼辦?此時ViewGroup可以讓子View重疊,以便所有的子View能夠在parent限定的區域內layout出來。
  • 選擇二:讓你的ViewGroup實現scroll功能,從而確保parent限定區域外的子View也能夠有機會展示自己。
  • 選擇三:將你的ViewGroup的parent換成ScrollView。這樣你的ViewGroup就不用自己實現scroll功能了。但是ScrollView只能允許子View的高度超過自己,不允許子View的寬度超過自己。所以,作為ViewGroup,可以在不超過availableWidth的情況下,將子View layout 到任意的高度上。如下圖所示:

Android自定義View:ViewGroup(三)

看到沒?作為一個優秀的ViewGroup,當你layout自己的子View時,只要保證子View在availableWidth之內,即使超過了parent要求的高度也沒有關係,開發者還是願意使用你的,因為他們可以為你指定ScrollView作為parent。

這就是我們看到許多的ViewGroup在layout子View時,寧超高度,不超寬度的原因。

至此,你應該明白,上文中我們提出的,對於parent指定的availableWidth和availableHeight,作為ViewGroup還是要儘量不超過parent限定的區域,如果一定要超過的話,那就超availableHeight,而不要超availableWidth

瞭解一下layout_gravity

我們看到,Android系統提供的FrameLayout、LinearLayout等都支援子View設定layout_gravity,它到底是幹什麼用的?我們自己自定義ViewGroup時能不能也用上它?

關於它的作用,一句話就能說明白,當ViewGroup給子View分配的空間超過子View要求的大小時,就需要gravity幫助ViewGroup為子View精確定位。可見,layout_gravity就是ViewGroup在layout階段,協助ViewGroup給它的子View確定位置的,沒錯,就是協助確定子View的 left,top,bottom,right四個值。

下面,我們以FrameLayout為例來進行說明。假設FrameLayout中有一個子View,這個子View的所要求的展示尺寸(measuredWidth,measuredHeight)小於FrameLayout的尺寸,但是FrameLayout是個實心眼,它不管子View要求多大,都會把它所有的螢幕區域給子View,這樣就可以保證,使用者在這個區域中的互動動作,都是與子View的互動。那麼問題來了,FrameLayout在layout子View時,總不能讓它的left和top為0,right和bottom等於自己的寬和高吧。如果這麼幹,子View就要在這個尺寸下,繪製自己,就不可避免地要對它包含的drawables進行拉伸,展示效果必然受到影響,那怎麼辦?

FrameLayout會提取子View的 LayoutParams中的gravity,看看子View想在哪個位置,假設子View的layout_gravity的值是"top|left",那麼FrameLayout就會把子View layout到自己的左上角,大小嘛就是子View所要求的大小。但是請注意,雖然此時子View繪製時是按照自己要求的大小繪製的,但是,能與它發生互動的區域卻是整個FrameLayout所佔的螢幕區域。

所以,要不要使用layout_gravity,就看你自定義的ViewGroup是不是給子View分配大於它們要求的空間。

下面我就舉一個簡單的例子來說明。

假設ViewGroup現在要layout一個子View,如下是該子View要求的尺寸大小:

final int childWidth = child.getMeasuredWidth(); 
final int childHeight = child.getMeasuredHeight();
複製程式碼

現在,ViewGroup要給這個子View設定位置和大小了。設定的位置和大小用如下四個參數列示:

bigLeft,bigTop,bigRight,bigBottom。
複製程式碼

這四個值在ViewGroup的以左上角為原點,向右x,向下y的座標系中構成了一個矩形。如下:

Rect bigRect = new Rect( bigLeft, bigTop, bigRight, bigBottom);
複製程式碼

進一步假設這個bigRect的寬高大於子View要求的寬高(是為了更明顯地說明layout_gravity的作用,實際情況可能不是這樣的),如下圖所示:

Android自定義View:ViewGroup(三)

現在ViewGroup準備把bigRect區域全部分給子View,但是ViewGroup顯然不能直接這樣layout 子View:

child.layout(bigLeft,bigTop,bigRight,bigBottom);
複製程式碼

這樣的話,child就要在bigRect區域內繪製自己,不可避免地要拉伸自己,導致展示的效果變差(想像一下1010的圖片擴成100100是什麼效果)。所以,我們需要在bigRect內進一步為子View定位,怎麼定位?

  • 第一步就是讀出子View的LayoutParams物件中的layout_gravity值。如下:
final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 
int child_layout_gravity = lp.gravity;
複製程式碼

從上面程式碼可以看出,layout_gravity最終是以整數的形式存放於子View的LayoutParams中的。

  • 第二步就是構建一個空的Rect,準備接收為子View定位後的四個座標值,如下:
Rect smallRect = new Rect();
複製程式碼
  • 第三步就是見證奇蹟的時刻,如下:
Gravity.apply(child_layout_gravity, childWidth, childHeight, bigRect, smallRect);
複製程式碼

經過上面的呼叫,Gravity會在smallRect中存入依據子View的layout_gravity以及子View要求的尺寸,在bigRect中為子View精確定位後的座標值,注意這個座標值所在的座標系還是ViewGroup的座標系。所以,我們現在可以愉快地layout子View了。

child.layout(smallRect.left, smallRect.top, smallRect.right, smallRect.bottom);
複製程式碼

示例

自定義一個ViewGroup,名為CustomLayout,效果如下:

Android自定義View:ViewGroup(三)

程式碼如下,註釋的很清晰:

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

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

    public CustomLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        this(context, attrs, defStyleAttr, 0);
    }

    @TargetApi(21)
    public CustomLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        /*
         *
         * maxHeight和maxWidth就是我們最後計算彙總後的ViewGroup需要的寬和高。
         * 用來報告給ViewGroup的parent。
         *
         * 在計算maxWidth時,我們首先簡單地把所有子View的寬度加起來,
         * 如果該ViewGroup所有的子View的寬度加起來都沒有
         * 超過parent的寬度限制,那麼我們把該ViewGroup的measured寬度設為maxWidth,
         * 如果最後的結果超過了parent的寬度限制,我們就設定measured寬度為parent的限制寬度,
         * 這是通過對maxWidth進行resolveSizeAndState處理得到的。
         *
         * 對於maxHeight,在每一行中找出最高的一個子View,然後把所有行中最高的子View加起來。
         * 這裡我們在報告maxHeight時,也進行一次resolveSizeAndState處理。
         *
         */
        int maxHeight = 0;
        int maxWidth = 0;

        /*
         * mLeftHeight表示當前行已有子View中最高的那個的高度。當需要換行時,把它的值加到maxHeight上,
         * 然後將新行中第一個子View的高度設定給它。
         *
         * mLeftWidth表示當前行中所有子View已經佔有的寬度,
         * 當新加入一個子View導致該寬度超過parent的寬度限制時,
         * 增加maxHeight的值,同時將新行中第一個子View的寬度設定給它。
         *
         */

        int mLeftHeight = 0;
        int mLeftWidth = 0;

        final int count = getChildCount();

        final int widthSize = MeasureSpec.getSize(widthMeasureSpec);

        // 遍歷我們的子View,並測量它們,根據它們要求的尺寸
        // 進而計算我們的StaggerLayout需要的尺寸。
        for (int i = 0; i < count; i++) {
            final View child = getChildAt(i);

            //可見性為gone的子View,我們就當它不存在。
            if (child.getVisibility() == GONE) {
                continue;
            }

            // 測量該子View
            measureChild(child, widthMeasureSpec, heightMeasureSpec);

            //簡單地把所有子View的測量寬度相加。
            maxWidth += child.getMeasuredWidth();
            mLeftWidth += child.getMeasuredWidth();

            //這裡判斷是否需將index 為i的子View放入下一行,
            // 如果需要,就要更新我們的maxHeight,mLeftHeight和mLeftWidth。
            if (mLeftWidth > widthSize) {
                maxHeight += mLeftHeight;
                mLeftWidth = child.getMeasuredWidth();
                mLeftHeight = child.getMeasuredHeight();
            }
            else {
                mLeftHeight = Math.max(mLeftHeight, child.getMeasuredHeight());
            }
        }

        //這裡把最後一行的高度加上,注意不要遺漏。
        maxHeight += mLeftHeight;

        //這裡將寬度和高度與Google為我們設定的建議最低寬高對比,
        // 確保我們要求的尺寸不低於建議的最低寬高。
        maxHeight = Math.max(maxHeight, getSuggestedMinimumHeight());
        maxWidth = Math.max(maxWidth, getSuggestedMinimumWidth());

        //報告我們最終計算出的寬高。
        setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, 0),
                             resolveSizeAndState(maxHeight, heightMeasureSpec, 0));
    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        final int count = getChildCount();

        //childLeft和childTop代表在staggerLayout的座標系中,
        // 能夠用來Layout子View的區域的左上角的頂點座標
        final int childLeft = getPaddingLeft();
        final int childTop = getPaddingTop();

        //childRight代表在StaggerLayout的座標系中,
        // 能夠用來Layout子view的區域的右邊那條邊的座標
        final int childRight = r - l - getPaddingRight();

        //curLeft和curTop代表StaggerLayout準備用來Layout子View的起點座標,
        // 這個點的座標隨著子View一個一個的被layout,在不斷變化。maxHeight代表當前行中最高的子View的高度,
        // 需要換行時,curTop要加上該值,以確保新行中的子View不會與上一行中的子View發生重疊
        int curLeft, curTop, maxHeight;

        maxHeight = 0;
        curLeft = childLeft;
        curTop = childTop;

        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);

            if (child.getVisibility() == GONE) {
                continue;
            }

            int curWidth, curHeight;
            curWidth = child.getMeasuredWidth();
            curHeight = child.getMeasuredHeight();

            //用來判斷是否應當將該子View放到下一行
            if (curLeft + curWidth >= childRight) {
                 /*
                    需要移到下一行時,更新curLeft和curTop的值,使它們指向下一行的起點
                    同時將maxHeight清零。
                     */
                curLeft = childLeft;
                curTop += maxHeight;
                maxHeight = 0;
            }

            //所有的努力只為了這一次layout
            child.layout(curLeft, curTop, curLeft + curWidth, curTop + curHeight);

            //更新maxHeight和curLeft
            if (maxHeight < curHeight) {
                maxHeight = curHeight;
            }

            curLeft += curWidth;
        }

    }
}
複製程式碼

目錄結構

參考文章

milter:教你步步為營掌握自定義ViewGroup

相關文章