美文网首页Android全集
【Android】自定义ViewGroup

【Android】自定义ViewGroup

作者: littlefogcat | 来源:发表于2021-01-29 10:50 被阅读0次

    关于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,如下:

    <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>
    

    可以看到,根布局是一个宽高都为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

        本文链接:https://www.haomeiwen.com/subject/egzxtltx.html