【Android】自定義ViewGroup

關(guān)于View的工作原理忙灼、繪制流程等罚攀,在第4章 View的工作原理這篇文章已經(jīng)寫(xiě)了鲜棠。本文詳細(xì)說(shuō)一下自定義ViewGroup亭敢。

ViewGroup 繼承自View,所以 ViewGroup 是一種包含子View的特殊View墨状。
自定義 ViewGroup 有兩個(gè)主要步驟卫漫,重寫(xiě) onMeasureonLayout

要注意到的是肾砂,ViewGroup 默認(rèn)是不走 onDraw 回調(diào)的列赎。如果想要 ViewGroup 走 onDraw 回調(diào),需要在 ViewGroup 的構(gòu)造方法中調(diào)用setWillNotDraw(false)镐确。

一包吝、重寫(xiě)onMeasure

onMeasure 的目的是測(cè)量該 ViewGroup 和其所有子 View 的寬和高。
雖然通常情況下辫塌,ViewGroup 都會(huì)重寫(xiě) onMeasure 方法漏策,但這并不是必須的。如果 ViewGroup 不重寫(xiě) onMeasure 的話(huà)臼氨,默認(rèn)使用 View 的 onMeasure 方法掺喻,其表現(xiàn)為除非設(shè)置其寬(高)為固定的大小,否則其寬(高)與父容器相同储矩。

onMeasure 方法有兩個(gè)參數(shù)感耙,widthMeasureSpecheightMeasureSpec。關(guān)于 MeasureSpec 持隧,在文章《【Android】MeasureSpec簡(jiǎn)述》中有詳細(xì)說(shuō)明即硼,這里就不贅述了。

一般來(lái)講屡拨,ViewGroup 需要先遍歷測(cè)量所有的子 View只酥,然后再根據(jù)子 View 的測(cè)量結(jié)果來(lái)計(jì)算自身的尺寸。

一種方式是調(diào)用measureChildren方法呀狼,可以一次性測(cè)量所有的子 View裂允。然后,遍歷所有子 View哥艇,根據(jù)其measuredWidthmesuredHeight計(jì)算 ViewGroup 的尺寸绝编。

另一種方式是,遍歷所有子 View貌踏,使用measureChildmeasureChildWithMargin(二者的區(qū)別是十饥,這個(gè) ViewGroup 的 LayoutParams 是否允許 margin,這點(diǎn)將在第三節(jié)中說(shuō)明)來(lái)測(cè)量子 View祖乳,并在這個(gè)過(guò)程中計(jì)算 ViewGroup 的大小逗堵。

在計(jì)算完畢后,調(diào)用setMeasuredDimension(w, h)來(lái)設(shè)置最終的測(cè)量結(jié)果凡资。這跟自定義 View 是相同的砸捏。

上面說(shuō)的正常情況下谬运,先測(cè)量子 View 再測(cè)量 ViewGroup ,那么非正常情況呢垦藏?比如不測(cè)量子 View 或者瞎測(cè)量梆暖,會(huì)有什么后果?這個(gè)放在第四章掂骏。

目標(biāo)是實(shí)現(xiàn)一個(gè)簡(jiǎn)易版的FrameLayout轰驳。以下代碼通過(guò)重寫(xiě)onMeasure,實(shí)現(xiàn)了簡(jiǎn)易版FrameLayout的測(cè)量:

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val widthSize = MeasureSpec.getSize(widthMeasureSpec)
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        val heightSize = MeasureSpec.getSize(heightMeasureSpec)

        // 測(cè)量所有子View
        measureChildren(widthMeasureSpec, heightMeasureSpec)

        // 如果是寬高都是固定值弟灼,那么就直接返回
        if (widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
            setMeasuredDimension(widthSize, heightSize)
            return
        }
        var maxChildWidth = 0 // 子View的最大寬度
        var maxChildHeight = 0 // 子View的最大高度
        for (i in 0 until childCount) {
            val child = getChildAt(i) // 子View
            maxChildWidth = max(maxChildHeight, child.measuredWidth)
            maxChildHeight = max(maxChildHeight, child.measuredHeight)
        }
        setMeasuredDimension(
            resolveSize(maxChildWidth, widthMeasureSpec),
            resolveSize(maxChildHeight, heightMeasureSpec)
        )
    }

其中级解,resolveSize(size, measureSpec)在測(cè)量模式為AT_MOST,并且size < specSize時(shí)田绑,返回size勤哗;其他情況下,將返回specSize掩驱。

這很容易理解芒划,(FrameLayout的測(cè)量邏輯)簡(jiǎn)單的來(lái)說(shuō)就是:

  • 當(dāng) ViewGroup 設(shè)置的寬度為固定值時(shí),最終寬度為這個(gè)固定值欧穴;
  • 當(dāng) ViewGroup 設(shè)置的寬度為match_parent時(shí)民逼,最終寬度為其父容器的寬度;
  • 當(dāng) ViewGroup 設(shè)置的寬度為wrap_content并且子 View 的最大寬度小于 ViewGroup 的父容器時(shí)涮帘,ViewGroup 的最終寬度等于最大子 View 的寬度拼苍;
  • 當(dāng) ViewGroup 設(shè)置的寬度為wrap_content并且子 View 的最大寬度大于等于 ViewGroup 的父容器時(shí),ViewGroup 的最終寬度等于其父容器的寬度调缨。

二疮鲫、重寫(xiě)onLayout

onLayout是自定義 ViewGroup 必須要實(shí)現(xiàn)的抽象方法,它的主要作用是確定 ViewGroup 各個(gè)子 View 的排列弦叶。

View 的位置由左棚点、上、右湾蔓、下四個(gè)邊界來(lái)描述。在計(jì)算出子 View 的四個(gè)邊界l砌梆、t默责、rb后咸包,調(diào)用child.layout(l, t, r, b)來(lái)進(jìn)行布局桃序。

對(duì)于簡(jiǎn)易的FrameLayout來(lái)講,很容易得到:

  • 子 View 的左烂瘫、上兩個(gè)邊界是與 ViewGroup 貼合的媒熊,所以這兩個(gè)邊界與 ViewGroup 相同奇适;
  • 右邊界 r 則等于左邊界l + 子View的寬度
  • 下邊界 b 等于上邊界t + 子View的寬度芦鳍。

由此嚷往,簡(jiǎn)易版FrameLayoutonLayout重寫(xiě)如下:

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        for (i in 0..childCount) {
            val child = getChildAt(i)
            child.layout(l, t, l + child.measuredWidth, t + child.measuredHeight)
        }
    }

三、自定義LayoutParams

在第一節(jié)中提到了measureChildmeasureChildWithMargin柠衅。在使用默認(rèn) LayoutParams 的時(shí)候皮仁,如果調(diào)用measureChildWithMargin,程序會(huì)報(bào)錯(cuò)菲宴,因?yàn)?code>ViewGroup.LayoutParams是不支持 margin 屬性的贷祈。
而 Android 自帶的那些 Layout 之所以支持 margin,是因?yàn)樗鼈兌加凶远x的 LayoutParams喝峦。

要實(shí)現(xiàn)自定義的 LayoutParams势誊,首先創(chuàng)建一個(gè)自定義 LayoutParams 類(lèi),然后實(shí)現(xiàn) generateDefaultLayoutParamsgenerateLayoutParams方法谣蠢。

  • generateDefaultLayoutParams方法在通過(guò)addView方法將子 View 添加到這個(gè) ViewGroup 中的時(shí)候調(diào)用粟耻,會(huì)給子 View 賦一個(gè)默認(rèn)的 LayoutParams。
  • generateLayoutParams方法有兩個(gè)重載漩怎,其中:
    generateLayoutParams(AttributeSet)將根據(jù)布局中填寫(xiě)的屬性來(lái)生成自定義的 LayoutParams 并返回勋颖;
    generateLayoutParams(LayouParams)一般和checkLayoutParams同時(shí)重寫(xiě)。在addView的過(guò)程中勋锤,當(dāng)待添加的子 View 的 LayoutParams 不滿(mǎn)足 checkLayoutParams的條件時(shí)饭玲,則調(diào)用generateLayoutParams(LayouParams)生成一個(gè)新的 LayoutParams。

上例中的簡(jiǎn)易FrameLayout想要支持 margin 屬性叁执,首先創(chuàng)建一個(gè)繼承自MarginLayoutParams的類(lèi)FrameLayoutParams

    class FrameLayoutParams: MarginLayoutParams {
        constructor(c: Context?, attrs: AttributeSet?) : super(c, attrs)
        constructor(width: Int, height: Int) : super(width, height)
    }

這里沒(méi)有添加任何屬性茄厘。如果想增加額外的自定義屬性,可以在 xml 中定義一個(gè) declare-styleable 標(biāo)簽谈宛,這與 View 的自定義屬性相同次哈,在此不再贅述。
然后再重寫(xiě)generateDefaultLayoutParamsgenerateLayoutParams 方法吆录。

    override fun generateDefaultLayoutParams() = FrameLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
    override fun generateLayoutParams(attrs: AttributeSet?) = FrameLayoutParams(context, attrs)
    override fun generateLayoutParams(p: LayoutParams?) = FrameLayoutParams(p)
    override fun checkLayoutParams(p: LayoutParams?) = p is FrameLayoutParams

因?yàn)檫@個(gè)自定義的 FrameLayoutParams 里面沒(méi)有任何的屬性窑滞,所以其實(shí)可以不創(chuàng)建新類(lèi),直接用 MarginLayoutParams 替代恢筝。

    override fun generateDefaultLayoutParams() = MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
    override fun generateLayoutParams(attrs: AttributeSet?) = MarginLayoutParams(context, attrs)
    override fun generateLayoutParams(p: LayoutParams?) = MarginLayoutParams(p)
    override fun checkLayoutParams(p: LayoutParams?) = p is MarginLayoutParams

由于添加了 margin 屬性哀卫,所以測(cè)量與布局的過(guò)程都要考慮 margin。最終SimpleFrameLayout的代碼如下:

class SimpleFrameLayout(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ViewGroup(context, attrs, defStyleAttr) {
    constructor(context: Context) : this(context, null, 0)
    constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val widthSize = MeasureSpec.getSize(widthMeasureSpec)
        val heightMode = MeasureSpec.getMode(heightMeasureSpec)
        val heightSize = MeasureSpec.getSize(heightMeasureSpec)
        // 測(cè)量所有子View
        measureChildren(widthMeasureSpec, heightMeasureSpec)
        // 如果是寬高都是固定值撬槽,那么就直接返回
        if (widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
            setMeasuredDimension(widthSize, heightSize)
            return
        }
        // 通過(guò)子View的寬(高)此改,計(jì)算出 ViewGroup 的寬(高)
        var maxChildWidth = 0 // 子View的最大寬度
        var maxChildHeight = 0 // 子View的最大高度
        for (i in 0 until childCount) {
            val child = getChildAt(i) // 子View
            maxChildWidth = max(maxChildHeight, child.measuredWidth + child.marginLeft + child.marginRight)
            maxChildHeight = max(maxChildHeight, child.measuredHeight + child.marginTop + child.marginBottom)
        }
        setMeasuredDimension(
            resolveSize(maxChildWidth, widthMeasureSpec),
            resolveSize(maxChildHeight, heightMeasureSpec)
        )
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        for (i in 0..childCount) {
            val child = getChildAt(i)
            val left = l + child.marginLeft
            val top = t + child.marginTop
            child.layout(left, top, left + child.measuredWidth, top + child.measuredHeight)
        }
    }

    override fun generateDefaultLayoutParams() = MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
    override fun generateLayoutParams(attrs: AttributeSet?) = MarginLayoutParams(context, attrs)
    override fun generateLayoutParams(p: LayoutParams?) = MarginLayoutParams(p)
    override fun checkLayoutParams(p: LayoutParams?) = p is MarginLayoutParams
}

四、不正常

上面說(shuō)了正常情況下的操作侄柔,下面看看不正常的情況下會(huì)發(fā)生什么共啃。

創(chuàng)建繼承自 ViewGroup 的類(lèi)MyLayout占调,代碼如下:

class MyLayout : ViewGroup {
    constructor(context: Context?) : super(context)
    constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
    constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            val size = MeasureSpec.makeMeasureSpec(100, MeasureSpec.EXACTLY)
            child.measure(size, size)
        }
        setMeasuredDimension(600, 1200)
    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        var top = t
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            val w = child.measuredWidth
            val h = child.measuredHeight
            child.layout(l, top, l + w, top + h)
            top += h
        }
    }
}

在這個(gè)類(lèi)中,強(qiáng)制把所有子 View 的寬高都設(shè)為了100像素移剪,并且在豎直方向按照順序依次排列究珊。并且把 ViewGroup 的尺寸設(shè)置為固定的 600x1200。那么挂滓,實(shí)際的顯示效果如何呢苦银?
創(chuàng)建一個(gè) test.xml,如下:

<top.littlefogcat.ui.MyLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#f00">

    <View
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:background="#00F" />

    <View
        android:layout_width="300dp"
        android:layout_height="100dp"
        android:background="#000" />
</top.littlefogcat.ui.MyLayout>

可以看到赶站,根布局是一個(gè)寬高都為match_parent幔虏、背景為紅色的的MyLayout
它有兩個(gè)子 View:第一個(gè)子 View 寬為wrap_content贝椿,高為match_parent想括,藍(lán)色背景;第二個(gè)子 View 寬高為 300dp x 100dp烙博,黑色背景瑟蜈。
實(shí)際的顯示效果如下:

4.1 實(shí)際顯示效果

可以看到,的確和上面描述的相同渣窜,父布局大小為600x1200铺根,子 View 為 100x100的正方形;不管這三個(gè)控件寬高如何設(shè)置乔宿,都不影響最終的顯示效果位迂。

甚至如下段代碼所示,都不用在onMeasure中測(cè)量子View详瑞,只用重寫(xiě)onLayout就能達(dá)到相同的效果:

class MyLayout(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : ViewGroup(context, attrs, defStyleAttr) {
    constructor(context: Context?) : this(context, null, 0)
    constructor(context: Context?, attrs: AttributeSet?) : this(context, attrs, 0)

    override fun onMeasure(w: Int, h: Int) = setMeasuredDimension(600, 1200)
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        var top = t
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            val bottom = top + 100
            child.layout(l, top, l + 100, bottom)
            top = bottom
        }
    }
}

然后掂林,我們可以在代碼中打印這些控件的大小:

    root.post {
        Log.d(TAG, "onCreate: root measured size = (${root.measuredWidth}, ${root.measuredHeight}), " +
                "root size = (${root.width}, ${root.height})")
        val count = root.childCount
        for (i in 0 until  count) {
            val child = root.getChildAt(i)
            Log.d(TAG, "onCreate: child$i/ measure=(${child.measuredWidth},${child.measuredHeight}), " +
                    "size=(${child.width},${child.height})")
        }
    }

得到以下結(jié)果:

root measured size = (600, 1200), root size = (600, 1200)
child0/ measure=(0,0), size=(100,100)
child1/ measure=(0,0), size=(100,100)

可見(jiàn)坝橡,子 View 的測(cè)量尺寸為0泻帮,因?yàn)楦揪蜎](méi)有測(cè)量過(guò)。

回過(guò)頭再來(lái)看 View 的 getMeasuredWidthgetWidth 方法:

    public final int getWidth() { return mRight - mLeft; }
    public final int getMeasuredWidth() { return mMeasuredWidth & MEASURED_SIZE_MASK; }

可以看到计寇,getMeasuredWidth返回的是 View 的 mMeasuredWidth 屬性锣杂,而這個(gè)屬性是在 ViewGroup 測(cè)量子 View 的時(shí)候通過(guò)child.measure(w, h)傳遞過(guò)去的;因?yàn)?code>MyLayout沒(méi)有測(cè)量子 View番宁,所以它的孩子的mMeasuredWidth都是0蹲堂。
getWidth返回的是mRight - mLeft,這兩個(gè)是在 onLayout 方法里面 ViewGroup 通過(guò)child.layout 方法傳遞過(guò)去的贝淤。
所以從這里可以看出來(lái)getMeasuredWidthgetWidth方法的區(qū)別了,一個(gè)是測(cè)量的寬度政供,一個(gè)是實(shí)際布局的寬度播聪。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末朽基,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子离陶,更是在濱河造成了極大的恐慌稼虎,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,311評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件招刨,死亡現(xiàn)場(chǎng)離奇詭異霎俩,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)沉眶,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,339評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)柳击,“玉大人片习,你說(shuō)我怎么就攤上這事捌肴。” “怎么了藕咏?”我有些...
    開(kāi)封第一講書(shū)人閱讀 152,671評(píng)論 0 342
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)孽查。 經(jīng)常有香客問(wèn)我,道長(zhǎng)卦碾,這世上最難降的妖魔是什么铺坞? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 55,252評(píng)論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮洲胖,結(jié)果婚禮上济榨,老公的妹妹穿的比我還像新娘绿映。我一直安慰自己,他們只是感情好叉弦,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,253評(píng)論 5 371
  • 文/花漫 我一把揭開(kāi)白布淹冰。 她就那樣靜靜地躺著库车,像睡著了一般樱拴。 火紅的嫁衣襯著肌膚如雪洋满。 梳的紋絲不亂的頭發(fā)上珍坊,一...
    開(kāi)封第一講書(shū)人閱讀 49,031評(píng)論 1 285
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼甲雅。 笑死回还,一個(gè)胖子當(dāng)著我的面吹牛虑乖,可吹牛的內(nèi)容都是我干的懦趋。 我是一名探鬼主播疹味,決...
    沈念sama閱讀 38,340評(píng)論 3 399
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼糙捺,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼诫咱!你這毒婦竟也來(lái)了洪灯?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 36,973評(píng)論 0 259
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤掏呼,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后憎夷,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體昧旨,經(jīng)...
    沈念sama閱讀 43,466評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,937評(píng)論 2 323
  • 正文 我和宋清朗相戀三年兔沃,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片额衙。...
    茶點(diǎn)故事閱讀 38,039評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖入偷,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情疏之,我是刑警寧澤暇咆,帶...
    沈念sama閱讀 33,701評(píng)論 4 323
  • 正文 年R本政府宣布,位于F島的核電站其骄,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏拯爽。R本人自食惡果不足惜钧忽,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,254評(píng)論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望耸黑。 院中可真熱鬧,春花似錦大刊、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 30,259評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)耿战。三九已至蛾绎,卻和暖如春昆箕,著一層夾襖步出監(jiān)牢的瞬間租冠,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 31,485評(píng)論 1 262
  • 我被黑心中介騙來(lái)泰國(guó)打工纤泵, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人捏题。 一個(gè)月前我還...
    沈念sama閱讀 45,497評(píng)論 2 354
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像公荧,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子循狰,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,786評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容