0%

自定义ViewGroup

自定义ViewGroup

我们在开发过程中最多需求是自定义ViewGroup,本文就以自定ViewGroup实现流布局为例来讲解自定义ViewGroup。

自定义ViewGroup通常是自定义测量和布局规则,在《View测量和布局》文章中已介绍了测量和布局原理,所以重写的onMeasure和onLayout就是自定义ViewGroup的核心方法。

案例分析

下面我们就通过ViewGroup来一步步实现流布局,流布局测量和布局是对内边距,换行,子View间距的综合计算结果,所以相对还是有点复杂的,针对相对复杂的自定义View我们可以采用由简到繁的方式,这样可以将复杂的问题简单化,不会感到无从下手,比如流布局我们可以先不考虑内边距,子View间距,换行,只考虑一行的情况是不是简单很多了呢,然后逐步完善。

代码实现

我们分三步来实现以简化难度,单行、多行、间距。

单行

首先自定义FLowLayout并重写onMeasure和onLayout

/**
*
*@author hezd
*Create on 2021/12/24 17:08
*/
class FlowLayout(context: Context?, attrs: AttributeSet?) : ViewGroup(context, attrs) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 因为我们要自定义测量规则所以可以将super方法注释掉
// super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
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) {
// Flowlayout测量尺寸
var finalHeight = 0
var finalWidth = 0
// 单行中高度最高的childHeight
var lineMaxHeight = 0

for(child in children){
// child测量以可用空间为基准,所以widthUsed和heightUsed都传0
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) {
// 已使用宽度
// 因为是水平layout所以,每个子view的left应该从已使用宽度开始计算
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" />
<!--为了节省空间忽略雷同Textview-->
<!--....-->
</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) {
// 子ViewLayoutParams强转为MarginLayoutParms
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 {
// 忽略部分代码……

// 创建子View
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) {
// Ignore, just fail over to child attrs.
}

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) {
// Flowlayout测量尺寸
var finalHeight = 0
var finalWidth = 0

val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec)
// 已使用行宽
var usedWidth = 0
// 单行中高度最高的尺寸大小
var maxLineHeight = 0
// 是否需要换行
var isNewLine: Boolean
for (child in children) {
// child测量
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中
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) {
// 已使用宽度
// 因为是水平layout所以,每个子view的left应该从已使用宽度开始计算
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)
// 布局top坐标更新
usedHeight += maxLineHeight
usedWidth = 0
maxLineHeight = 0
}

// 开始布局
child.layout(
usedWidth,
usedHeight,
usedWidth + child.measuredWidth,
usedHeight + child.measuredHeight
)

// 更新单行最大高度、已占用行宽度
maxLineHeight = max(maxLineHeight, child.measuredHeight)
usedWidth += child.measuredWidth

}
}

来看下运行效果

image-20211224202434667
间距

添加间距相对比较简单就是在测量和布局时将间距考虑进去,首先需要自定义属性行间距、条目间距

自定义属性添加在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最终代码

/**
*
*@author hezd
*Create on 2021/12/24 17:08
*/
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) {

// Flowlayout测量尺寸
var finalHeight = 0
var finalWidth = 0

val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec)
// 已使用行宽
var usedWidth = 0
// 单行中高度最高的尺寸大小
var maxLineHeight = 0
// 是否需要换行
var isNewLine: Boolean
for (child in children) {
// child测量以可用空间为基准
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中
finalHeight += maxLineHeight

// 保存测量高度
setMeasuredDimension(finalWidth, finalHeight)
}

private fun getDefaultUsedWidth() = paddingLeft + paddingRight

override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
// 已使用宽度
// 因为是水平layout所以,每个子view的left应该从已使用宽度开始计算
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)
// 布局top坐标更新
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) {
// Flowlayout测量尺寸
var finalHeight = 0
var finalWidth = 0

val widthSpecSize = MeasureSpec.getSize(widthMeasureSpec)
// 已使用行宽
var usedWidth = 0
// 单行中高度最高的尺寸大小
var maxLineHeight = 0
// 是否需要换行
var isNewLine: Boolean
for (child in children) {
// child测量以可用空间为基准
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中
finalHeight += maxLineHeight

// 保存测量高度
setMeasuredDimension(
resolveSize(finalWidth, widthMeasureSpec),
resolveSize(finalHeight, heightMeasureSpec)
)
}

再次运行可以看到FlowLayout只显示大概一行的内容跟我们预期一致

代码Github仓库地址:FlowLayout