【Android】自定义ViewGroup

关于View的工作原理、绘制流程等,在第4章 View的工作原理这篇文章已经写了。本文详细说一下自定义ViewGroup。

ViewGroup 继承自View,所以 ViewGroup 是一种包含子View的特殊View。
自定义 ViewGroup 有两个主要步骤,重写 onMeasureonLayout

要注意到的是,ViewGroup 默认是不走 onDraw 回调的。如果想要 ViewGroup 走 onDraw 回调,需要在 ViewGroup 的构造方法中调用setWillNotDraw(false)

一、重写onMeasure

onMeasure 的目的是测量该 ViewGroup 和其所有子 View 的宽和高。
虽然通常情况下,ViewGroup 都会重写 onMeasure 方法,但这并不是必须的。如果 ViewGroup 不重写 onMeasure 的话,默认使用 View 的 onMeasure 方法,其表现为除非设置其宽(高)为固定的大小,否则其宽(高)与父容器相同。

onMeasure 方法有两个参数,widthMeasureSpecheightMeasureSpec。关于 MeasureSpec ,在文章《【Android】MeasureSpec简述》中有详细说明,这里就不赘述了。

一般来讲,ViewGroup 需要先遍历测量所有的子 View,然后再根据子 View 的测量结果来计算自身的尺寸。

一种方式是调用measureChildren方法,可以一次性测量所有的子 View。然后,遍历所有子 View,根据其measuredWidthmesuredHeight计算 ViewGroup 的尺寸。

另一种方式是,遍历所有子 View,使用measureChildmeasureChildWithMargin(二者的区别是,这个 ViewGroup 的 LayoutParams 是否允许 margin,这点将在第三节中说明)来测量子 View,并在这个过程中计算 ViewGroup 的大小。

在计算完毕后,调用setMeasuredDimension(w, h)来设置最终的测量结果。这跟自定义 View 是相同的。

上面说的正常情况下,先测量子 View 再测量 ViewGroup ,那么非正常情况呢?比如不测量子 View 或者瞎测量,会有什么后果?这个放在第四章。

目标是实现一个简易版的FrameLayout。以下代码通过重写onMeasure,实现了简易版FrameLayout的测量:

    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)

        // 测量所有子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)在测量模式为AT_MOST,并且size < specSize时,返回size;其他情况下,将返回specSize

这很容易理解,(FrameLayout的测量逻辑)简单的来说就是:

  • 当 ViewGroup 设置的宽度为固定值时,最终宽度为这个固定值;
  • 当 ViewGroup 设置的宽度为match_parent时,最终宽度为其父容器的宽度;
  • 当 ViewGroup 设置的宽度为wrap_content并且子 View 的最大宽度小于 ViewGroup 的父容器时,ViewGroup 的最终宽度等于最大子 View 的宽度;
  • 当 ViewGroup 设置的宽度为wrap_content并且子 View 的最大宽度大于等于 ViewGroup 的父容器时,ViewGroup 的最终宽度等于其父容器的宽度。

二、重写onLayout

onLayout是自定义 ViewGroup 必须要实现的抽象方法,它的主要作用是确定 ViewGroup 各个子 View 的排列。

View 的位置由左、上、右、下四个边界来描述。在计算出子 View 的四个边界ltrb后,调用child.layout(l, t, r, b)来进行布局。

对于简易的FrameLayout来讲,很容易得到:

  • 子 View 的左、上两个边界是与 ViewGroup 贴合的,所以这两个边界与 ViewGroup 相同;
  • 右边界 r 则等于左边界l + 子View的宽度
  • 下边界 b 等于上边界t + 子View的宽度

由此,简易版FrameLayoutonLayout重写如下:

    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

在第一节中提到了measureChildmeasureChildWithMargin。在使用默认 LayoutParams 的时候,如果调用measureChildWithMargin,程序会报错,因为ViewGroup.LayoutParams是不支持 margin 属性的。
而 Android 自带的那些 Layout 之所以支持 margin,是因为它们都有自定义的 LayoutParams。

要实现自定义的 LayoutParams,首先创建一个自定义 LayoutParams 类,然后实现 generateDefaultLayoutParamsgenerateLayoutParams方法。

  • generateDefaultLayoutParams方法在通过addView方法将子 View 添加到这个 ViewGroup 中的时候调用,会给子 View 赋一个默认的 LayoutParams。
  • generateLayoutParams方法有两个重载,其中:
    generateLayoutParams(AttributeSet)将根据布局中填写的属性来生成自定义的 LayoutParams 并返回;
    generateLayoutParams(LayouParams)一般和checkLayoutParams同时重写。在addView的过程中,当待添加的子 View 的 LayoutParams 不满足 checkLayoutParams的条件时,则调用generateLayoutParams(LayouParams)生成一个新的 LayoutParams。

上例中的简易FrameLayout想要支持 margin 属性,首先创建一个继承自MarginLayoutParams的类FrameLayoutParams

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

这里没有添加任何属性。如果想增加额外的自定义属性,可以在 xml 中定义一个 declare-styleable 标签,这与 View 的自定义属性相同,在此不再赘述。
然后再重写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

因为这个自定义的 FrameLayoutParams 里面没有任何的属性,所以其实可以不创建新类,直接用 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 属性,所以测量与布局的过程都要考虑 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)
        // 测量所有子View
        measureChildren(widthMeasureSpec, heightMeasureSpec)
        // 如果是宽高都是固定值,那么就直接返回
        if (widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
            setMeasuredDimension(widthSize, heightSize)
            return
        }
        // 通过子View的宽(高),计算出 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
}

四、不正常

上面说了正常情况下的操作,下面看看不正常的情况下会发生什么。

创建继承自 ViewGroup 的类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
        }
    }
}

在这个类中,强制把所有子 View 的宽高都设为了100像素,并且在竖直方向按照顺序依次排列。并且把 ViewGroup 的尺寸设置为固定的 600x1200。那么,实际的显示效果如何呢?
创建一个 test.xml,如下:



    

    

可以看到,根布局是一个宽高都为match_parent、背景为红色的的MyLayout
它有两个子 View:第一个子 View 宽为wrap_content,高为match_parent,蓝色背景;第二个子 View 宽高为 300dp x 100dp,黑色背景。
实际的显示效果如下:

4.1 实际显示效果

可以看到,的确和上面描述的相同,父布局大小为600x1200,子 View 为 100x100的正方形;不管这三个控件宽高如何设置,都不影响最终的显示效果。

甚至如下段代码所示,都不用在onMeasure中测量子View,只用重写onLayout就能达到相同的效果:

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})")
        }
    }

得到以下结果:

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

可见,子 View 的测量尺寸为0,因为根本就没有测量过。

回过头再来看 View 的 getMeasuredWidthgetWidth 方法:

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

可以看到,getMeasuredWidth返回的是 View 的 mMeasuredWidth 属性,而这个属性是在 ViewGroup 测量子 View 的时候通过child.measure(w, h)传递过去的;因为MyLayout没有测量子 View,所以它的孩子的mMeasuredWidth都是0。
getWidth返回的是mRight - mLeft,这两个是在 onLayout 方法里面 ViewGroup 通过child.layout 方法传递过去的。
所以从这里可以看出来getMeasuredWidthgetWidth方法的区别了,一个是测量的宽度,一个是实际布局的宽度。

你可能感兴趣的:(【Android】自定义ViewGroup)