Android进阶之路Android开发经验谈Android自定义View

深入理解自定义ViewGroup的布局测量过程

2020-11-11  本文已影响0人  Android开发架构师

要理解如何自定义一个viewgroup的测量和布局 其实不是一件容易的事。 多数人对自定义viewgroup的布局和测量的了解仅限于 网上随处可见的taglayout的写法(对taglayout还不清楚的同学 建议都去搜搜看)

但是大部分人应该看完以后 也是懵逼的,不知道为什么应该这么写,导致这部分人以后对自定义一个viewgroup显的很没信心。今天这篇文章 就来告诉你 为什么taglayout 或者是其他viewgroup的 测量和布局 要那么写

争取一篇文章把这个知识点搞透

父view 到底是在什么时候 决定子view的布局和宽高?

class TagLayout2 @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        for (index in 0 until childCount step 1) {
            //其实最终你的view 布局在哪个位置 宽高多少 都是在这里 最终决定的
            getChildAt(index).layout(left2, top2, right2, bottom2)
        }
    }
}

其实就是在这个onlayout方法里来做的,我们上面的代码 大家可以自行体会一下, 所谓viewgroup对view的布局无非就是在onlayout方法里面 对所有的子view 逐一调用layout方法

那为什么那么多人都说viewgroup 难呢?难在哪里? 当然就是难在这个onlayout方法里 要传递的四个参数, 这个四个参数 到底应该如何确定? 这里涉及到2个问题:

  1. 这么多view 需要的 四个参数 肯定都不一样 那在onlayout方法里面 应该怎么取到这些值?
  2. 这些值 如果有一个地方 可以取到 那么这些值由谁来确定?

第一个问题,去哪取子view的4个参数值?

class TagLayout2 @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    //用一个列表来存储所有子view的rect 即可
    var listRect = arrayListOf<Rect>()

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        for (index in 0 until childCount step 1) {
            //每次layout的时候 我们去这个list 里面直接去值 就可以了
            val child = getChildAt(index)
            val childBounds = listRect[index]
            child.layout(childBounds.left, childBounds.top, childBounds.right, childBounds.bottom)
        }
    }
}

第一个问题很简单, 我们找一个地方存一下值 然后在onlayout方法里 直接取就行了

第二个问题 listRect里面的值 由谁来决定?

当然是由测量结果来决定了,那么子view的测量结果由谁来决定? 这是个很好的问题。

对于一个viewgroup来说 他的所有的子view的 测量结果 都是由 这个viewgroup的 父view 和 这个viewgroup的子view 来共同决定的。

lass TagLayout2 @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    //用一个列表来存储所有子view的rect 即可
    var listRect = arrayListOf<Rect>()

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {

        //先看看我的父view 对我的要求是什么 这里可以理解成:
        //我自己这个自定义的layout 也是要会有父view的, 那么我自己的layout params属性
        // 其实就是对应的这两个值了  match wrap 和具体的数值 
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val widthSize = MeasureSpec.getSize(widthMeasureSpec)

        //开始便利我的子view
        for (index in 0 until childCount) {
            val child = getChildAt(index)
            //取出我的子view 的layoutparams 的值 这个其实就是我的子view 在我的 xml中
            // 也是会有layout width和layout height 设置的对吧 这里取出来的值  其实就是他们的值
            //代表我的子view 自己期望的大小
            val layoutParams = child.layoutParams

            //todo  这里其实就是 少了关键的一步  就是 我的父view 对我自己的宽高 有了
            // 我自己的每个子view 所期望的宽高也有了  那么这里就要开始算了, 算什么?
            // 要算两个东西  一个是每个子view 到底多大 这个是我来决定的
            // 还有一个 就是我的每个子view 多大 算出来以后  加起来 就是我自己期望的大小了
            child.measure(childWidthMeasureSpec,childHeightMeasureSpec)

            val childBounds=listRect[index]

        }

        //子view 都算出来了 那我自己也肯定就算出来了吧
        val measureWidth=?
        val measureHeight=?
        //算完了以后 直接调用这个方法 到这里测量就全部结束了
        setMeasuredDimension(measuredWidth,measuredHeight)

    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        for (index in 0 until childCount step 1) {
            //每次layout的时候 我们去这个list 里面直接去值 就可以了
            val child = getChildAt(index)
            val childBounds = listRect[index]
            child.layout(childBounds.left, childBounds.top, childBounds.right, childBounds.bottom)
        }
    }
}

如何完成关键的todo 部分?

其实todo的部分 就是一个确定子view MeasureSpec的 东西,这个measureSpec 怎么算?当然是通过mode和size 2个一起决定啊 到这里我们再次改进代码:

我们 就以 宽度的计算为例:

 //开始便利我的子view
        for (index in 0 until childCount) {
            val child = getChildAt(index)
            //取出我的子view 的layoutparams 的值 这个其实就是我的子view 在我的 xml中
            // 也是会有layout width和layout height 设置的对吧 这里取出来的值  其实就是他们的值
            //代表我的子view 自己期望的大小
            val layoutParams = child.layoutParams

            var childWidthMode=0
            var childWidthSize=0

            val childWidthMeasureSpec=MeasureSpec.makeMeasureSpec(childWidthSize,childWidthMode)

            child.measure(childWidthMeasureSpec,childHeightMeasureSpec)

            val childBounds=listRect[index]

        }

当然这只是第一步,具体怎么算?

 override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        //我的父view 对我的要求
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val widthSize = MeasureSpec.getSize(widthMeasureSpec)

        for (index in 0 until childCount step 1) {
            val child = getChildAt(index)
            val layoutParams = child.layoutParams

            var childWidthMode = 0
            var childWidthSize = 0

            when (layoutParams.width) {
                //如果子view 想要的是match_Parent 属性 也就是尽可能充满 我自己的宽度
                //这里很多人不理解 为啥 子view 想要match_parent 下面还非要是EXACTLY 不能是AT_MOST?
                //你要这么想,如果你的第一个子view 就是match 那他其实是EXACTLY 还是 AT_MOST 没啥区别
                //但 如果你的第一个子view 不是match 而是后面的某个子view 是match 那就必定得是从剩余的空间match了
                //这种情况 当然得计算一个准确值 然后减去 已经使用的值
                LayoutParams.MATCH_PARENT -> {
                    //看一下我的父view 对我的要求
                    when (widthMode) {
                        //如果我自己的宽度是match_parent 也就是能有多大就要多大
                        //我的子view 也是能要多大就要多大
                        //那我的子view 到底应该是多大呢? 当然是我自己可用的宽度-已经用掉的宽度了
                        //有的人觉得奇怪,为什么这里不是at_most? 我的子view 都要match了 难道还不能给他most吗?
                        MeasureSpec.AT_MOST -> {
                            childWidthMode = MeasureSpec.EXACTLY
                            //这个具体的值到底是多少  当然就是我自己的宽度 - 已经用掉的宽度了
                            //啥叫已经用掉的宽度? 因为是taglayout吗  一行当然是存放多个的
                            childWidthSize = widthSize - widthUsed
                        }
                        //如果我的父view 对我的要求 是一个具体的值 也就是说我自己的宽度已经是一个具体的数值了
                        MeasureSpec.EXACTLY -> {
                            //那么 显然我对我的子view的要求 也得是一个具体的值 因为我自己的宽度都定好了啊
                            childWidthMode = MeasureSpec.EXACTLY
                            //这个具体的值到底是多少  当然就是我自己的宽度 - 已经用掉的宽度了
                            //啥叫已经用掉的宽度? 因为是taglayout吗  一行当然是存放多个的
                            childWidthSize = widthSize - widthUsed
                        }
                        //滚动的情况就会碰到这个情况了 scrollview listview 之类 不做限制的
                        MeasureSpec.UNSPECIFIED -> {
                            childWidthMode = MeasureSpec.UNSPECIFIED
                            childWidthSize = 0
                        }
                    }
                }
            }

            val childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(childWidthSize, childWidthMode)
            child.measure(childWidthMeasureSpec, childHeightMeasureSpec)

            val childBounds = listRect[index]
            childBounds.set(?, ?, ?, ?)

        }

这规则也太复杂了吧 简化版本呢?

在前面的代码中 我们完成了 width的计算(虽然只适配了一种情况) 但是大体思路 就是这样的,跟着思路你还可以完成width其他的情况或者height ,但是都这么写 也太复杂了, 所以简化版本要来了。其实上面的代码你自己好好品品 都是固定的逻辑 所以谷歌当然提供了普遍的方法来帮我们写,所以简化版本要来了

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {

        for (index in 0 until childCount step 1) {
            val child = getChildAt(index)
            //谷歌爸爸早就帮我们准备好这个方法了,可以自动帮我们来完成这个测量子view的过程
            measureChildWithMargins(child,widthMeasureSpec,widthUsed,heightMeasureSpec,heightUsed)
            val childBounds = listRect[index]
            childBounds.set(?, ?, ?, ?)

        }

        //子view 都算出来了 那我自己也肯定就算出来了吧
        val measureWidth=?
        val measureHeight=?
        //算完了以后 直接调用这个方法 到这里测量就全部结束了
        setMeasuredDimension(measuredWidth,measuredHeight)

    }

如何计算used呢?

我们对子view的测量用简化的代码 完成了以后 还要传递used的 参数值啊,那剩下计算一下这个值就可以了。 我们先考虑一下 简单版的taglayout

就是暂时不考虑 一行放不下以后的换行操作,也就是暂时不考虑 高度。暂时只考虑 横向的摆放各个子view 把这个搞明白了 剩下的高度也就能搞好了。

下面就看一下这个极简版的 只考虑横向摆放view的 layout计算思路

class TagLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    // 设置view 支持 layout margin 属性
    override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
        return MarginLayoutParams(context, attrs)
    }

    private var childBoundsList = mutableListOf<Rect>()

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        //用了多少宽度
        var widthUsed = 0
        //用了多少高度
        var heightUsed = 0

        if (childBoundsList.isEmpty()) {
            for (index in 0 until childCount) {
                childBoundsList.add(Rect())
            }
        }
        for (x in 0 until childCount) {
            val child = getChildAt(x)
            //谷歌爸爸早就帮我们准备好这个方法了,可以自动帮我们来完成这个测量子view的过程
            measureChildWithMargins(
                child,
                widthMeasureSpec,
                widthUsed,
                heightMeasureSpec,
                heightUsed
            )
            val childBounds = childBoundsList[x]
            //margin和padding 暂时不加了 
            //其实就是 你每个child的 left 坐标的起点 就是 横向宽度已经用了多少的值
            // right坐标 就是 你left的坐标 加上 view的 宽度 即可
            childBounds.set(
                widthUsed,
                heightUsed,
                widthUsed + child.measuredWidth,
                heightUsed + child.measuredHeight
            )
            // used的值 计算很简单,就是每次都加上你测量子view的宽度即可    
            widthUsed += child.measuredWidth
            //heightUsed = Math.max(heightUsed, child.measuredHeight)

        }

        //子view 都算出来了 那我自己也肯定就算出来了吧
        val measureWidth = widthUsed
        val measureHeight = 400
        //算完了以后 直接调用这个方法 到这里测量就全部结束了
        setMeasuredDimension(measureWidth, measureHeight)

    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        if (childBoundsList.isNotEmpty()) {
            for (index in 0 until childCount step 1) {
                val child = getChildAt(index)
                val childBounds = childBoundsList[index]
                child.layout(
                    childBounds.left,
                    childBounds.top,
                    childBounds.right,
                    childBounds.bottom
                )
            }
        }
    }

}

如何理解measureChildWithMargins 的 参数 widthUsed?

这个参数的意思 就是 告诉 measureChild的过程 我已经用了多少宽度了, 所以我们传了widthUsed 这个值

但是这个值如果在taglayout 里面 这么写就不对了。

我们假设taglayout本身的宽度是360 第一个子view 用了120 然后第二个子view 调用这个方法的时候 widthUsed 传的是 120对吧, 假设第二个子view 用了200的宽度。 那么第三个子view 在调用这个 方法的时候 widthUsed 就是 320了,所以这个时候measureChild返回的值就是360-320 40了这个view 被自动压缩了。

但是taglayout 我们想要的效果是什么? 我们想要的效果是 自己判断 如果这个view 在这一行放不下就换行。 所以这里widthUsed 我们要传0 传0 就是告诉 这个measureChild 你可以随便用宽度,你来返回你measure的结果就行,由我taglayout的作者来判断 最终用了多少,而不是让measureChildWithMargins 来替我决定。

这个逻辑一定要想明白。 想明白 了后面的写法 也就好明白了。

所以最终我们的逻辑就是

1.新建一个变量lineWidthUsed 他来记录 这一行 用了多少宽度 2.调用measureChildWidthMargins 方法 used参数用0 3.判断 lineWidthUsed+child.measureWidth 是否 大于 自己的可用宽度 4.最终决定是否换行

最终的代码

class TagLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {

    // 设置view 支持 layout margin 属性
    override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
        return MarginLayoutParams(context, attrs)
    }

    private var childBoundsList = mutableListOf<Rect>()

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        //用了多少宽度
        var widthUsed = 0
        //用了多少高度
        var heightUsed = 0

        //每一行用了多少宽度
        var lineWidthUsed = 0
        //每一行用了多少高度
        var lineHeight = 0
        //取出自己的宽度限制
        var widthMode = MeasureSpec.getMode(widthMeasureSpec)
        var widthSize = MeasureSpec.getSize(widthMeasureSpec)

        if (childBoundsList.isEmpty()) {
            for (index in 0 until childCount) {
                childBoundsList.add(Rect())
            }
        }
        for (x in 0 until childCount) {
            val child = getChildAt(x)
            //先测量一次 算一下这个child的 measureWidth
            measureChildWithMargins(
                child,
                widthMeasureSpec,
                0,
                heightMeasureSpec,
                heightUsed
            )
            //如果算出来的 宽度 比自己的宽度还大那就要重新测量 准备换行
            if (widthMode != MeasureSpec.UNSPECIFIED && lineWidthUsed + child.measuredWidth > widthSize) {
                //既然是重新测量了 那显然 每行已经用掉的宽度就是0了
                lineWidthUsed = 0
                //计算一下 已经用了多少高度了 因为既然换行了 heightUsed 就要增加了
                heightUsed += lineHeight
                measureChildWithMargins(
                    child,
                    widthMeasureSpec,
                    0,
                    heightMeasureSpec,
                    heightUsed
                )
            }
            //测量结束以后开始设置 bounds
            val childBounds = childBoundsList[x]
            //起点的left和top 很好理解 就是 这一行 已经用了多少 你就从这个位置开 layout
            // right和bottom 也就是加上自己的宽高 即可
            childBounds.set(
                lineWidthUsed,
                heightUsed,
                lineWidthUsed + child.measuredWidth,
                heightUsed + child.measuredHeight
            )
            //每一行已经用的 当然是加上这个child的宽度
            lineWidthUsed += child.measuredWidth
            //计算一下最大宽度 到时候自己要用
            widthUsed = Math.max(lineWidthUsed, widthUsed)
            //每一行的高度 就等于这一行里面 高度最大的那个view
            lineHeight = Math.max(lineHeight, child.measuredHeight)

        }

        //子view 都算出来了 那我自己也肯定就算出来了吧
        val measureWidth = widthUsed
        val measureHeight = (heightUsed + lineHeight)
        //算完了以后 直接调用这个方法 到这里测量就全部结束了
        setMeasuredDimension(measureWidth, measureHeight)

    }

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        if (childBoundsList.isNotEmpty()) {
            for (index in 0 until childCount step 1) {
                val child = getChildAt(index)
                val childBounds = childBoundsList[index]
                child.layout(
                    childBounds.left,
                    childBounds.top,
                    childBounds.right,
                    childBounds.bottom
                )
            }
        }
    }

}

喜欢本文的话,不妨顺手给我点个小赞、评论区留言或者转发支持一下呗😜😜😜~
点击【GitHub】还有彩蛋哦!!!

上一篇 下一篇

猜你喜欢

热点阅读