简单介绍我的布局框架
这里大家应该很奇怪,为什么先要介绍我的布局框架呢?其实主要的原因是因为先了解了我的布局框架后,再去理解我为什么会遇到这样的问题,会比较容易有代入感,下次看官你也遇到这种情况的时候可以很快的想起来这篇文章的解决方案了。
现在先来简单介绍一下我的布局,看代码:
熟悉安卓开发的应该很容易理解,这个布局其实就是我的一个基类的基础布局,这里什么都没有,就是一个很简单的CoordinatorLayout基础布局方式,里面唯一可见的就是Toolbar,今天我们不去追究为了形成 这个布局而做的其他工作,而主要研究的是这个布局方式所带来的一个奇怪的问题,就是子View的高度不正确的问题。
在实际开发的过程中,我的设计是开发者在开发的时候只要写具体的业务布局文件即可,写完的布局文件在利用LayoutInflator添加到StatusLayout中去,这个StatusLayout其本质就是个FrameLayout,你在这里也可以将其替换为FrameLayout,在本文中不会有任何影响。
这里就涉及到本文的核心问题了,我在基类的代码中使用了
flActivityContainer.addView(LayoutInflater.from(this).inflate(layoutId, flActivityContainer, false));
这行代码来实现布局的动态引入,但是引入后,我发现我实际的布局是这样的:
图1
正常情况下,粉红色应该铺满整个背景屏幕,但是从图中我们可以看到,粉红色只有子View的高度有多高,才会显示多高,并不是像我们在布局文件中设置的那样铺满屏幕,OK,问题终于来了。
第一次分析问题
从正常的角度来看,我的第一反应肯定是LayoutInflator引入View的时候产生的问题 ,正好加上LayoutInflator这里的知识点也不是很牢靠,于是打算从这里出发,好好的分析一下问题的原因。
首先,我会通过三个案例,来讲解inflate这个方法由于不通的参数而产生不同的变化,为什么是三个案例?
我们先看看方法体:
public View inflate(@LayoutRes int resource, @Nullable ViewGroup root, boolean attachToRoot)
这个方法体有三个参数,第一个参数就是我们引入的布局文件的resourceId,这个如果为空,那这个方法自然执行不下去了。第三个参数为boolean值,也就是说会有两种情况,是否将当前这个子view加入到父布局容器中去,而如果第二个参数,也就是我们说的父布局容器如果为空,那后面的布尔值不论是true还是false,结果都是一样的了,就是无家可归。
那么就会有三种状态:
1.父布局不为空,子布局添加到父布局中去。
2.父布局不为空,子布局不添加到父布局中去。
3.父布局为空,子布局添不添加到父布局中去已经不重要了。
现在我们根据这三种状态来分析各自的情况:
1.null!=root &&attachToRoot==true
这种情况下表示将resource指定的布局添加到root中去,添加的过程中所有子View相关的布局参数都是起作用的,你可能会问了,什么叫布局参数起作用?
举个简单的例子吧:
当你写一个布局文件中的相关组件都是需要给这个组件设定android:layout_width和android:layout_height,假如你将这两个属性都设置为match_parent,并且界面中只有这一个组件的话,那么这个组件就应当铺满整个布局,这就是layout_width和layout_height起作用了。
组件的布局属性想其作用还有一个关键的因素是其必须有父布局,如果没有父布局那么子View的宽和高将不可测量,你可能会问了,那我们的布局文件最外层的ViewGroup就没有父布局,布局参数不是也一样起作用了吗?那是因为我们每个Activity其实都包含了一个PhoneWindow,而PhoneWindow又包含了一个DecorView,DecorView又是由一个TitleView和一个ContentView组成的,而ContentView的最外层其实就是一个FrameLayout,所以其实每个布局文件都是有父布局的。
现在回到当前的引入方式,我们将会给要引入的布局文件加一个父布局,这样当这个组件存在父布局时,所有的布局参数都是起作用的。
但是当我们再将这个inflate产生的View放置到某个布局中去的话,系统就会报这个异常:
java.lang.IllegalStateException: The specified child already has a parent. You must call removeView() on the child's parent first.
这个异常其实就是说,当前这个子View已经有父View了,不可以重复添加了,这也印证了之前我们的对这个方法的理解。
2.null!=root &&attachToRoot==false
这个参数设置其实有点绕,简单来说就是,我又想让这个子布局的参数起作用,我又不想让这个子View只处于某一个容器中,那么我们就可以用这种方式来引入布局文件了。说白了就是root会协助子View来生成布局文件,但是仅仅是协助,这两者并没有什么直接关系。
所以当我们用这种方式引入布局文件的话,这个生成的子View这会其实是没有父布局的,必须用ViewGroup的add方法将其加载到某个父布局中去,该子View才能在界面中显示出来。
用代码来说就是:
View view = inflater.inflate(R.layout.linearlayout, null, false);
ll.addView(view);
同样的代码,放到第一个引入方式中就会报刚才指出的多次引入的错误,而放到这个引入方式中则不会了。
我在项目中用的也是这种引入方式,因为这种方式还是比较灵活,我可以将其引入到不通的布局界面中去,而不用局限在某个具体的父View下。
3.null==root
当第二个参数root为null时,不论第三个参数attachToRoot为true还是为false,其结果都是一样的,就是说我们不需要把子View引入到任何一个容器中去,并且没有任何一个容器来协助第一个参数的子View来生成布局参数,从而子View所设置的任何布局参数也是不会起作用了。
就算将此方法引入的子View再引入到任何父布局中去,其布局参数的信息也会如图1那样,不会起任何作用,只会跟着我这个布局文件中所存在的组件的宽高而动态的修改自己的宽和高。
通过上面的介绍,基本上也理解了inflate的方法相关的参数了,可见我在这里使用的是第二种引入方式,这种引入方式是应当可以让子View的布局参数起作用的,但是我的框架中,并没有起作用,所以问题不在这里,别急别急,我们不是更加深刻的理解了LayoutInflator的inflate方法了吗?
第二次分析
刚才的分析并没有解决我的问题,于是我又开始分析到底是哪里出了问题,当然,这是一个破案的过程,破案与否就看你掌握的线索多少了,我找到的线索是,包裹着StatusLayout外部的NestScrollView,这个突然让我警觉起来,因为之前在没有NestScrollView这个组件的时候我们都使用的是ScrollView这个组件,这个组件有一个不成文的坑:
那就是如果在ScrollView中的子View高度大于ScrollView的高度时,没有任何显示问题;但是如果子View的高度小于ScrollView的高度时,子View想要使用match_parent 属性则必须加入一个参数设置,那就是fillViewport=true。
鉴于上面这个坑,我琢磨着NestScrollView是不是也有同样的特性呢?
说的再多不如直接加上参数试试效果:
起作用了,看来问题出在了这里,于是很好奇这个fillViewPort到底是做什么的呢?其实他就是一个参数,我们通过阅读ScrollView的源码来查看:
public ScrollView(Context context) {
this(context, null);
}
public ScrollView(Context context, AttributeSet attrs) {
this(context, attrs, com.android.internal.R.attr.scrollViewStyle);
}
public ScrollView(Context context, AttributeSet attrs, int defStyleAttr) {
this(context, attrs, defStyleAttr, 0);
}
//调用第四个构造方法
public ScrollView(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
super(context, attrs, defStyleAttr, defStyleRes);
initScrollView();//初始化ScrollView的一些参数,后面会讲
final TypedArray a = context.obtainStyledAttributes(
attrs, com.android.internal.R.styleable.ScrollView, defStyleAttr, defStyleRes);//获取属性集对象
setFillViewport(a.getBoolean(R.styleable.ScrollView_fillViewport, false));//根据布局文件判断是否布局是否溢出当前窗口
a.recycle();//TypedArray对象回收
}
ScrollView的构造方法中有一句源码是
setFillViewport(a.getBoolean(R.styleable.ScrollView_fillViewport, false));
这句话的意思就是从布局文件中获取android:fillViewport属性是否为true,默认值是false,如果设置为true的话,我们看看ScrollView会做什么?
private boolean mFillViewport;
public void setFillViewport(boolean fillViewport) {
if (fillViewport != mFillViewport) {
mFillViewport = fillViewport;//当布局文件满足溢出情况的时候,fillViewport为true
requestLayout();//请求布局
}
}
这里就是判断一下fillViewport属性是否为true,并将新值赋值给mFillViewport这个类变量,然后开始执行requestLayout()方法:
@Override
public void requestLayout() {
mIsLayoutDirty = true;
super.requestLayout();
}
看到这里了解到该方法调用了父类的requestLayout方法,该方法就是View绘制时重新执行了onMeasure和onLayout方法,这里具体的就是ScrollView会重新执行onMeasure()方法时会重新确认控件的大小然后再确定自己的宽高,最后在执行onLayout(),这个方法是对所有的子控件进行定位。
通过onMeasure的源码可以看到:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {//溢出与否,就要通过之前我们的TypedArray里面的getBoolean里参数的布局文件来判断了。
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
if (!mFillViewport) {//如果mFillViewport为true,则子布局充满当前可见区域,宽高即不需要重新测量。
return;
}
final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
if (heightMode == MeasureSpec.UNSPECIFIED) {
return;
}
if (getChildCount() > 0) {
final View child = getChildAt(0);
final int widthPadding;
final int heightPadding;
final int targetSdkVersion = getContext().getApplicationInfo().targetSdkVersion;
final FrameLayout.LayoutParams lp = (LayoutParams) child.getLayoutParams();
if (targetSdkVersion >= VERSION_CODES.M) {
widthPadding = mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin;
heightPadding = mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin;
} else {
widthPadding = mPaddingLeft + mPaddingRight;
heightPadding = mPaddingTop + mPaddingBottom;
}
final int desiredHeight = getMeasuredHeight() - heightPadding;
if (child.getMeasuredHeight() < desiredHeight) {
final int childWidthMeasureSpec = getChildMeasureSpec(
widthMeasureSpec, widthPadding, lp.width);
final int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
desiredHeight, MeasureSpec.EXACTLY);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
}
}
从代码中可以看到,如果我们将mFillViewport设置为false的话将会直接return整个方法,下面的测量代码将不会执行。只有当mFillViewport设置为true时,才会根据子View的高度和ScrollView本身的高度决定是否重新测量子View使其充满ScrollView。
结语
至此我们终于知道了问题到底出在哪了,也确实明确了两个知识点:
1.动态引入的View如果没有父类将无法实现布局属性。
2.如果外层有ScrollView或者NestScrollView的话,如果是通用布局最好加上fillViewport属性。
参考资料
ScrollView源码分析
领悟自定义风采,ScrollView源码完全解析
三个案例带你看懂LayoutInflater中inflate方法两个参数和三个参数的区别
LayoutInflate.inflate()三个参数含义