关于View的工作原理、绘制流程等,在第4章 View的工作原理这篇文章已经写了。本文详细说一下自定义ViewGroup。
ViewGroup 继承自View,所以 ViewGroup 是一种包含子View的特殊View。
自定义 ViewGroup 有两个主要步骤,重写 onMeasure
与 onLayout
。
要注意到的是,ViewGroup 默认是不走 onDraw
回调的。如果想要 ViewGroup 走 onDraw 回调,需要在 ViewGroup 的构造方法中调用setWillNotDraw(false)
。
一、重写onMeasure
onMeasure
的目的是测量该 ViewGroup 和其所有子 View 的宽和高。
虽然通常情况下,ViewGroup 都会重写 onMeasure 方法,但这并不是必须的。如果 ViewGroup 不重写 onMeasure 的话,默认使用 View 的 onMeasure 方法,其表现为除非设置其宽(高)为固定的大小,否则其宽(高)与父容器相同。
onMeasure 方法有两个参数,widthMeasureSpec
和heightMeasureSpec
。关于 MeasureSpec ,在文章《【Android】MeasureSpec简述》中有详细说明,这里就不赘述了。
一般来讲,ViewGroup 需要先遍历测量所有的子 View,然后再根据子 View 的测量结果来计算自身的尺寸。
一种方式是调用measureChildren
方法,可以一次性测量所有的子 View。然后,遍历所有子 View,根据其measuredWidth
和mesuredHeight
计算 ViewGroup 的尺寸。
另一种方式是,遍历所有子 View,使用measureChild
或measureChildWithMargin
(二者的区别是,这个 ViewGroup 的 LayoutParams 是否允许 margin,这点将在第三节中说明)来测量子 View,并在这个过程中计算 ViewGroup 的大小。
在计算完毕后,调用setMeasuredDimension(w, h)
来设置最终的测量结果。这跟自定义 View 是相同的。
上面说的正常情况下,先测量子 View 再测量 ViewGroup ,那么非正常情况呢?比如不测量子 View 或者瞎测量,会有什么后果?这个放在第四章。
例
目标是实现一个简易版的FrameLayout
。以下代码通过重写onMeasure
,实现了简易版FrameLayout
的测量:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
// 测量所有子View
measureChildren(widthMeasureSpec, heightMeasureSpec)
// 如果是宽高都是固定值,那么就直接返回
if (widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
setMeasuredDimension(widthSize, heightSize)
return
}
var maxChildWidth = 0 // 子View的最大宽度
var maxChildHeight = 0 // 子View的最大高度
for (i in 0 until childCount) {
val child = getChildAt(i) // 子View
maxChildWidth = max(maxChildHeight, child.measuredWidth)
maxChildHeight = max(maxChildHeight, child.measuredHeight)
}
setMeasuredDimension(
resolveSize(maxChildWidth, widthMeasureSpec),
resolveSize(maxChildHeight, heightMeasureSpec)
)
}
其中,resolveSize(size, measureSpec)
在测量模式为AT_MOST
,并且size < specSize
时,返回size
;其他情况下,将返回specSize
。
这很容易理解,(FrameLayout的测量逻辑)简单的来说就是:
- 当 ViewGroup 设置的宽度为固定值时,最终宽度为这个固定值;
- 当 ViewGroup 设置的宽度为
match_parent
时,最终宽度为其父容器的宽度; - 当 ViewGroup 设置的宽度为
wrap_content
并且子 View 的最大宽度小于 ViewGroup 的父容器时,ViewGroup 的最终宽度等于最大子 View 的宽度; - 当 ViewGroup 设置的宽度为
wrap_content
并且子 View 的最大宽度大于等于 ViewGroup 的父容器时,ViewGroup 的最终宽度等于其父容器的宽度。
二、重写onLayout
onLayout
是自定义 ViewGroup 必须要实现的抽象方法,它的主要作用是确定 ViewGroup 各个子 View 的排列。
View 的位置由左、上、右、下四个边界来描述。在计算出子 View 的四个边界l
、t
、r
、b
后,调用child.layout(l, t, r, b)
来进行布局。
例
对于简易的FrameLayout
来讲,很容易得到:
- 子 View 的左、上两个边界是与 ViewGroup 贴合的,所以这两个边界与 ViewGroup 相同;
- 右边界
r
则等于左边界l + 子View的宽度
; - 下边界
b
等于上边界t + 子View的宽度
。
由此,简易版FrameLayout
的onLayout
重写如下:
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
for (i in 0..childCount) {
val child = getChildAt(i)
child.layout(l, t, l + child.measuredWidth, t + child.measuredHeight)
}
}
三、自定义LayoutParams
在第一节中提到了measureChild
与measureChildWithMargin
。在使用默认 LayoutParams 的时候,如果调用measureChildWithMargin
,程序会报错,因为ViewGroup.LayoutParams
是不支持 margin 属性的。
而 Android 自带的那些 Layout 之所以支持 margin,是因为它们都有自定义的 LayoutParams。
要实现自定义的 LayoutParams,首先创建一个自定义 LayoutParams 类,然后实现 generateDefaultLayoutParams
和generateLayoutParams
方法。
-
generateDefaultLayoutParams
方法在通过addView
方法将子 View 添加到这个 ViewGroup 中的时候调用,会给子 View 赋一个默认的 LayoutParams。 -
generateLayoutParams
方法有两个重载,其中:
generateLayoutParams(AttributeSet)
将根据布局中填写的属性来生成自定义的 LayoutParams 并返回;
generateLayoutParams(LayouParams)
一般和checkLayoutParams
同时重写。在addView
的过程中,当待添加的子 View 的 LayoutParams 不满足checkLayoutParams
的条件时,则调用generateLayoutParams(LayouParams)
生成一个新的 LayoutParams。
例
上例中的简易FrameLayout
想要支持 margin 属性,首先创建一个继承自MarginLayoutParams
的类FrameLayoutParams
:
class FrameLayoutParams: MarginLayoutParams {
constructor(c: Context?, attrs: AttributeSet?) : super(c, attrs)
constructor(width: Int, height: Int) : super(width, height)
}
这里没有添加任何属性。如果想增加额外的自定义属性,可以在 xml 中定义一个 declare-styleable
标签,这与 View 的自定义属性相同,在此不再赘述。
然后再重写generateDefaultLayoutParams
与generateLayoutParams
方法。
override fun generateDefaultLayoutParams() = FrameLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
override fun generateLayoutParams(attrs: AttributeSet?) = FrameLayoutParams(context, attrs)
override fun generateLayoutParams(p: LayoutParams?) = FrameLayoutParams(p)
override fun checkLayoutParams(p: LayoutParams?) = p is FrameLayoutParams
因为这个自定义的 FrameLayoutParams 里面没有任何的属性,所以其实可以不创建新类,直接用 MarginLayoutParams 替代。
override fun generateDefaultLayoutParams() = MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
override fun generateLayoutParams(attrs: AttributeSet?) = MarginLayoutParams(context, attrs)
override fun generateLayoutParams(p: LayoutParams?) = MarginLayoutParams(p)
override fun checkLayoutParams(p: LayoutParams?) = p is MarginLayoutParams
由于添加了 margin 属性,所以测量与布局的过程都要考虑 margin。最终SimpleFrameLayout
的代码如下:
class SimpleFrameLayout(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : ViewGroup(context, attrs, defStyleAttr) {
constructor(context: Context) : this(context, null, 0)
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs, 0)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
// 测量所有子View
measureChildren(widthMeasureSpec, heightMeasureSpec)
// 如果是宽高都是固定值,那么就直接返回
if (widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) {
setMeasuredDimension(widthSize, heightSize)
return
}
// 通过子View的宽(高),计算出 ViewGroup 的宽(高)
var maxChildWidth = 0 // 子View的最大宽度
var maxChildHeight = 0 // 子View的最大高度
for (i in 0 until childCount) {
val child = getChildAt(i) // 子View
maxChildWidth = max(maxChildHeight, child.measuredWidth + child.marginLeft + child.marginRight)
maxChildHeight = max(maxChildHeight, child.measuredHeight + child.marginTop + child.marginBottom)
}
setMeasuredDimension(
resolveSize(maxChildWidth, widthMeasureSpec),
resolveSize(maxChildHeight, heightMeasureSpec)
)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
for (i in 0..childCount) {
val child = getChildAt(i)
val left = l + child.marginLeft
val top = t + child.marginTop
child.layout(left, top, left + child.measuredWidth, top + child.measuredHeight)
}
}
override fun generateDefaultLayoutParams() = MarginLayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
override fun generateLayoutParams(attrs: AttributeSet?) = MarginLayoutParams(context, attrs)
override fun generateLayoutParams(p: LayoutParams?) = MarginLayoutParams(p)
override fun checkLayoutParams(p: LayoutParams?) = p is MarginLayoutParams
}
四、不正常
上面说了正常情况下的操作,下面看看不正常的情况下会发生什么。
创建继承自 ViewGroup 的类MyLayout
,代码如下:
class MyLayout : ViewGroup {
constructor(context: Context?) : super(context)
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
for (i in 0 until childCount) {
val child = getChildAt(i)
val size = MeasureSpec.makeMeasureSpec(100, MeasureSpec.EXACTLY)
child.measure(size, size)
}
setMeasuredDimension(600, 1200)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var top = t
for (i in 0 until childCount) {
val child = getChildAt(i)
val w = child.measuredWidth
val h = child.measuredHeight
child.layout(l, top, l + w, top + h)
top += h
}
}
}
在这个类中,强制把所有子 View 的宽高都设为了100像素,并且在竖直方向按照顺序依次排列。并且把 ViewGroup 的尺寸设置为固定的 600x1200。那么,实际的显示效果如何呢?
创建一个 test.xml,如下:
<top.littlefogcat.ui.MyLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#f00">
<View
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:background="#00F" />
<View
android:layout_width="300dp"
android:layout_height="100dp"
android:background="#000" />
</top.littlefogcat.ui.MyLayout>
可以看到,根布局是一个宽高都为match_parent
、背景为红色的的MyLayout
。
它有两个子 View:第一个子 View 宽为wrap_content
,高为match_parent
,蓝色背景;第二个子 View 宽高为 300dp x 100dp,黑色背景。
实际的显示效果如下:
可以看到,的确和上面描述的相同,父布局大小为600x1200,子 View 为 100x100的正方形;不管这三个控件宽高如何设置,都不影响最终的显示效果。
甚至如下段代码所示,都不用在onMeasure
中测量子View,只用重写onLayout
就能达到相同的效果:
class MyLayout(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : ViewGroup(context, attrs, defStyleAttr) {
constructor(context: Context?) : this(context, null, 0)
constructor(context: Context?, attrs: AttributeSet?) : this(context, attrs, 0)
override fun onMeasure(w: Int, h: Int) = setMeasuredDimension(600, 1200)
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var top = t
for (i in 0 until childCount) {
val child = getChildAt(i)
val bottom = top + 100
child.layout(l, top, l + 100, bottom)
top = bottom
}
}
}
然后,我们可以在代码中打印这些控件的大小:
root.post {
Log.d(TAG, "onCreate: root measured size = (${root.measuredWidth}, ${root.measuredHeight}), " +
"root size = (${root.width}, ${root.height})")
val count = root.childCount
for (i in 0 until count) {
val child = root.getChildAt(i)
Log.d(TAG, "onCreate: child$i/ measure=(${child.measuredWidth},${child.measuredHeight}), " +
"size=(${child.width},${child.height})")
}
}
得到以下结果:
root measured size = (600, 1200), root size = (600, 1200)
child0/ measure=(0,0), size=(100,100)
child1/ measure=(0,0), size=(100,100)
可见,子 View 的测量尺寸为0,因为根本就没有测量过。
回过头再来看 View 的 getMeasuredWidth
和 getWidth
方法:
public final int getWidth() { return mRight - mLeft; }
public final int getMeasuredWidth() { return mMeasuredWidth & MEASURED_SIZE_MASK; }
可以看到,getMeasuredWidth
返回的是 View 的 mMeasuredWidth
属性,而这个属性是在 ViewGroup 测量子 View 的时候通过child.measure(w, h)
传递过去的;因为MyLayout
没有测量子 View,所以它的孩子的mMeasuredWidth
都是0。
而getWidth
返回的是mRight - mLeft
,这两个是在 onLayout 方法里面 ViewGroup 通过child.layout
方法传递过去的。
所以从这里可以看出来getMeasuredWidth
和 getWidth
方法的区别了,一个是测量的宽度,一个是实际布局的宽度。
网友评论