在Android开发中,LayoutParams使用的场景相对来说比较少,总结一下也就三大方面:一是自定义ViewGroup时要获取子元素的LayoutParams来完成测量和布局流程;二是动态的给ViewGroup添加一个子View;三是动态改变子元素布局参数来实现滑动效果。虽然说它的使用频率并不高,但是它对我们深入理解View的工作原理上具有重要的作用,本文将结合源码介绍LayoutParams的相关知识。
LayoutParams是什么,有什么用
LayoutParams翻译成汉语就是布局参数,每个控件都有自己的LayoutParams,而且子元素通过自己的LayoutParams来告诉父容器如何摆放自己。这里的摆放又包含了两层含义:一是确定子元素的大小(因为首先要知道大小才能进行摆放);二是确定子元素在父容器中的位置。因为任何一个控件都不是独立存在的,都要被添加到ViewGroup中才能显示在屏幕上,所以LayoutParams跟父容器是分不开的,因此脱离了父容器来讨论LayoutParams没有任何意义。
就其本质来说,LayoutParams的根本作用就是为View的3大流程服务的,关于3大流程,可以参考之前的一篇文章View的三大流程分析。回想一下View测量流程中的2个方法:
protected void measureChild(View child, int parentWidthMeasureSpec, int parentHeightMeasureSpec) {
final LayoutParams lp = child.getLayoutParams();
//父容器根据子元素的布局参数和父容器自己的MeasureSpec计算子元素的MeasureSpec
final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, mPaddingLeft + mPaddingRight, lp.width);
final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec, mPaddingTop + mPaddingBottom, lp.height);
//根据计算好的MeasureSpec调用子元素的measure方法进入了子元素的测量流程
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed,int parentHeightMeasureSpec, int heightUsed) {
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
//父容器根据子元素的布局参数和父容器自己的MeasureSpec计算子元素的MeasureSpec
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);
//根据计算好的MeasureSpec调用子元素的measure方法进入了子元素的测量流程
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
可以看到,计算子元素MeasureSpec的第一步就是要获取其LayoutParams,之后将LayoutParams通过getChildMeasureSpec方法转换成具体的数值。在ViewGroup的布局流程中,如果考虑margin,同样也要先获取子元素的布局参数。
LayoutParams及其派生子类
上面已经说过了,摆放自己有两个含义,一是确定大小,二是确定位置。跟大小相关的布局参数有两个:android:layout_width和android:layout_height;跟位置相关的布局参数就比较多了:android:layout_margin、android:layout_gravity、android:layout_weight、android:layout_alignParentStart等等。当然了,使用android:layout_weight的前提是父容器必须是LinearLayout,使用android:layout_alignParentStart的前提是父容器必须是RelativeLayout。
根据以上的分析我们知道,子元素除了一些自己具有的参数android:layout_width、android:layout_height和android:layout_margin之外,根据父容器的不同还可以有一些特殊的参数,这样就意味着布局参数这个概念在Java代码中会有不同的类去实现。而这些不同的类都有一个共同的父类,就是ViewGroup.LayoutParams,它的重要代码如下:
public static class LayoutParams {
public static final int FILL_PARENT = -1;
public static final int MATCH_PARENT = -1;
public static final int WRAP_CONTENT = -2;
public int width;
public int height;
//这个构造方法用于根据布局文件中的android:layout_width和android:layout_height创建LayoutParams
public LayoutParams(Context c, AttributeSet attrs) {
TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.ViewGroup_Layout);
setBaseAttributes(a,R.styleable.ViewGroup_Layout_layout_width,R.styleable.ViewGroup_Layout_layout_height);
a.recycle();
}
//这个构造方法根据指定的宽高创建LayoutParams
public LayoutParams(int width, int height) {
this.width = width;
this.height = height;
}
//这个构造方法通过传入的LayoutParams对象来创建LayoutParams
public LayoutParams(LayoutParams source) {
this.width = source.width;
this.height = source.height;
}
}
LayoutParams是一个ViewGroup的一个静态内部类,它保存了View的宽高信息,其对应着xml文件中的android:layout_width和android:layout_height属性。因为一个控件的宽高其父容器相关,所以这个属性被命名为android:layout_width/height而不是android:width,前面已经提过了,脱离了父容器,这些属性也就没了意义。
当然了,仅有宽高两个属性并不能确定控件在父容器中的位置,所以就产生了一个新的类MarginLayoutParams,它在LayoutParams的基础上增加了margin属性。MarginLayoutParams的主要代码如下:
public static class MarginLayoutParams extends ViewGroup.LayoutParams {
public int leftMargin;
public int topMargin;
public int rightMargin;
public int bottomMargin;
public MarginLayoutParams(Context c, AttributeSet attrs) {
super();
//设置宽和高
TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.ViewGroup_MarginLayout);
setBaseAttributes(a,
R.styleable.ViewGroup_MarginLayout_layout_width,
R.styleable.ViewGroup_MarginLayout_layout_height);
int margin = a.getDimensionPixelSize(
com.android.internal.R.styleable.ViewGroup_MarginLayout_layout_margin, -1);
if (margin >= 0) {
leftMargin = margin;
topMargin = margin;
rightMargin= margin;
bottomMargin = margin;
} else {
int horizontalMargin = a.getDimensionPixelSize(
R.styleable.ViewGroup_MarginLayout_layout_marginHorizontal, -1);
int verticalMargin = a.getDimensionPixelSize(
R.styleable.ViewGroup_MarginLayout_layout_marginVertical, -1);
if (horizontalMargin >= 0) {
leftMargin = horizontalMargin;
rightMargin = horizontalMargin;
} else {
leftMargin = a.getDimensionPixelSize(
R.styleable.ViewGroup_MarginLayout_layout_marginLeft,
UNDEFINED_MARGIN);
if (leftMargin == UNDEFINED_MARGIN) {
mMarginFlags |= LEFT_MARGIN_UNDEFINED_MASK;
leftMargin = DEFAULT_MARGIN_RESOLVED;
}
rightMargin = a.getDimensionPixelSize(
R.styleable.ViewGroup_MarginLayout_layout_marginRight,
UNDEFINED_MARGIN);
if (rightMargin == UNDEFINED_MARGIN) {
mMarginFlags |= RIGHT_MARGIN_UNDEFINED_MASK;
rightMargin = DEFAULT_MARGIN_RESOLVED;
}
}
startMargin = a.getDimensionPixelSize(
R.styleable.ViewGroup_MarginLayout_layout_marginStart,
DEFAULT_MARGIN_RELATIVE);
endMargin = a.getDimensionPixelSize(
R.styleable.ViewGroup_MarginLayout_layout_marginEnd,
DEFAULT_MARGIN_RELATIVE);
if (verticalMargin >= 0) {
topMargin = verticalMargin;
bottomMargin = verticalMargin;
} else {
topMargin = a.getDimensionPixelSize(
R.styleable.ViewGroup_MarginLayout_layout_marginTop,
DEFAULT_MARGIN_RESOLVED);
bottomMargin = a.getDimensionPixelSize(
R.styleable.ViewGroup_MarginLayout_layout_marginBottom,
DEFAULT_MARGIN_RESOLVED);
}
}
……
a.recycle();
}
}
代码有点长,但还是很好理解的。因为MarginLayoutParams是继承自LayoutParams的,所以首先还是设置宽和高,之后就是设置margin了。设置margin的过程遵循着由上到下的过程,这一点从代码中也可以看出来。如果在xml中指定了android:layout_margin属性,那么上下左右边距都会被赋值成这个margin值;如果没设置android:layout_margin而是设置了android:layout_marginVertical或者android:layout_marginHorizontal属性,那么就将上下边距或左右边距指定为对应的值;如果xml文件中没有上述3个属性,则会依次设置android:layout_marginLeft/Right/Bottom/Top这4个属性。
由这个过程我们可以得出结论:上述几个属性的优先级为:margin > horizontalMargin和verticalMargin > leftMargin和RightMargin、topMargin和bottomMargin,优先级高的属性会覆盖掉低的。还要注意一点的是,如果我们动态的改变这些边距值,那么还要通过控件的setLayoutParams方法对其进行更新。
MarginLayoutParams之后就是一些具体容器特有的布局参数类了,比如说LinearLayout.LayoutParams、RelativeLayout.LayoutParams、FrameLayout.LayoutParams等等,它们都是继承自ViewGroup.MarginLayoutParams,这里我们拿出LinearLayout.LayoutParams看看:
public static class LayoutParams extends ViewGroup.MarginLayoutParams {
@ViewDebug.ExportedProperty(category = "layout")
@InspectableProperty(name = "layout_weight")
public float weight;
/**
* Gravity for the view associated with these LayoutParams.
*
* @see android.view.Gravity
*/
@ViewDebug.ExportedProperty(category = "layout", mapping = {
@ViewDebug.IntToString(from = -1, to = "NONE"),
@ViewDebug.IntToString(from = Gravity.NO_GRAVITY, to = "NONE"),
@ViewDebug.IntToString(from = Gravity.TOP, to = "TOP"),
@ViewDebug.IntToString(from = Gravity.BOTTOM, to = "BOTTOM"),
@ViewDebug.IntToString(from = Gravity.LEFT, to = "LEFT"),
@ViewDebug.IntToString(from = Gravity.RIGHT, to = "RIGHT"),
@ViewDebug.IntToString(from = Gravity.START, to = "START"),
@ViewDebug.IntToString(from = Gravity.END, to = "END"),
@ViewDebug.IntToString(from = Gravity.CENTER_VERTICAL, to = "CENTER_VERTICAL"),
@ViewDebug.IntToString(from = Gravity.FILL_VERTICAL, to = "FILL_VERTICAL"),
@ViewDebug.IntToString(from = Gravity.CENTER_HORIZONTAL, to = "CENTER_HORIZONTAL"),
@ViewDebug.IntToString(from = Gravity.FILL_HORIZONTAL, to = "FILL_HORIZONTAL"),
@ViewDebug.IntToString(from = Gravity.CENTER, to = "CENTER"),
@ViewDebug.IntToString(from = Gravity.FILL, to = "FILL")
})
@InspectableProperty(
name = "layout_gravity",
valueType = InspectableProperty.ValueType.GRAVITY)
public int gravity = -1;
/**
* {@inheritDoc}
*/
public LayoutParams(Context c, AttributeSet attrs) {
super(c, attrs);
……
}
}
可以看到,里面定义了LinearLayout中一些自己特有的属性android:layout_gravity和android:layout_weight。对于其他容器的LayoutParams,里面也定义了它们特有的属性。
布局参数的创建和跟View联系的建立
明白了布局参数LayoutParams的意义和作用之后,那么它是怎么和一个具体的控件建立关联的呢?View给我们提供了一个setLayoutParams方法,该方法执行完毕之后在测量和布局流程中我们就可以通过getLayoutParams获取到这个布局参数了。
那么LayoutParams是怎样被创建出来的呢?这要分为两种情况:定义在xml文件中控件布局参数的创建和动态创建控件时布局参数的创建。
动态创建View时布局参数的创建过程
先来说说比较简单的动态创建View时布局参数是如何被创建的。由于控件是我们动态创建的,所以它没有父容器,我们想要让它显示在屏幕上就要让它成为一个容器的子元素,ViewGroup提供了addView方法来实现这一功能,该方法有几个重载:
//重载1:直接给ViewGroup添加一个子元素,默认添加到ViewGroup的末尾
public void addView(View child) {
addView(child, -1);
}
//重载2:将View添加到指定位置index
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();// 生成当前ViewGroup默认的LayoutParams
if (params == null) {
throw new IllegalArgumentException("generateDefaultLayoutParams() cannot return null");
}
}
addView(child, index, params);
}
//重载3:给View指定了宽高值再添加到ViewGroup中,默认添加到ViewGroup的末尾
public void addView(View child, int width, int height) {
final LayoutParams params = generateDefaultLayoutParams(); // 生成当前ViewGroup默认的LayoutParams
params.width = width;
params.height = height;
addView(child, -1, params);
}
//重载4:根据传入的布局参数将View添加到ViewGroup中,默认添加到ViewGroup的末尾
@Override
public void addView(View child, LayoutParams params) {
addView(child, -1, params);
}
//重载5:根据传入的布局参数将View添加到ViewGroup的指定位置index
public void addView(View child, int index, LayoutParams params) {
if (child == null) {
throw new IllegalArgumentException("Cannot add a null child view to a ViewGroup");
}
// addViewInner() will call child.requestLayout() when setting the new LayoutParams
// therefore, we call requestLayout() on ourselves before, so that the child's request
// will be blocked at our level
requestLayout();
invalidate(true);
addViewInner(child, index, params, false);
}
对于前两个重载方法由于没有指定LayoutParams,所以它们在内部会调用generateDefaultLayoutParams方法来为它创建一个LayoutParams。当然这也有个前提,就是传进来的View本身没有LayoutParams。在源码中我们也可以看到,如果child.getLayoutParams为空,那么才会通过generateDefaultLayoutParams创建默认的布局参数。
对于第3个重载方法,在源码中可以看到首先通过generateDefaultLayoutParams创建默认的布局参数,之后再将其宽高设置为指定值。
对于后两个重载方法,由于在调用的时候就已经指定了布局参数,所以在内部调用addViewInner将View添加到ViewGroup中即可。在源码中可以发现,前4个方法最终都会调用第5个重载方法。
说了这么多,我们来看看如果调用时没指定布局参数而且View本身也不带布局参数时,ViewGroup是怎么为子元素创建默认的布局参数的,我们点开ViewGroup的generateDefaultLayoutParams方法:
protected LayoutParams generateDefaultLayoutParams() {
return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
}
非常简单,ViewGroup默认为其子元素创建的布局参数在宽高方向上都是wrap_content的。但是很可惜,这个方法几乎不会被用到,因为对于一个具体的容器,都重写了这个方法。这里我们点开FrameLayout的generateDefaultLayoutParams方法看一下:
@Override
protected LayoutParams generateDefaultLayoutParams() {
return new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
}
FrameLayou默认创建的布局参数宽高都是match_parent的,对于其他的容器,创建的原则也有所不同。
布局参数创建好了之后,在第5个重载方法中调用了requestLayout()和invalidate(true)方法来重新激活3大流程。这一点是必然的,因为既然添加了新的子元素,那么原有视图就会改变,因此3大流程就要重新走一遍。之后就调用了addViewInner方法将子元素放到父容器中了,我们点开它看一看:
private void addViewInner(View child, int index, LayoutParams params, boolean preventRequestLayout) {
……
//判断传入的child是否已经有父容器了
if (child.getParent() != null) {
throw new IllegalStateException("The specified child already has a parent. " +
"You must call removeView() on the child's parent first.");
}
//判断传入的布局参数是否为空
if (!checkLayoutParams(params)) {
params = generateLayoutParams(params);
}
//判断child需不需要重新布局
if (preventRequestLayout) {
child.mLayoutParams = params; //需要
} else {
child.setLayoutParams(params); //不需要
}
if (index < 0) {
index = mChildrenCount;
}
//将child添加到父容器中
addInArray(child, index);
// 指定了child的父容器为this
if (preventRequestLayout) {
child.assignParent(this);
} else {
child.mParent = this;
}
……
}
代码中的注释把addInner方法的流程描述的很清楚了,这样一来动态创建的View布局参数创建过程就说完了,接下来看看我们编写xml文件来做app界面时,文件中控件的布局参数是如何被创建的。
xml文件中View的布局参数创建过程
在Android开发之LayoutInflater及其源码分析和Android开发Activity的setContentView源码分析这俩篇文章中已经说过了,Activity的setContentView方法中会调用LayoutInflater的inflate方法将xml中定义的各种控件解析成对象,最终将布局的根ViewGroup添加到mContentParent中来,其代码如下:
/**
* 解析XML文件中的View
* @param parser 解析器
* @param root 父容器(可能为null)
* @param attachToRoot View是否需要附加到父容器中
*/
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
......
View result = root;
......
final String name = parser.getName();
if (TAG_MERGE.equals(name)) { // 针对<merge>标签
......
} else { // 针对普通标签
// ① 通过XML生成对应的View对象
// Temp指的是XML文件中的根View
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
ViewGroup.LayoutParams params = null;
if (root != null) {
// ② 通过XML中的布局参数生成对应的LayoutParams
params = root.generateLayoutParams(attrs);
if (!attachToRoot) {
// ③ 如果不需要将View附加到父容器中,就直接为View设置LayoutParams
temp.setLayoutParams(params);
}
}
rInflateChildren(parser, temp, attrs, true); // 解析View中包含的子View(如果存在的话)
// ④ 如果父容器不为null,且需要将View附加到父容器中,就使用addView方法
if (root != null && attachToRoot) {
root.addView(temp, params);
}
// Decide whether to return the root that was passed in or the top view found in xml.
if (root == null || !attachToRoot) {
result = temp;
}
}
......
return result;
}
代码中可以看到如果root不为空,则调用generateLayoutParams为子元素创建布局参数,而generateLayoutParams方法是根据xml文件中子元素的属性值来创建LayoutParams的,这一点根创建默认布局参数不同。
总的来说,就是Activity中调用setContentView方法,通过LayoutInflater将整个XML文件解析为View树,从根布局开始为每个View和ViewGroup设置相应的LayoutParams。
这样一来布局参数的创建过程也就说完了,有了以上的理论过程,就可以来讨论一下自定义布局参数了。
自定义布局参数
在自定义ViewGroup时如果想让它支持我们自定义的一些布局属性,就可以用自定义布局参数来实现。自定义的ViewGroup要继承自ViewGroup.MarginLayoutParams,否则这个自定义的ViewGroup将不会支持子元素的margin属性。此外因为这些属性是我们自定义的,那么就要在attr.xml文件中定义好它们,示例代码如下:
<resources>
<!-- 自定义属性集 -->
<declare-styleable name="MyViewGroup_Layout">
<!-- 自定义的属性 -->
<attr name="my_layout_xxx" format="integer"/>
<!-- 使用系统预置的属性 -->
<attr name="android:layout_gravity"/>
</declare-styleable>
</resources>
之后就是在自定义的布局参数中解析这些属性:
public static class LayoutParams extends ViewGroup.MarginLayoutParams {
public int my_layout_xxx;
public int gravity;
public LayoutParams(Context c, AttributeSet attrs) {
super(c, attrs);
// 解析布局属性
TypedArray typedArray = c.obtainStyledAttributes(attrs, R.styleable.MyViewGroup_Layout);
simpleAttr = typedArray.getInteger(R.styleable.MyViewGroup_Layout_my_layout_xxx, 0);
gravity=typedArray.getInteger(R.styleable.SimpleViewGroup_Layout_android_layout_gravity, -1);
typedArray.recycle();//释放资源
}
public LayoutParams(int width, int height) {
super(width, height);
}
public LayoutParams(MarginLayoutParams source) {
super(source);
}
public LayoutParams(ViewGroup.LayoutParams source) {
super(source);
}
}
最后,我们还需要重写ViewGroup中几个与LayoutParams相关的方法:
// 检查LayoutParams是否合法
@Override
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
return p instanceof SimpleViewGroup.LayoutParams;
}
// 生成默认的LayoutParams
@Override
protected ViewGroup.LayoutParams generateDefaultLayoutParams() {
return new SimpleViewGroup.LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT);
}
// 对传入的LayoutParams进行转化
@Override
protected ViewGroup.LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
return new SimpleViewGroup.LayoutParams(p);
}
// 对传入的LayoutParams进行转化
@Override
public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) {
return new SimpleViewGroup.LayoutParams(getContext(), attrs);
}
这几个方法的作用之前已经提过了。如果不重写的话,那么LayoutInflater在解析xml文件时为子元素创建的布局参数的类型就是ViewGroup.LayoutParams,这样使用如下代码强制转换时就会产生类型不匹配异常:
MyViewGroup.LayoutParams lp = (MyViewGroup.LayoutParams) child.getLayoutParams();
同理,就算不自定义布局参数,如果想让自定义的ViewGroup支持子元素的margin属性,也要重写上面4个方法并在其中返回MarginLayoutParams的实例,否则就会在下面的代码中产生类型不匹配异常:
MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();