开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第2天,点击查看活动详情
前言
最近在重新跟随扔物线的课程学习下自定义View 知识,本篇文章主要是介绍View 的布局过程.本文主要参考了扔物线课程中的一些知识,加上自己的一些理解. 另一篇文章讲述了View 事件分发机制重新学习 - View的触发反馈基本原理
布局过程的作用
自定义View 的主要有三个点 绘制 布局 触摸反馈.而布局则是 绘制 与触摸反馈的基础.
布局过程为 绘制和触摸反馈的范围做支持.确定View何处绘制,确定用户点击的位置.
- 作用: 为绘制和触摸范围做支持
- 绘制:确定何处绘制
- 触摸反馈:确定用户点击位置
测量和布局需要重写的方法
对于一个自定义View,需要重写onMeasure 去自定义尺寸.
对于一个自定义ViewGroup.需要重写onMeasure去自定义尺寸,并重写onLayout去管理布局内容.
布局的流程
View 的布局流程一般分为两层.先是测量流程(measure),再是布局流程(layout).
-
测量流程: 从根View 递归调用每一级子View的
measure()方法,对他们进行测量 -
布局流程: 从根View递归调用每一级子View 的
layout()方法,将测量过程中得出的子View的位置和尺寸传给子View,子View保存这些数据.
为什么View 的布局测量需要两个流程?
因为测量流程可能是多次的. 如果两个流程合一,则可能出现多次的 无用的layout流程.
测量流程是一个动态化的,反复的过程.当然,大多数情况下,只需要一次即可.
具体到View的布局过程
对于每个View:
-
运行前,开发者在xml文件或者 通过代码
layout*参数,设置对View的布局要求 layout_XXX -
父View 在自己的
onMeasure()中,根据开发者在xml中写的对子View的要求,和自己的可用空间,得出对子View的具体尺寸要求 -
子View 在自己的
onMeasure()中,根据父View的要求以及自己的特性计算出自己的期望尺寸.(在子View的期望大小和父View的指定大小发生冲突时,应该遵从父View.绝大多数情况下父View制定的尺寸和子View的期望是一致的.父View的要求指的是 根据View写的时候传入的layout_height等参数计算出的尺寸)- 如果是ViewGroup,还会在此处调用每个子View的
measure()进行测量
- 如果是ViewGroup,还会在此处调用每个子View的
-
父View 在子View 计算出期望尺寸后,得出子view的实际尺寸和位置
-
子View在自己的
layout()方法中,将父View 传进来的自己的实际尺寸和位置保存.- 如果是ViewGroup,会在
onLayout()方法中调用子View的layout()方法,传入子View的尺寸和位置.
- 如果是ViewGroup,会在
onMeasure 方法分析
View 的尺寸定义是由 父View和 子View 共同决定的.而不管是 子View的尺寸还是父View的尺寸处理主要操作都是在onMeasure 方法中进行的.
默认的实现如下:
class View {
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
}
View 定义自身的 width,height 的大小最终都是需要通过 setMeasuredDimension方法去设置的. 不过在onMeasure方法中调用setMeasuredDimension 中传入的宽高并不一定就是最终的宽高.onMeasure只是View自己声明了其想要获得的宽高,但是最终父View不一定给View想要的.我们可以看到onMeasure有两个参数
widthMeasureSpec,heightMeasureSpec两个参数,这两个参数中包含了 父View对于子View的宽高设置的要求.
一般而言,自定义一个View的时候需要去尽量满足父View对齐的要求,否则即使你通过setMeasuredDimension的,也不一定会生效,,在个别情况下,父View不一定认可子View要求,会重新强制校正.
正常的一个View要申请实际的尺寸,是结合自己期望尺寸和父View的要求来进行的.并且View 提供了对应的函数resolveSize让我们去处理.当然,如果你的View 有其他的尺寸策略(比如规定宽高至少要大于20px)可以按自身测量重写,.正常情况建议就用该种测量策略
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int wantWidthSize = {根据实际需要计算得出的值};//
int wantHeightSize = {根据实际需要计算得出的值};//
int widthSize = resolveSize(wantWidthSize,widthMeasureSpec);
int heightSize = resolveSize(wantWidthSize,widthMeasureSpec);
setMeasuredDimension(widthSize,heightSize);
}
如上主要是一个子View的一般策略,如果是一个ViewGroup.则还要考虑子View的大小.一般而言
private List<Rect> childBounds = new ArrayList();
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int wantWidthSize = {根据实际需要计算得出的值};//
int wantHeightSize = {根据实际需要计算得出的值};//
for (int i =0 ;i < getChildCount();i++) {
View child = getChildAt(i);
...//根据策略确定确定子View的位置
if(i >= childBounds.size()) {
childBounds.add(new Rect());
}
childBounds.get(i).set({根据测量确定的子View坐标});
// 计算 wantWidthSize,wantHeightSize
}
int widthSize = resolveSize(wantWidthSize,widthMeasureSpec);
int heightSize = resolveSize(wantWidthSize,widthMeasureSpec);
setMeasuredDimension(widthSize,heightSize);
}
那么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);
}
从如上代码我们可以看见
measureSpec这个int型参数中既存储了 测量的模式要求,也存储了View最多可以达到的大小.为什么其能存储两种信息?这主要涉及到了与或运算, 一个 int参数占用了32bit,measureSpece使用前两位用于保存测量模式,后面的30位用于保存size数据,30bit用于保存尺寸数据显然是绰绰有余了.
测量模式一种有三种:
-
MeasureSpec.EXACTLY: 子View大小已被指定.(一般是通过xml中 layout_width,layout_height 制)
-
MeasureSpec.AT_MOST: 子View 大小被制定了一个最大范围. 子View可以在不超过最大值的情况喜爱根据自己的特性定义自己的尺寸.
-
MeasureSpec.UNSPECIFIED: 子View大小不被限制,完全由子View根据自己的特性定义自己的尺寸.
MeasureSpec.UNSPECIFIED 一般是在可滚动的布局中出现,传给其子View.当布局可滚动时,子View的大小很多时候就不需要被限制了
onLayout 方法分析
以下是一个自定义的ViewGroup的部分代码,后面将会描述该布局
private List<Rect> childBounds = new ArrayList();
override fun onLayout(changed: Boolean, left: Int, top: Int, rright: Int, bottom: Int) {
for ((index,childView) in children.withIndex()) {
val childBound = childBounds[index]
childView.layout(childBound.left,childBound.top,childBound.right,childBound.bottom)
}
}
onLayout方法一般而言,相对onMeasure方法复杂度少很多.因为,子View的布局位置和父View本身的布局大小,一般只要在onMeasure就可以测量完毕.
onLayout中只需要调用子View的layout方法传入对应的布局数据即可.后面将会有个自定义的TagLayout例子介绍.
自定义布局的几种情形
自定义布局时主要有以下三种情形.
- 继承已有的View,重写
oneMeasure方法,修改尺寸 - 重写
onMeasure定制全新的View的尺寸 - 重写
onMeasure,onLayout来全新定义ViewGroup的内部布局.
情形1 继承已有 View
在有的时候,系统已有的View可能大部分情况都符合我们的一个需求,但是他的尺寸的定义可能不太符合,在这种情况下,我们可以重写onMeasure方法.接下来就是一个例子,实现一个SquareImageView ,该View用于保证其本身始终属于一个正方型的View.
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:padding="20dp">
<!--加上背景色显示View大小-->
<com.example.viewlearn.view.SquareImageView
android:layout_width="200dp"
android:layout_height="300dp"
android:background="@color/colorDarkCyan"
android:src="@drawable/ic3"
/>
</LinearLayout>
不重写onMeasure
class SquareImageView(context: Context, attrs: AttributeSet?) : androidx.appcompat.widget.AppCompatImageView(context, attrs) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
}
}
修改后
class SquareImageView(context: Context, attrs: AttributeSet?) : androidx.appcompat.widget.AppCompatImageView(context, attrs) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
//取最小值作为宽高
val size = min(measuredWidth,measuredHeight)
setMeasuredDimension(size,size)
}
}
显示效果如下:
情形2 全新定义View的尺寸
很多情况下,一个View是需要根据一些绘制内容来决定 size的大小.比如TextView,在 layout_width,layout_height 为 wrap_content时,其显示的大小一般都是与其 text的内容长度以及字体大小等共同决定的.大多数情况下,在onMeasure的参数widthMeasureSpec ,hegithMeasureSpec 值不等于MeasureSpec.EXACTLY 时,就需要View去计算出自己需要的尺寸.
对了View 本身的默认onMeasure方法,在 layout_*属性为wrap_content时,一般情况是占据下剩余所有空间.
class View {
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}
}
大多数的布局,在 layout_*属性为wrap_content时 ,传入的measureSpec是 MeasureSpec.AT_MOST.
以上是View的默认实现,接下来就让我们来看下一个自定义实现的CircleView 吧.
子View通过wantWidth,wantHeight描述了正常期望能获得的宽高
private val PADDING = 5.dp
class CircleView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val DEFAULT_RADIUS = 100.dp
private var radius = DEFAULT_RADIUS
init {
val typedArray = context.obtainStyledAttributes(attrs,R.styleable.CircleView)
typedArray.run {
radius = this.getDimension(R.styleable.CircleView_circleRadius,DEFAULT_RADIUS)
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val wantWidth = (radius + PADDING) * 2
val wantHeight = (radius + PADDING) * 2
val widthSize = resolveSize(wantWidth.toInt(),widthMeasureSpec)
val heightSize = resolveSize(wantHeight.toInt(),heightMeasureSpec)
setMeasuredDimension(widthSize,heightSize)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawCircle(width/2f,height/2f,radius,paint)
}
}
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
>
<com.example.viewlearn.view.CircleView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@color/colorYellow"
app:radius="100dp"
/>
</LinearLayout>
效果如下:
情形3 全新定义ViewGroup的内部布局
ViewGroup布局的尺寸自定义,相比于View的自定义复杂很多,其要计算子View的位置大小,还要计算得出本身的大小.
下面来展示一个标签布局.先看下,效果图如下
具体代码如下四个文件TagLayout.kt、·ColorTextView.kt、TagLayoutActivity.kt、activity_tag_layout.xml.
重点在于TagLayout的重写,此处重点描述下其整个逻辑。后面也会贴上其余三个文件的代码,供读者去粘贴复制验证效果。
private const val TAG = "TagLayout"
class TagLayout(context: Context, attrs: AttributeSet?) : ViewGroup(context, attrs) {
/**
* 记录 child 位置
*/
private val childBounds = mutableListOf<Rect>()
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
Log.i(TAG, "onMeasure: onMeasure")
//被指定的大小
var specWidthMode = MeasureSpec.getMode(widthMeasureSpec)
//被指定的大小
var specWidthSize = MeasureSpec.getSize(widthMeasureSpec)
var specHeightMode = MeasureSpec.getMode(heightMeasureSpec)
//被指定的大小
var specHeightSize = MeasureSpec.getSize(heightMeasureSpec)
var maxAvailableWidth = specWidthSize - paddingLeft - paddingRight
var maxWidth = 0
var needWidth = 0
var needHeight = paddingTop //起点
var lineWidthUsed = 0 // 该行已用
var lineMaxHeight = 0 //本行最大高
for ((index,childView) in children.withIndex()) {
val lp = childView.layoutParams as MarginLayoutParams
//代码1 此处widthUsed传入0,尽可能的满足子View 的宽度要求
measureChildWithMargins(childView,widthMeasureSpec,0,needHeight,heightMeasureSpec)
//代码2
// 不是 UNSPECIFIED. 说明是 EXACTLY 或 AT_MOST.如果是 EXACTLY,则宽度是固定的,如果View 宽度超过,当需要换行.
// 如果是 AT_MOST,说明值也是固定的
if( (specWidthMode != MeasureSpec.UNSPECIFIED)
&& (lineWidthUsed + lp.leftMargin + childView.measuredWidth) > maxAvailableWidth) {
needHeight += lineMaxHeight //下一层起点
lineWidthUsed = 0
lineMaxHeight = 0
//重新计算
measureChildWithMargins(childView,widthMeasureSpec,0,needHeight,heightMeasureSpec)
}
if (index >= childBounds.size) {
childBounds.add(Rect())
}
val childRect = childBounds[index]
// 孩子布局位置需要加入边界的偏移
childRect.set(paddingLeft + lineWidthUsed + lp.leftMargin,needHeight + lp.topMargin,paddingLeft + lineWidthUsed + lp.leftMargin + childView.measuredWidth,needHeight + lp.topMargin + childView.measuredHeight)
lineWidthUsed += childView.measuredWidth + lp.leftMargin + lp.rightMargin
maxWidth = max(lineWidthUsed,maxWidth)
lineMaxHeight = max(childView.measuredHeight + lp.topMargin + lp.bottomMargin,lineMaxHeight)
}
//计算需要的高度 宽度值
needHeight += lineMaxHeight + paddingBottom + paddingTop
needWidth = maxWidth + paddingLeft + paddingRight
var height = when(specHeightMode) {
MeasureSpec.EXACTLY -> specHeightSize
MeasureSpec.AT_MOST -> min(needHeight,specHeightSize)
else -> needHeight
}
var width = when(specWidthMode) {
MeasureSpec.EXACTLY -> specWidthSize
MeasureSpec.AT_MOST -> min(needWidth,specWidthSize)
else -> needWidth
}
setMeasuredDimension(width,height)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, rright: Int, bottom: Int) {
for ((index,childView) in children.withIndex()) {
val childBound = childBounds[index]
val lp = childView.layoutParams as MarginLayoutParams
childView.layout(childBound.left,childBound.top,childBound.right,childBound.bottom)
}
}
/**
** 代码3 重写 generateLayoutParams 方法,生成MarginlayoutParmas,
**/
override fun generateLayoutParams(attrs: AttributeSet?): LayoutParams {
return MarginLayoutParams(context,attrs)
}
}
如上代码,基本也写了很多的注释了。其总体一个逻辑就是 TagLayout 不会去主动限制子View的宽度,让其尽可能的去根据自己的特性来定义尺寸。
代码1: 使用measureChildWithMargins函数去测量子View,其实就是在该方法中调用了chilView.measure 方法。其实现如下,具体就是除去父View自身的Padding 和 子View margin数据后,告诉子View其可以申请的最大宽高。因为考虑计算了 Margin,因此需要使用 MarginLayoutParams类,而ViewGroup 默认为子View生成的是LayoutParams类,因此如果是从xml文件解析View时,需要重写generateLayoutParams 方法。
class ViewGroup {
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);
}
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
int specMode = MeasureSpec.getMode(spec);
int specSize = MeasureSpec.getSize(spec);
int size = Math.max(0, specSize - padding);
int resultSize = 0;
int resultMode = 0;
switch (specMode) {
// Parent has imposed an exact size on us
case MeasureSpec.EXACTLY:
if (childDimension >= 0) {
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size. So be it.
resultSize = size;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can't be
// bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
// Parent has imposed a maximum size on us
case MeasureSpec.AT_MOST:
if (childDimension >= 0) {
// Child wants a specific size... so be it
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size, but our size is not fixed.
// Constrain child to not be bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size. It can't be
// bigger than us.
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
// Parent asked to see how big we want to be
case MeasureSpec.UNSPECIFIED:
if (childDimension >= 0) {
// Child wants a specific size... let them have it
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
// Child wants to be our size... find out how big it should
// be
resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
resultMode = MeasureSpec.UNSPECIFIED;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
// Child wants to determine its own size.... find out how
// big it should be
resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
resultMode = MeasureSpec.UNSPECIFIED;
}
break;
}
//noinspection ResourceType
return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}
}
代码2:
当View的宽度在当前行不被满足时,就会去换一行,并重新测量子View的宽高,具体可从代码2 处看出。当然这种做法下,可能出现这样的情况,那就是子View期望的宽度甚至比当前TagLayout的最大宽度还大,这种情况下只能显示不全了。
注: 如上代码中使用
childBounds在onMeasure时期去View的位置与尺寸,然后在onLayout只是简单的遍历。这种做法只是为了简单化,不想再在onLayout中重写一堆与onMeasure类似的逻辑了。但是在实际开发中,有些情况这种做法并不好。因为在onMeasure时期,TagLayout的大小还并未确定。TagLayout的父View可能在onLayout的方法中传入的宽高并不是TagLayout期望的。
如上TagLayout讲解完毕 ,接下来就贴下其他三个文件代码吧。
ColorTextView.kt
private val CORNER_RADIUS = 4.dp
private val PADDING_X = 16.dp.toInt()
private val PADDING_Y = 8.dp.toInt()
class ColorTextView(context: Context, attrs: AttributeSet?,textSize:Float,backgroundColor:Int,text:CharSequence) : AppCompatTextView(context, attrs) {
private var backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG)
init {
setTextColor(Color.WHITE)
isSingleLine = true
setPadding(PADDING_X, PADDING_Y, PADDING_X, PADDING_Y)
setTextSize(textSize)
maxLines = 1
backgroundPaint.color = backgroundColor
setText(text)
}
override fun onDraw(canvas: Canvas) {
canvas.drawRoundRect(
0f,
0f,
width.toFloat(),
height.toFloat(),
CORNER_RADIUS,
CORNER_RADIUS,
backgroundPaint
)
super.onDraw(canvas)
}
}
activity_tag_layout.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:orientation="vertical"
android:padding="20dp">
<com.example.viewlearn.view.TagLayout
android:id="@+id/tagLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#03DAC5"
>
</com.example.viewlearn.view.TagLayout>
</LinearLayout>
TagLayoutActivity.kt
class TagLayoutActivity : AppCompatActivity() {
private val tagList = arrayListOf<String>(
"Android",
"Java",
"Kotlin",
"Rust",
"Python",
"C++",
"C",
"C#",
"Ruby",
"后端",
"前端",
"Html",
"Javascript",
"Go",
"Hadoop",
"大数据",
)
private val colors =
intArrayOf(Color.parseColor("#00ff00"),
Color.parseColor("#000099"),
Color.parseColor("#330033"),
Color.parseColor("#663300"),
Color.parseColor("#996600"))
private val textSizes = intArrayOf(14, 16, 18)
private lateinit var tagLayout: TagLayout
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_tag_layout)
tagLayout = findViewById<TagLayout>(R.id.tagLayout)
val random = Random()
for (tag in tagList) {
val textSize = textSizes[random.nextInt(textSizes.size)]
val color = colors[random.nextInt(colors.size)]
val text = tag
val colorTextView = ColorTextView(this, null, textSize.toFloat(), color, text)
val marginLayoutParams = ViewGroup.MarginLayoutParams(
ViewGroup.LayoutParams.WRAP_CONTENT,
ViewGroup.LayoutParams.WRAP_CONTENT
).apply {
leftMargin = 5.dp.toInt()
rightMargin = 5.dp.toInt()
topMargin = 5.dp.toInt()
bottomMargin = 5.dp.toInt()
}
tagLayout.addView(colorTextView, marginLayoutParams)
}
}
}