手把手教你自定义流式布局

代码是kotlin代码,所以看到有些值直接调用的不要疑惑,这些直接调用的值并不是类属性实际也是调用的get和set方法

废话不多说,直接开始。

根据之前的文章,自定义ViewGroup需要重写onMeasure和onLayout方法,所以我们先来重写onMeasure方法

重写onMeasure方法分为下面几步:

  1. 遍历当前ViewGroup的所有子View,调用measureChildWithMargins方法设置每个ChildView的宽高,measureChildWithMarginsmeasureChild的区别是measureChildWithMargins同时设置了ChildView的margin值,使用measureChildWithMargins的前提是当前ViewGroup覆盖了generateLayoutParams3个方法,下面我们会说到,没有覆盖generateLayoutParams3个方法的话调用measureChildWithMargins会崩溃。
  2. 遍历的时候同时记录当前ChildView的left和top值,在onLayout方法里使用这些值直接调用ChildView的layout方法,这样就不需要在onLayout方法里再次遍历计算ChildView的layout的值了,优化代码性能。
  3. 遍历完以后调用View.resolveSize方法传入自己的宽高和宽高的MeasureSpec,来计算自己在不同父容器的MeasureSpec下的不同宽高

首先定义几个类属性

//记录执行onMeasure方法时ChildView左上角相对ViewGroup的坐标
//这样在onLayout方法就不需要再次计算了,提高效率
private val pointList = mutableListOf<Pair<Int, Int>>()
//行间距,在xml获取该值
private var mRowSpacing = 0
//列间距,在xml获取该值
private var mColumnSpacing = 0

重写的onMeasure方法如下

  1. 首先在该方法里我们先定义几个变量:

    1. 需要1个变量记录自己的宽(width)

    2. 需要1个变量记录当前行顶部坐标(相对于ViewGroup),用来最后计算该ViewGroup的高度(startY)

    3. 需要2个变量记录当前行的行宽和行高,用来判断是否需要换行(lineWidth,lineHeight)

    4. 需要1个变量记录当前ChildView的左边坐标(相对于ViewGroup)(childLeft),用来保存当前ChildView的绘制位置(left和top)
      代码如下

      //计算的宽度
      var width = 0
      //记录当前行顶部坐标(相对于ViewGroup)
      var startY = 0
      //记录当前行宽
      var lineWidth = 0
      //记录当前行高
      var lineHeight = 0
      //当前ChildView的左边坐标(相对于ViewGroup)
      var childLeft = 0
      
    5. 然后我们还需要记录2个值,因为ChildView可用空间不包括上层容器的padding值,所以先定义2个值,下面会用到

      //ViewGroup的左右padding值
      val lrPaddingUsed = paddingLeft + paddingRight
      //ViewGroup的上下padding值
      val tbPaddingUsed = paddingTop + paddingBottom
      
  1. 开始遍历,并且记录ChildView在调用ChildView自己的layout方法时需要的left和top值,代码如下
(0 until childCount).forEach { i ->
    val child = getChildAt(i)
    //GONE状态的View就不需要执行measureChild方法了,以提高效率,因为这种状态的View宽高是0(自定义View需要将GONE状态的自己的宽高设置为0)
    if (child.visibility != View.GONE) {
        //2.调用measureChildWithMargins计算子View宽高
        //因为重写了3个generateLayout方法所以这里调用measureChildWithMargins不会有异常
        measureChildWithMargins(child, widthMeasureSpec, lrPaddingUsed, heightMeasureSpec, tbPaddingUsed)
        //3.1.子View执行measure方法后该ViewGroup获取子View的getMeasuredWidth和getMeasuredHeight
        val layoutParams = child.layoutParams as MarginLayoutParams
        //记录该ChildView占用的空间
        val childWidth = layoutParams.leftMargin + child.measuredWidth + layoutParams.rightMargin
        val childHeight = layoutParams.topMargin + child.measuredHeight + layoutParams.bottomMargin
        //3.2.计算ViewGroup自己的宽高
        //第一个ChildView或者每行第一个ChildView的左边都是没有mColumnSpacing的
        //每行的最后一个ChildView也是没有mColumnSpacing的
        //第一行第一个ChildView不需要换行
        if (i == 0) {//第一行
            //第一个ChildView初始化childLeft和childTop
            //paddingLeft是ViewGroup的左边内间距
            childLeft = paddingLeft + layoutParams.leftMargin
            //paddingTop是ViewGroup的上边内间距,将第一行的顶部坐标设为paddingTop
            startY = paddingTop
            //lineWidth行宽在每行放置第一个ChildView时除了累加childWidth还需要累加ViewGroup的左右内间距
            lineWidth += lrPaddingUsed + childWidth
            //lineHeight设置为第一行第一个ChildView的高度
            lineHeight = childHeight
        } else if (lineWidth + mColumnSpacing + childWidth <= measureWidth) {
            //进入该代码块代表当前ChildView和上一个ChildView在同一行
            //所以只需要设置childLeft而不需要设置childTop
            //判断时需要mColumnSpacing是因为2个ChildView之间有列间距
            childLeft += mColumnSpacing + layoutParams.leftMargin
            lineWidth += mColumnSpacing + childWidth
            //lineHeight取最大高度
            lineHeight = Math.max(childHeight, lineHeight)
        } else {//需要换行,该ChildView放到了新行
            //换行时childLeft和第一行一样需要重新设置为ViewGroup的左边内间距加该ChildView的左外间距
            childLeft = paddingLeft + layoutParams.leftMargin
            //该行顶部坐标(相对于ViewGroup)需要累加上一行的行高和行间距
            startY += lineHeight + mRowSpacing
            //下面2个值的操作和第一行一样
            lineWidth = lrPaddingUsed + childWidth
            //lineHeight取新行第一个ChildView的高度
            lineHeight = childHeight
        }
        //添加该ChildView的left和top到集合,以便在该类的onLayout方法中调用ChildView的layout方法给该ChildView布局
        pointList.add(Pair(childLeft, startY + layoutParams.topMargin))
        //该ViewGroup的宽度取当前该ViewGroup的宽度和行宽的最大值
        width = Math.max(width, lineWidth)
        //childLeft设置为该ChildView所占空间的右边坐标
        //说明一下,每个ChildView所占空间包括了它的margin值,因为ChildView的外间距是不能显示任何控件的,外间距这部分空间是View之间的间距
        childLeft += child.measuredWidth + layoutParams.rightMargin
    }
}

上面的代码我把注释写的很详细,已经不需要解释什么了。。。

  1. 遍历完以后,就可以该ViewGroup的宽高也就可以确定了,接下来调用View.resolveSize方法来计算自己在不同父容器的MeasureSpec下的不同宽高,代码如下

    //4.调用resolveSize方法传入自己计算的宽高和上级ViewGroup的MeasureSpec,得到自身不同MeasureSpec下的宽高
    val resultWidth = resolveSize(width, widthMeasureSpec)
    val resultHeight = resolveSize(startY + lineHeight + paddingBottom, heightMeasureSpec)
    
  2. 然后调用setMeasuredDimension方法保存自己的宽高,代码如下

    //5.调用setMeasuredDimension保存自己的宽高
    setMeasuredDimension(resultWidth, resultHeight)
    
  3. 到这里,onMeasure方法就结束了,下面我们开始onLayout方法的重写

重写的onLayout方法如下

这个方法就很简单了,因为在onMeasure方法里已经设置好了ChildView们的位置,让我们来看一下代码吧

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    val childCount = this.childCount
    (0 until childCount).forEach { i ->
        val child = getChildAt(i)
        //GONE状态的View就不需要执行layout方法了,以提高效率,因为这种状态的View宽高是0(自定义View需要将GONE状态的自己的宽高设置为0)
        if (child.visibility != View.GONE) {
            val pair = pointList[i]
            child.layout(pair.first, pair.second, pair.first + child.measuredWidth, pair.second + child.measuredHeight)
        }
    }
}

然后该流式布局的主要部分就完成了

重写generateLayoutParams的3个方法

然后我们需要重写generateLayoutParams的3个方法,否则在调用measureChildWithMargins方法的时候是会报类转换异常的,至于为什么自己看一下源码就知道了,下面直接上重写好的代码

//ChildView的LayoutParams是包裹它的ViewGroup传递的,而默认传递的ViewGroup.LayoutParams是没有margin值的
//所以如果要使用margin需要重写这3个方法,ViewGroup会根据不同情况调用不同的方法的,所以最好把3个方法都重写了
override fun generateDefaultLayoutParams(): LayoutParams {
    return MarginLayoutParams(super.generateDefaultLayoutParams())
}

override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
    return MarginLayoutParams(context, attrs)
}

override fun generateLayoutParams(p: LayoutParams?): LayoutParams {
    return MarginLayoutParams(p)
}

最后还有个之前提到的mRowSpacingmColumnSpacing,这两个值在xml里设置该流式布局的行间距和列间距,我们在res/value下创建一个文件,例如叫做attrs_flowlayout.xml,然后添加如下代码

<resources>
    <declare-styleable name="FlowLayout">
        <attr name="rowSpacing" format="dimension"/>
        <attr name="columnSpacing" format="dimension"/>
    </declare-styleable>
</resources>

使用方法如下

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="horizontal"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    tools:context="liuhc.me.flowlayout.MainActivity">

    <liuhc.me.flowlayout.FlowLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="#ff0000"
        app:rowSpacing="10dp"
        app:columnSpacing="10dp"
        android:padding="10dp">
      ...
    </liuhc.me.flowlayout.FlowLayout>
</LinearLayout>

至此,一个流式布局就完成了,代码提交到了github,地址:
https://github.com/ikakaxi/FlowLayout

    原文作者:o动感超人o
    原文地址: https://www.jianshu.com/p/6a5490b00e9a
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞