Android 自定义View之Layout过程
前言
在上篇文章:Android 自定义View之Measure过程,我们分析了Measure过程,本次将会掀开承上启下的Layout过程神秘面纱,
通过本篇文章,你将了解到:
1、关于Layout 简单类比
2、一个简单Demo
3、View Layout过程
4、ViewGroup Layout过程
5、View/ViewGroup 常用方法分析
6、为什么说Layout是承上启下的作用
关于Layout 简单类比
在上篇文章的比喻里,我们说过:
老王给三个儿子,大王(大王儿子:小小王)、二王、三王分配了具体的良田面积,三个儿子(小小王)也都确认了自己的需要的良田面积。这就是:Measure过程
既然知道了分配给各个儿孙的良田大小,那他们到底分到哪一块呢,是靠边、还是中间、还是其它位置呢?先分给谁呢?
老王想按到这个家的时间先后顺序来吧(对应addView 顺序),大王是自己的长子,先分配给他,于是从最左侧开始,划出3亩田给大王。现在轮到二王了,由于大王已经分配了左侧的3亩,那么给二王的5亩地只能从大王右侧开始划分,最后剩下的就分给三王。这就是:ViewGroup onLayout 过程。
大王拿到老王给自己指定的良田的边界,将这个边界(左、上、右、下)坐标记录下来。这就是:View Layout过程
接着大王告诉自己的儿子小小王:你爹有点私心啊,从爷爷那继承的5亩田地不能全分给你,我留一些养老。这就是设置:padding 过程
如果二王在最开始测量的时候就想:我不想和大王、三王的田离得太近,那么老王就会给大王、三王与二王的土地之间留点缝隙。这就是设置:margin 过程
一个简单Demo
自定义ViewGroup
public class MyViewGroup extends ViewGroup {
public MyViewGroup(Context context) {
super(context);
}
public MyViewGroup(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int usedWidth = 0;
int maxHeight = 0;
int childState = 0;
//测量子布局
for (int i = 0; i < getChildCount(); i++) {
View childView = getChildAt(i);
MarginLayoutParams layoutParams = (MarginLayoutParams) childView.getLayoutParams();
measureChildWithMargins(childView, widthMeasureSpec, usedWidth, heightMeasureSpec, 0);
usedWidth += layoutParams.leftMargin + layoutParams.rightMargin + childView.getMeasuredWidth();
maxHeight = Math.max(maxHeight, layoutParams.topMargin + layoutParams.bottomMargin + childView.getMeasuredHeight());
childState = combineMeasuredStates(childState, childView.getMeasuredState());
}
//统计子布局水平,记录尺寸值
usedWidth += getPaddingLeft() + getPaddingRight();
maxHeight += getPaddingTop() + getPaddingBottom();
setMeasuredDimension(resolveSizeAndState(usedWidth, widthMeasureSpec, childState),
resolveSizeAndState(maxHeight, heightMeasureSpec, childState << MEASURED_HEIGHT_STATE_SHIFT));
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
//父布局传递进来的位置信息
int parentLeft = getPaddingLeft();
int left = 0;
int top = 0;
int right = 0;
int bottom = 0;
//遍历子布局
for (int i = 0; i < getChildCount(); i++) {
View childView = getChildAt(i);
MarginLayoutParams layoutParams = (MarginLayoutParams) childView.getLayoutParams();
left = parentLeft + layoutParams.leftMargin;
right = left + childView.getMeasuredWidth();
top = getPaddingTop() + layoutParams.topMargin;
bottom = top + childView.getMeasuredHeight();
//子布局摆放
childView.layout(left, top, right, bottom);
//横向摆放
parentLeft += right;
}
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new MyLayoutParam(getContext(), attrs);
}
//自定义LayoutParams
static class MyLayoutParam extends MarginLayoutParams {
public MyLayoutParam(Context c, AttributeSet attrs) {
super(c, attrs);
}
}
该ViewGroup 重写了onMeasure(xx)和onLayout(xx)方法:
- onMeasure(xx) 测量子布局大小,并根据子布局测算结果来决定自己的尺寸
- onLayout(xx) 摆放子布局位置
自定义View
public class MyView extends View {
public MyView(Context context) {
super(context);
}
public MyView(Context context, @Nullable AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onDraw(Canvas canvas) {
canvas.drawColor(Color.GREEN);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int defaultSize = 100;
setMeasuredDimension(resolveSize(defaultSize, widthMeasureSpec), resolveSize(defaultSize, heightMeasureSpec));
}
@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
super.onLayout(changed, left, top, right, bottom);
}
}
该View 重写了onMeasure(xx)和onLayout(xx)方法:
- onMeasure(xx) 测量自身大小,并记录尺寸值
- onLayout(xx) 什么都没做
为MyViewGroup 添加子布局
<?xml version="1.0" encoding="utf-8"?>
<com.fish.myapplication.MyViewGroup xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/myviewgroup"
android:layout_width="match_parent"
android:layout_height="100dp"
android:layout_gravity="center_vertical"
android:background="#000000"
android:paddingLeft="10dp"
tools:context=".MainActivity">
<com.fish.myapplication.MyView
android:layout_width="wrap_content"
android:layout_height="wrap_content">
</com.fish.myapplication.MyView>
<Button
android:layout_marginLeft="10dp"
android:text="hello Button"
android:layout_width="match_parent"
android:layout_height="wrap_content">
</Button>
</com.fish.myapplication.MyViewGroup>
MyViewGroup里添加了MyView、Button两个控件,最终运行的效果如下:
image.png
可以看出,MyViewGroup 里子布局的是横向摆放的。我们重点关注Layout过程。实际上,MyViewGroup里我们只重写了onLayout(xx)方法,MyView也是重写了onLayout(xx)方法。
接下来,分析View Layout过程。
View Layout过程
View.layout(xx)
与Measure过程类似,连接ViewGroup onLayout(xx)和View onLayout(xx)之间的桥梁是View layout(xx)。
#View.java
public void layout(int l, int t, int r, int b) {
//PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT 在measure时候可能会设置
if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
}
//记录当前的坐标值
int oldL = mLeft;
int oldT = mTop;
int oldB = mBottom;
int oldR = mRight;
//新(父布局给的)的坐标值与当前坐标值不一致,则认为有改变
boolean changed = isLayoutModeOptical(mParent) ?
setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
//坐标改变或者是需要重新layout
if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
//调用onLayout方法,传入父布局传入的坐标
onLayout(changed, l, t, r, b);
...
//清空请求layout标记
mPrivateFlags &= ~PFLAG_LAYOUT_REQUIRED;
//监听的onLayoutChange回调,通过addOnLayoutChangeListener 设置
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnLayoutChangeListeners != null) {
ArrayList<OnLayoutChangeListener> listenersCopy =
(ArrayList<OnLayoutChangeListener>)li.mOnLayoutChangeListeners.clone();
int numListeners = listenersCopy.size();
for (int i = 0; i < numListeners; ++i) {
listenersCopy.get(i).onLayoutChange(this, l, t, r, b, oldL, oldT, oldR, oldB);
}
}
}
...
}
public static boolean isLayoutModeOptical(Object o) {
//设置了阴影,发光等属性
//只有ViewGroup有这属性
//设置 android:layoutMode="opticalBounds" 或者 android:layoutMode="clipBounds"
//则返回true,默认没设置以上属性
return o instanceof ViewGroup && ((ViewGroup) o).isLayoutModeOptical();
}
private boolean setOpticalFrame(int left, int top, int right, int bottom) {
//如果设置了阴影、发光灯属性
//则获取其预留的尺寸
Insets parentInsets = mParent instanceof View ?
((View) mParent).getOpticalInsets() : Insets.NONE;
Insets childInsets = getOpticalInsets();
//重新改变坐标值,并调用setFrame(xx)
return setFrame(
left + parentInsets.left - childInsets.left,
top + parentInsets.top - childInsets.top,
right + parentInsets.left + childInsets.right,
bottom + parentInsets.top + childInsets.bottom);
}
可以看出,最终都调用了setFrame(xx)方法。
#View.java
protected boolean setFrame(int left, int top, int right, int bottom) {
boolean changed = false;
//当前坐标值与新的坐标值不一致,则重新设置
if (mLeft != left || mRight != right || mTop != top || mBottom != bottom) {
changed = true;
//记录PFLAG_DRAWN标记位
int drawn = mPrivateFlags & PFLAG_DRAWN;
//记录新、旧宽高
int oldWidth = mRight - mLeft;
int oldHeight = mBottom - mTop;
int newWidth = right - left;
int newHeight = bottom - top;
//新、旧宽高是否一样
boolean sizeChanged = (newWidth != oldWidth) || (newHeight != oldHeight);
//不一样,走inValidate,最终执行Draw流程
invalidate(sizeChanged);
//将新的坐标值记录
mLeft = left;
mTop = top;
mRight = right;
mBottom = bottom;
//设置坐标值给RenderNode
mRenderNode.setLeftTopRightBottom(mLeft, mTop, mRight, mBottom);
//标记已经layout过
mPrivateFlags |= PFLAG_HAS_BOUNDS;
if (sizeChanged) {
//调用sizeChange,在该方法里,我们已经能够拿到View宽、高值
sizeChange(newWidth, newHeight, oldWidth, oldHeight);
}
...
}
return changed;
}
对于Measure过程在onMeasure(xx)里记录了尺寸的值,而对于Layout过程则在layout(xx)里记录了坐标值,具体来说是在setFrame(xx)里,该方法两个重点地方:
1、将新的坐标值记录到成员变量mLeft、mTop、mRight、mBottom里
2、将新的坐标值记录到RenderNode里,当调用Draw过程的时候,Canvas绘制起点就是RenderNode里的位置
View.onLayout(xx)
#View.java
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
}
View.onLayout(xx)是空实现
从layout(xx)和onLayout(xx)声明可知,这两个方法都是可以被重写的,接下来看看ViewGroup是否重写了它们。
ViewGroup Layout过程
ViewGroup.layout(xx)
#ViewGroup.java
@Override
public final void layout(int l, int t, int r, int b) {
if (!mSuppressLayout && (mTransition == null || !mTransition.isChangingLayout())) {
//没有被延迟,或者动画没在改变坐标
if (mTransition != null) {
mTransition.layoutChange(this);
}
//父类方法,其实就是View.layout(xx)
super.layout(l, t, r, b);
} else {
//被延迟,那么设置标记位,动画完成后根据标志位requestLayout,重新发起layout过程
mLayoutCalledWhileSuppressed = true;
}
}
ViewGroup.layout(xx)虽然重写了layout(xx),但是仅仅做了简单判断,最后还是调用了View.layout(xx)。
ViewGroup.onLayout(xx)
#ViewGroup.java
protected abstract void onLayout(boolean changed,
int l, int t, int r, int b);
这重写后将onLayout变为抽象方法,也就是说继承自ViewGroup的类必须重写onLayout(xx)方法。
我们以FrameLayout为例,分析其onLayout(xx)做了什么。
FrameLayout.onLayout(xx)
#FrameLayout.java
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
layoutChildren(left, top, right, bottom, false /* no force left gravity */);
}
void layoutChildren(int left, int top, int right, int bottom, boolean forceLeftGravity) {
//子布局个数
final int count = getChildCount();
//前景padding,意思是子布局摆放的时候不要侵占该位置
final int parentLeft = getPaddingLeftWithForeground();
final int parentRight = right - left - getPaddingRightWithForeground();
final int parentTop = getPaddingTopWithForeground();
final int parentBottom = bottom - top - getPaddingBottomWithForeground();
//遍历子布局
for (int i = 0; i < count; i++) {
final View child = getChildAt(i);
//GONE状态下无需layout
if (child.getVisibility() != GONE) {
//获取LayoutParams
final FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) child.getLayoutParams();
//获取之前在Measure过程确定的测量值
final int width = child.getMeasuredWidth();
final int height = child.getMeasuredHeight();
int childLeft;
int childTop;
//摆放重心落在哪
int gravity = lp.gravity;
if (gravity == -1) {
gravity = DEFAULT_CHILD_GRAVITY;
}
//布局方向,左到右还是右到左,默认左到右
final int layoutDirection = getLayoutDirection();
//水平反向的Gravity
final int absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection);
//垂直方向的Gravity
final int verticalGravity = gravity & Gravity.VERTICAL_GRAVITY_MASK;
switch (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) {
case Gravity.CENTER_HORIZONTAL:
//若子布局水平居中,则它的水平方向起始点
//扣除父布局padding剩下的位置
//结合子布局宽度,使得子布局在剩下位置里居中
//再将子布局margin考虑进去
//从这里可以看出,若是xml里有居中,也有margin,先考虑居中,然后再考虑margin
childLeft = parentLeft + (parentRight - parentLeft - width) / 2 +
lp.leftMargin - lp.rightMargin;
break;
case Gravity.RIGHT:
//靠右,则改变横向的开始坐标值
if (!forceLeftGravity) {
childLeft = parentRight - width - lp.rightMargin;
break;
}
//默认是从左到右
case Gravity.LEFT:
default:
childLeft = parentLeft + lp.leftMargin;
}
//垂直方向与水平方向类似
switch (verticalGravity) {
case Gravity.TOP:
childTop = parentTop + lp.topMargin;
break;
case Gravity.CENTER_VERTICAL:
childTop = parentTop + (parentBottom - parentTop - height) / 2 +
lp.topMargin - lp.bottomMargin;
break;
case Gravity.BOTTOM:
childTop = parentBottom - height - lp.bottomMargin;
break;
default:
childTop = parentTop + lp.topMargin;
}
//确定了child的坐标位置
//传递给child
child.layout(childLeft, childTop, childLeft + width, childTop + height);
}
}
}
FrameLayout.onLayout(xx)为子布局Layout的时候,起始坐标都是以FrameLayout为基准,并没有记录上一个子布局占了哪块位置,因此子布局的摆放位置可能会重叠,这也是FrameLayout布局特性的由来。而我们之前的Demo在水平方向上记录了上一个子布局的摆放位置,下一个摆放时只能在它之后,因此就形成了水平摆放的功能。
由此类推,我们常说的某个子布局在父布局里的哪个位置,决定这个位置的即是ViewGroup.onLayout(xx)。
View/ViewGroup 常用方法分析
上边我们分析了View.layout(xx)、View.onLayout(xx)、ViewGroup.layout(xx)、ViewGroup.onLayout(xx),这四者什么关系呢?
View.layout(xx)
将摆放位置坐标记录到成员变量里并给RenderNode设值
View.onLayout(xx)
空实现
ViewGroup.layout(xx)
调用View.layout(xx)**
ViewGroup.onLayout(xx)
抽象方法,子类必须重写。子类重写时候需要为每一个子布局计算出摆放位置,并传递给子布局
View/ViewGroup 子类需要重写哪些方法:
继承自ViewGroup必须重写onLayout(xx),为子布局计算位置坐标
继承自View 无需重写layout(xx)和onLayout(xx),因为它已经没有子布局可以摆放
用图表示:
image.png
为什么说Layout是承上启下的作用
通过上述的描述,我们发现Measure过程和Layout过程里定义的方法比较类似:
measure(xx)<----->layout(xx)
onMeasure(xx)<----->onLayout(xx)
它俩的套路比较类似:measure(xx)、layout(xx)一般不需要我们重写,measure(xx)里调用onMeasure(xx),layout(xx)为调用者设置坐标值。
若是ViewGroup:onMeasure(xx)里遍历子布局,并测量每个子布局,最后将结果汇总,设置自己测量的尺寸;onLayout(xx)里遍历子布局,并设置每个子布局的坐标。
若是View:onMeasure(xx)则测量自身,并存储测量尺寸;onLayout(xx)不需要做什么。
承上
Measure过程虽然比Layout过程复杂,但仔细分析后就会发现其本质就是为了设置两个成员变量:
设置 mMeasuredWidth 和 mMeasuredHeight
而Layout过程虽然比较简单,其本质是为了设置坐标值
1、设置mLeft、mRight、mTop、mBottom 这四个值确定一个矩形区域
2、mRenderNode.setLeftTopRightBottom(mLeft, mTop, mRight, mBottom) 给RnederNode设置坐标
将Measure设置的变量和Layout设置的变量联系起来:
mRight、mBottom 是根据mLeft、mRight 结合mMeasuredWidth、mMeasuredHeight 计算而得的
这就是Layout的承上作用
启下
我们知道View的绘制需要依靠Canvas绘制,而Canvas是有作用区域限制的。例如我们使用:
canvas.drawColor(Color.GREEN);
Cavas绘制的起点是哪呢?
正是通过Layout过程中设置的RenderNode坐标。
这就是Layout的启下作用
以上即是Measure、Layout、Draw三者的内在联系。
当然Layout的"承上"还需要考虑margin、gravity等参数的影响。具体用法参见最开始的Demo。
经典问题
getMeasuredWidth()/getMeasuredHeight 与 getWidth/getHeight区别
我们以获取width为例,分别来看看其方法:
#View.java
public final int getMeasuredWidth() {
return mMeasuredWidth & MEASURED_SIZE_MASK;
}
public final int getWidth() {
return mRight - mLeft;
}
getMeasuredWidth():获取测量的宽,属于"临时值"
getWidth():获取View真实的宽
在Layout过程之前,getWidth() 默认为0
何时可以获取真实的宽、高
1、重写View.onSizeChanged(xx)方法获取
2、注册View.addOnLayoutChangeListener(xx),在onLayoutChange(xx)里获取
3、重写View.onLayout(xx)方法获取
下篇将分析Draw()过程,我们将分析"一切都是draw出来的"道理
本篇基于 Android 10.0