简介

       Android自定义View,ViewGroup是在开发中使用的比较多的,学习和掌握自定View,是中高级开发必须掌握的技能,本文使用一个自定义FlowLayout,来介绍自定义ViewGroup的流程与需要注意的地方

实践

  1. 我们先来看看最后的实现效果
  2. Android FlowLayoutManager 流式布局_android

  3. 接下来我们来实现这个效果,首先我们新建一个类,继承至ViewGroup,并且重写其onMeasure和onLayout两个方法,ViewGroup有4个构造方法,我们可以根据需要,重写其构造方法
class FlowLayout : ViewGroup {

    constructor(context: Context) : super(context)
    constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet)
    constructor(context: Context, attributeSet: AttributeSet, defStyleAttr: Int) 
    : super(context, attributeSet, defStyleAttr)
    /**
     * 测量控件大小
     */
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {

    }
    /**
     * 布局
     */
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        
    }
}

onMeasure:测量控件大小的,我们定义的是ViewGroup,所以里面子view的大小都需要在这测量,测量后我们就可以拿到 控件的大小;

注意:onMeasure是有可能执行多次的,像ViewPager就调用了两次,详细可以看ViewPager的onMeasure方法源码,onMeasure调用几次,取决于它的父ViewGroup,它的父ViewGroup调用了多次measure,onMeasure就会执行多次

onLayout:布局,这个地方可以对控件进行布局,决定子view的排列方式,显示的位置;

  1. 测量子View的大小
    怎么测量子view的大小呢?其实ViewGroup已经给我提供了3个方法来测量子view的大小
//循环遍历所有的子view,调用measureChild来测量子view大小
protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) {
        final int size = mChildrenCount;
        final View[] children = mChildren;
        for (int i = 0; i < size; ++i) {
            final View child = children[i];
            if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
                measureChild(child, widthMeasureSpec, heightMeasureSpec);
            }
        }
}
//测量单个view的大小,会考虑ViewGroup的padding
protected void measureChild(View child, int parentWidthMeasureSpec,
            int parentHeightMeasureSpec) {
        final LayoutParams lp = child.getLayoutParams();

        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                mPaddingLeft + mPaddingRight, lp.width);
        final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
                mPaddingTop + mPaddingBottom, lp.height);

        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
//测量单个view的大小,会考虑ViewGroup的padding  和子view的margin
protected void measureChildWithMargins(View child,
            int parentWidthMeasureSpec, int widthUsed,
            int parentHeightMeasureSpec, int heightUsed) {
        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

        final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
                mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
                        + widthUsed, lp.width);
        final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
                mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
                        + heightUsed, lp.height);

        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

因为我们需要考虑到子view的margin,所以我们可以使用第三个方法来测量子view的大小,当然我们也可以参考它的写法,自己来写
首先我们要获取到自定义ViewGroup的padding,然后在循环遍历所有子view,测量每一个view的大小

/**
 * 测量
 */
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    //获取控件设置的内边距
    val paddingLeft = paddingLeft
    val paddingRight = paddingRight
    val paddingTop = paddingTop
    val paddingBottom = paddingBottom

    //先测量子view的大小
    for (index in 0 until childCount) {
        val childView = getChildAt(index)
        //获取子view的LayoutParams --> MarginLayoutParams
        val childLP = childView.layoutParams as MarginLayoutParams
        //将LayoutParam转变成为MeasureSpec
        val childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
            paddingLeft + paddingRight + childLP.leftMargin + childLP.rightMargin, childLP.width)
        val childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec,
            paddingTop + paddingBottom + childLP.topMargin + childLP.bottomMargin, childLP.height )
        //测量子view的大小
        childView.measure(childWidthMeasureSpec, childHeightMeasureSpec)
    }
}

我们可以看到, 我们先获取到了子view的LayoutParams,然后使用getChildMeasureSpec()这个方法获取到了子view的MeasureSpec,然后调用子view的measure方法来度量子view的宽高

那么getChildMeasureSpec方法,measure方法分别干了什么事情呢?MeasureSpec又是什么呢?详情可以看我另一篇文章
自定义ViewGroup之measureSpec

这个地方有一个需要注意的地方,childView.layoutParams拿到的LayoutParams是无法直接强转MarginLayoutParams的,而只有MarginLayoutParams才能获取到margin,所以 需要我们重写ViewGroup的这几个方法

/**
     * 重写generateLayoutParams方法,返回自定义的LayoutParams
     * 使其可以获取Margin值
     */
    override fun generateLayoutParams(p: LayoutParams?): LayoutParams? {
        return MarginLayoutParams(p)
    }

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

    override fun generateDefaultLayoutParams(): LayoutParams? {
        return MarginLayoutParams(
            LayoutParams.MATCH_PARENT,
            LayoutParams.MATCH_PARENT
        )
    }
  1. 测量完子view后,我们就可以获取到子view的大小了,获取到子view大小后,我们还需计算出我们自定义的FlowLayout的实际大小,并且调用setMeasuredDimension()方法保存起来
    在计算我们的FlowLayout的实际大小时,因为我们的子view显示不下的时候是需要换行的,所以我们高度就是所有行的高度之和,宽度就是最宽的那一行的宽度
    那我们怎么知道什么时候应该换行呢?就是当前行大于我们的FlowLayout的宽度的时候,就应该换行,但是我们现在又在计算FlowLayout的大小,那我们怎么获取呢?
    在onMeasure方法中有两个参数widthMeasureSpec和heightMeasureSpec,这个就是父ViewGroup给我们measureSpec,然后使用MeasureSpec.getSize()这个方法获取到父控件可以提供给你最大的布局空间,详情可以参考自定义ViewGroup之MeasureSpec,现在所有的问题都解决了,我们来看看代码:
class FlowLayout : ViewGroup {
    //每一个Item横向间距
    private var mHorizontalSpacing = dp2px(0f)

    //每一行Item纵向间距
    private var mVerticalSpeaing = dp2px(0f)

    /**
     * 设置每个item的横向边距
     */
    fun setHorizontalSpacing(hSpacing: Float) {
        mHorizontalSpacing = dp2px(hSpacing)
    }

    /**
     * 设置每个item的纵向边距
     */
    fun setVerticalSpacing(vSpacing: Float) {
        mVerticalSpeaing = dp2px(vSpacing)
    }

    //保存每一行的view的数据
    val allLineViews: MutableList<MutableList<View>> = mutableListOf()

    //保存每一行的高度
    val allLineHeights: MutableList<Int> = mutableListOf()


    constructor(context: Context) : super(context)

    constructor(context: Context, attributeSet: AttributeSet) : super(context, attributeSet)

    constructor(context: Context, attributeSet: AttributeSet, defStyleAttr: Int) : super(context, attributeSet, defStyleAttr)

    init {
        //初始化代码

    }

    /**
     * 测量
     */
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        //清空上次保存的数据
        allLineHeights.clear()
        allLineViews.clear()
        //获取控件设置的内边距
        val paddingLeft = paddingLeft
        val paddingRight = paddingRight
        val paddingTop = paddingTop
        val paddingBottom = paddingBottom
        //保存一行中所有的view
        var lineViews: MutableList<View> = mutableListOf()
        var lineWidthUsed = 0 //记录这行已经使用了多宽的size
        var lineHeight = 0 //一行的行高
        //解析的父ViewGroup给我的参考宽度
        val selfWidth = MeasureSpec.getSize(widthMeasureSpec)
        //解析的父ViewGroup给我的参考高度
        val selfHeight = MeasureSpec.getSize(heightMeasureSpec)
        //所有子view中,最宽的值
        var parentNeededWidth = 0
        //所有子view的高度
        var parentNeededHeight = 0

        //记录一行,最大的topMargin和最大的bottomMargin
        var maxTopMargin = 0
        var maxBottomMargin = 0

        //先测量子view的大小
        for (index in 0 until childCount) {
            val childView = getChildAt(index)
            //获取子view的LayoutParams --> MarginLayoutParams
            val childLP = childView.layoutParams as MarginLayoutParams
            //将LayoutParam转变成为MeasureSpec
            val childWidthMeasureSpec = getChildMeasureSpec(
                widthMeasureSpec,
                paddingLeft + paddingRight + childLP.leftMargin + childLP.rightMargin, childLP.width
            )
            val childHeightMeasureSpec = getChildMeasureSpec(
                heightMeasureSpec,
                paddingTop + paddingBottom + childLP.topMargin + childLP.bottomMargin,
                childLP.height
            )
            //测量子view的大小
            childView.measure(childWidthMeasureSpec, childHeightMeasureSpec)

            //获取子view测量后的宽高
            val childMeasuredWidth = childView.measuredWidth
            val childMeasuredHeight = childView.measuredHeight
            //这一行的宽度 还需要加上当前view的margin
            var lineWidth =
                lineWidthUsed + childMeasuredWidth + mHorizontalSpacing + childLP.leftMargin + childLP.rightMargin
            //当这一行的宽度大于父布局的宽度时,需要换行
            if (lineWidth > selfWidth) {
                //保存上一行的数据
                allLineViews.add(lineViews)
                allLineHeights.add(lineHeight)

                //修改ViewGroup宽高 高度需要加上这一行最大的topMargin和最大的bottomMargin
                parentNeededHeight += lineHeight + mVerticalSpeaing + maxTopMargin + maxBottomMargin
                parentNeededWidth = Math.max(parentNeededWidth, lineWidth)

                //初始化下一行的数据
                lineWidth = childMeasuredWidth + mHorizontalSpacing + childLP.leftMargin + childLP.rightMargin
                lineViews = mutableListOf()
                lineHeight = 0
                maxTopMargin = 0
                maxBottomMargin = 0

            }
            //view是分行的layout,所以药记录每一行有哪些view
            lineViews.add(childView)
            //记录这一行,最大的topMargin和最大的bottomMargin
            maxTopMargin = Math.max(maxTopMargin, childLP.topMargin)
            maxBottomMargin = Math.max(maxBottomMargin, childLP.bottomMargin)
            //更新每一行的宽和高
            lineWidthUsed = lineWidth
            lineHeight = Math.max(lineHeight, childMeasuredHeight)

            //处理最后一行的数据
            if (index == childCount - 1) {
                allLineViews.add(lineViews)
                allLineHeights.add(lineHeight)
                //修改宽高
                parentNeededHeight += lineHeight + mVerticalSpeaing + maxTopMargin + maxBottomMargin
                parentNeededWidth = Math.max(parentNeededWidth, lineWidth)
            }

        }

        //根据子view度量的结果,来重新度量自己ViewGroup
        //作为一个ViewGroup,它自己也是一个View,它的大小也要根据它的父View给他提供的宽高来度量
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val heigthMode = MeasureSpec.getMode(heightMeasureSpec)
        //如果是实际大小,则直接使用设置的具体值
        val realWidth = if (widthMode == MeasureSpec.EXACTLY) selfWidth else parentNeededWidth
        val realHeight = if (heigthMode == MeasureSpec.EXACTLY) selfHeight else parentNeededHeight
        //保存测量的大小
        setMeasuredDimension(realWidth, realHeight)
    }
}

这里需要特别注意的是,我们需要根据父ViewGroup给我们的widthMeasureSpec,调用MeasureSpec.getMode()方法,获取到MeasureSpec,如果MeasureSpec等于MeasureSpec.EXACTLY(确定的大小),则使用MeasureSpec.getSize()方法获取到的大小,如果是其它的,则使用我们计算出来的大小,最后调用setMeasuredDimension(realWidth, realHeight)保存我们ViewGroup的实际大小

  1. 测量完成后,我们就需要在onLayout里面确定子view的位置,在开始前,我们需要了解Android中的两种坐标系

    一种是以Android屏幕左上角为原点,一种是根据父ViewGroup来确定位置,我们这里必须使用第二种,下面我们来看看代码
/**
     * 布局
     */
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {

        var curL = paddingLeft
        var curT = paddingTop

        for ((index, itemViewList) in allLineViews.withIndex()) {
            //这一行的高度
            val lineHeight = allLineHeights[index]
            //记录这一行,最大的topMargin和最大的bottomMargin
            var maxTopMargin = 0
            var maxBottomMargin = 0
            for (view in itemViewList) {
                //获取子view的LayoutParams --> MarginLayoutParams
                val childLP = view.layoutParams as MarginLayoutParams
                //需要加上margin
                val left = curL + childLP.leftMargin
                val top = curT + childLP.topMargin
                val right = left + view.measuredWidth
                val bottom = top + view.measuredHeight
                view.layout(left, top, right, bottom)
                //下一个view的左边距离需要加上当前view的rightMargin
                curL = right + mVerticalSpeaing + childLP.rightMargin
                //记录这一行,最大的topMargin和最大的bottomMargin
                maxTopMargin = Math.max(maxTopMargin, childLP.topMargin)
                maxBottomMargin = Math.max(maxBottomMargin, childLP.bottomMargin)
            }
            //下一行距离父布局top的距离需要加上 上一行最大的topMargin和最大的bottomMargin
            curT += lineHeight + mVerticalSpeaing + maxTopMargin + maxBottomMargin
            curL = paddingLeft
        }
    }

好了,到这我们的自定义FlowLayout 就大功告成了,我们可以在布局中使用FlowLayout,在里面随便加一几个view,就实现文章开头所展示的效果啦