自定义ViewGroup 我们在开发过程中最多需求是自定义ViewGroup,本文就以自定ViewGroup实现流布局为例来讲解自定义ViewGroup。
自定义ViewGroup通常是自定义测量和布局规则,在《View测量和布局》 文章中已介绍了测量和布局原理,所以重写的onMeasure和onLayout就是自定义ViewGroup的核心方法。
案例分析 下面我们就通过ViewGroup来一步步实现流布局,流布局测量和布局是对内边距,换行,子View间距的综合计算结果,所以相对还是有点复杂的,针对相对复杂的自定义View我们可以采用由简到繁的方式,这样可以将复杂的问题简单化,不会感到无从下手,比如流布局我们可以先不考虑内边距,子View间距,换行,只考虑一行的情况是不是简单很多了呢,然后逐步完善。
代码实现 我们分三步来实现以简化难度,单行、多行、间距。
单行 首先自定义FLowLayout并重写onMeasure和onLayout
class FlowLayout (context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) { override fun onMeasure (widthMeasureSpec: Int , heightMeasureSpec: Int ) { } override fun onLayout (changed: Boolean , l: Int , t: Int , r: Int , b: Int ) { } }
首先需要测量,ViewGroup测量就是遍历测量子View根据测量尺寸来确定ViewGroup的测量尺寸,子View如何测量尺寸呢ViewGrop提供了MeasureChildWithMargins
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); }
widthUsed是已被使用宽度,heightUsed是已被使用的高度,这两个参数是为了计算可用空间并最终调用getChildMeasureSpec确定子View的MeasureSpec约束,getChildMeasureSpec已在《View测量和布局》 中做过解析此处不在赘述,不考虑内边距情况下widthUse和heightUsed为0,单行情况下取高度最高的子View的height作为FlowLayout的height,宽度就先已子View宽度之和作为ViewGroup的测量宽度。
override fun onMeasure (widthMeasureSpec: Int , heightMeasureSpec: Int ) { var finalHeight = 0 var finalWidth = 0 var lineMaxHeight = 0 for (child in children){ measureChildWithMargins(child,widthMeasureSpec,0 ,heightMeasureSpec,0 ) lineMaxHeight = max(lineMaxHeight, child.measuredHeight) finalWidth+=child.measuredWidth } setMeasuredDimension(finalWidth,finalHeight) }
然后需要布局,ViewGroup的布局就是遍历子View然后根据测量尺寸和坐标进行水平layout,暂时只考虑单行情况所以所有子View的to都是0,代码也很好理解
override fun onLayout (changed: Boolean , l: Int , t: Int , r: Int , b: Int ) { var usedWidth = 0 for (child in children){ child.layout(usedWidth,0 ,usedWidth+child.measuredWidth,child.measuredHeight) usedWidth+=child.measuredWidth } }
创建布局文件并添加子View
<androidx.constraintlayout.widget.ConstraintLayout android:layout_width ="match_parent" android:layout_height ="match_parent" > <com.hezd.taglayout.FlowLayout android:id ="@+id/tag_layout" android:layout_width ="wrap_content" android:layout_height ="wrap_content" app:layout_constraintLeft_toLeftOf ="parent" app:layout_constraintTop_toTopOf ="parent" > <TextView android:layout_width ="wrap_content" android:layout_height ="wrap_content" android:background ="@drawable/tag_bg" android:gravity ="center" android:paddingLeft ="15dp" android:paddingTop ="5dp" android:paddingRight ="15dp" android:paddingBottom ="5dp" android:text ="aa" android:textAllCaps ="true" /> </com.hezd.taglayout.FlowLayout > </androidx.constraintlayout.widget.ConstraintLayout >
单行情况已经处理完了,运行看看效果吧。咦,这是什么鬼
java.lang.ClassCastException: android.view.ViewGroup$LayoutParams cannot be cast to android.view.ViewGroup$MarginLayoutParams at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:6899) at com.hezd.taglayout.FlowLayout.onMeasure(FlowLayout.kt:23) at android.view.View.measure(View.java:24851) at androidx.constraintlayout.widget.ConstraintLayout$Measurer.measure(ConstraintLayout.java:811)
程序崩溃了类型转化异常,从堆栈信息可以看到异常发生在ViewGroup的measureChildWithMargins
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的LayoutParams强转为MarginLayoutParms发生类型转化异常,不能将ViewGroup$LayoutParams转化为ViewGroup$MarginLayoutParams,那我们刚才布局添加的子View的LayoutParams哪里来的呢为什么是ViewGroup$LayoutParams呢,怎么才能转化为MarginLayoutParms,其实布局中子View的布局信息是在LayoutInflater解析布局文件时指定的
private void parseInclude (XmlPullParser parser, Context context, View parent, AttributeSet attrs) throws XmlPullParserException, IOException { final View view = createViewFromTag(parent, childName, context, childAttrs, hasThemeOverride); ViewGroup.LayoutParams params = null ; final ViewGroup group = (ViewGroup) parent; try { params = group.generateLayoutParams(attrs); } catch (RuntimeException e) { } if (params == null ) { params = group.generateLayoutParams(childAttrs); } view.setLayoutParams(params); }
可以看到子View通过调用父View的generateLayoutPrams获取LayoutParams并setLayoutParams
public LayoutParams generateLayoutParams (AttributeSet attrs) { return new LayoutParams (getContext(), attrs); }
返回的就是ViewGroup$LayoutParams,既然xml中声明的子View是通过调用父View的generateLayoutParams来获取LayoutParams我们可以重写generateLayoutParams方法
class FlowLayout (context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) { override fun generateLayoutParams (attrs: AttributeSet ?) : LayoutParams { return MarginLayoutParams(context,attrs) } }
再次运行查看效果
需要注意的是这是在xml中添加子View的情况,如果是我们通过代码添加的情况呢,来看ViewGroup的addView
public void addView (View child) { addView(child, -1 ); } public void addView (View child, int index) { if (child == null ) { throw new IllegalArgumentException ("Cannot add a null child view to a ViewGroup" ); } LayoutParams params = child.getLayoutParams(); if (params == null ) { params = generateDefaultLayoutParams(); if (params == null ) { throw new IllegalArgumentException ( "generateDefaultLayoutParams() cannot return null " ); } } addView(child, index, params); } protected LayoutParams generateDefaultLayoutParams () { return new LayoutParams (LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT); }
从代码可以看到如果addView时如果没有设置LayoutParams是通过调用generateDefaultLayoutParams来获取LayoutParams因此需要对这种情况做容错处理
class FlowLayout (context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) { override fun generateLayoutParams (attrs: AttributeSet ?) : LayoutParams { return MarginLayoutParams(context,attrs) } override fun generateDefaultLayoutParams () : LayoutParams { return MarginLayoutParams(MarginLayoutParams.WRAP_CONTENT,MarginLayoutParams.WRAP_CONTENT) } }
多行 首先来看onMeasure实现多行,onMeasure最终目的是测量ViewGroup的宽高,什么时候需要换行呢,我们可以定义一个usedWidth表示当前行已占用的宽度,当usedWidth+child.measuredWidth>widthMeasureSpecSize时,并重置usedWidth为child.measuredWidth,如果有换行finalWidth的值应该为widthMeasureSpecSize。在来看高度计算,高度应该是每一行高度的总和。所以onMeasure的实现代码如下:
override fun onMeasure (widthMeasureSpec: Int , heightMeasureSpec: Int ) { var finalHeight = 0 var finalWidth = 0 val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec) var usedWidth = 0 var maxLineHeight = 0 var isNewLine: Boolean for (child in children) { measureChildWithMargins(child, widthMeasureSpec, 0 , heightMeasureSpec, 0 ) isNewLine = usedWidth+child.measuredWidth>widthSpecSize if (isNewLine){ finalWidth = widthSpecSize maxLineHeight = max(maxLineHeight,child.measuredHeight) finalHeight+=maxLineHeight usedWidth = 0 maxLineHeight = 0 } maxLineHeight = max(maxLineHeight,child.measuredHeight) usedWidth+=child.measuredWidth finalWidth = max(finalWidth,usedWidth) } finalHeight += maxLineHeight setMeasuredDimension(finalWidth, finalHeight) }
在来看onLayout,就是child的布局思路跟onMeasure类似,就是根据usedWith、child.measureWidth、是否需要换行来确定child的坐标,代码如下
override fun onLayout (changed: Boolean , l: Int , t: Int , r: Int , b: Int ) { var usedWidth = 0 var usedHeight = 0 var maxLineHeight = 0 var isNeedNewLine: Boolean for (child in children) { isNeedNewLine = usedWidth + child.measuredWidth > measuredWidth if (isNeedNewLine) { maxLineHeight = max(maxLineHeight, child.measuredHeight) usedHeight += maxLineHeight usedWidth = 0 maxLineHeight = 0 } child.layout( usedWidth, usedHeight, usedWidth + child.measuredWidth, usedHeight + child.measuredHeight ) maxLineHeight = max(maxLineHeight, child.measuredHeight) usedWidth += child.measuredWidth } }
来看下运行效果
间距 添加间距相对比较简单就是在测量和布局时将间距考虑进去,首先需要自定义属性行间距、条目间距
自定义属性添加在res/attrs.xml文件中
<?xml version="1.0" encoding="utf-8" ?> <resources > <declare-styleable name ="FlowLayout" > <attr name ="lineSpacing" format ="dimension" /> <attr name ="itemSpacing" format ="dimension" /> </declare-styleable > </resources >
布局文件中设置属性值
<com.hezd.flowlayout.FlowLayout android:id ="@+id/tag_layout" app:itemSpacing ="10dp" app:lineSpacing ="10dp" android:paddingLeft ="10dp" android:paddingRight ="10dp" android:layout_width ="wrap_content" android:layout_height ="wrap_content" app:layout_constraintLeft_toLeftOf ="parent" app:layout_constraintTop_toTopOf ="parent" > </com.hezd.flowlayout.FlowLayout >
FLowLayout最终代码
class FlowLayout (context: Context, attrs: AttributeSet) : ViewGroup(context, attrs) { var lineSpacing: Int var itemSpacing: Int init { val typedArray = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout) lineSpacing = typedArray.getDimensionPixelOffset(R.styleable.FlowLayout_lineSpacing, 0 ) itemSpacing = typedArray.getDimensionPixelOffset(R.styleable.FlowLayout_itemSpacing, 0 ) typedArray.recycle() } override fun onMeasure (widthMeasureSpec: Int , heightMeasureSpec: Int ) { var finalHeight = 0 var finalWidth = 0 val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec) var usedWidth = 0 var maxLineHeight = 0 var isNewLine: Boolean for (child in children) { measureChildWithMargins( child, widthMeasureSpec, getDefaultUsedWidth(), heightMeasureSpec, paddingTop + paddingBottom ) isNewLine = usedWidth + child.measuredWidth > widthSpecSize if (isNewLine) { finalWidth = widthSpecSize maxLineHeight = max(maxLineHeight, child.measuredHeight) finalHeight += maxLineHeight + lineSpacing usedWidth = getDefaultUsedWidth() maxLineHeight = 0 } maxLineHeight = max(maxLineHeight, child.measuredHeight) usedWidth += child.measuredWidth + itemSpacing finalWidth = max(finalWidth, usedWidth) } finalHeight += maxLineHeight setMeasuredDimension(finalWidth, finalHeight) } private fun getDefaultUsedWidth () = paddingLeft + paddingRight override fun onLayout (changed: Boolean , l: Int , t: Int , r: Int , b: Int ) { var usedWidth = getDefaultUsedWidth() var usedHeight = 0 var maxLineHeight = 0 var isNeedNewLine: Boolean for (child in children) { isNeedNewLine = usedWidth + child.measuredWidth > measuredWidth if (isNeedNewLine) { maxLineHeight = max(maxLineHeight, child.measuredHeight) usedHeight += maxLineHeight + lineSpacing usedWidth = getDefaultUsedWidth() maxLineHeight = 0 } child.layout( usedWidth, usedHeight, usedWidth + child.measuredWidth, usedHeight + child.measuredHeight ) maxLineHeight = max(maxLineHeight, child.measuredHeight) usedWidth += child.measuredWidth + itemSpacing } } override fun generateLayoutParams (attrs: AttributeSet ?) : LayoutParams { return MarginLayoutParams(context, attrs) } override fun generateDefaultLayoutParams () : LayoutParams { return MarginLayoutParams(MarginLayoutParams.WRAP_CONTENT, MarginLayoutParams.WRAP_CONTENT) } }
运行查看效果
尺寸修正 目前看来已经没有问题了是不是,别急我们把布局中FlowLayout高度改为50dp,会发现任然可以正常显示,这是什么鬼?因为我们测量完以后还需要考虑它的布局参数,进行尺寸修正,如何修正呢,View提供了resolveSize方法
public static int resolveSize (int size, int measureSpec) { return resolveSizeAndState(size, measureSpec, 0 ) & MEASURED_SIZE_MASK; } public static int resolveSizeAndState (int size, int measureSpec, int childMeasuredState) { final int specMode = MeasureSpec.getMode(measureSpec); final int specSize = MeasureSpec.getSize(measureSpec); final int result; switch (specMode) { case MeasureSpec.AT_MOST: if (specSize < size) { result = specSize | MEASURED_STATE_TOO_SMALL; } else { result = size; } break ; case MeasureSpec.EXACTLY: result = specSize; break ; case MeasureSpec.UNSPECIFIED: default : result = size; } return result | (childMeasuredState & MEASURED_STATE_MASK); }
可以看到当我们布局中指定了高度时就会走EXACTLY这个case测量高度就是我们指定的值,所以最终测量高度不应该仅仅以child的测量高度来计算,尺寸修正后的onMeasure
override fun onMeasure (widthMeasureSpec: Int, heightMeasureSpec: Int) { var finalHeight = 0 var finalWidth = 0 val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec) var usedWidth = 0 var maxLineHeight = 0 var isNewLine: Boolean for (child in children) { measureChildWithMargins( child, widthMeasureSpec, getDefaultUsedWidth(), heightMeasureSpec, paddingTop + paddingBottom ) isNewLine = usedWidth + child.measuredWidth > widthSpecSize if (isNewLine) { finalWidth = widthSpecSize maxLineHeight = max(maxLineHeight, child.measuredHeight) finalHeight += maxLineHeight + lineSpacing usedWidth = getDefaultUsedWidth() maxLineHeight = 0 } maxLineHeight = max(maxLineHeight, child.measuredHeight) usedWidth += child.measuredWidth + itemSpacing finalWidth = max(finalWidth, usedWidth) } finalHeight += maxLineHeight setMeasuredDimension( resolveSize(finalWidth, widthMeasureSpec), resolveSize(finalHeight, heightMeasureSpec) ) }
再次运行可以看到FlowLayout只显示大概一行的内容跟我们预期一致
代码Github仓库地址:FlowLayout