前段时间做一个页面需求,就是经典的复杂嵌套,scrollview嵌套viewPager+fragment,其中fragment是一个recyclerView,虽然官方不建议这种页面嵌套,但这种页面布局在开发中是很常见的一种,此篇文章记录一下开发过程中页面中的各种嵌套问题,包括viewPager的高度自适应问题。
ScrollView嵌套ListView,是最基础的一种页面嵌套,会发现ScrollView嵌套ListView时候ListView内容只会显示一行,那么这是为什么呢?
从源码角度分析:
查看ScrollView源码:
在onMeasure()调用super.onMeasure(widthMeasureSpec, heightMeasureSpec),关于父类ViewGroup的onMeasure()方法如下:
for (int i = 0; i < count; i++) {
final View child = getChildAt(i);
if (mMeasureAllChildren || child.getVisibility() != GONE) {
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
maxWidth = Math.max(maxWidth,
child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin);
maxHeight = Math.max(maxHeight,
child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin);
childState = combineMeasuredStates(childState, child.getMeasuredState());
if (measureMatchParentChildren) {
if (lp.width == LayoutParams.MATCH_PARENT ||
lp.height == LayoutParams.MATCH_PARENT) {
mMatchParentChildren.add(child);
}
}
}
}
其中在父类ViewGroup中的measureChildWithMargins()方法中进行子View的测量,如下:
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);
}
会根据父元素的parentWidthMeasureSpec、parentHeightMeasureSpec的测量规格,得到子元素的childWidthMeasureSpec 、childHeightMeasureSpec 测量规格,在其过程中并未改变子元素的测量模式,而ScrollView重写了此方法,如下:
@Override
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 usedTotal = mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin +
heightUsed;
final int childHeightMeasureSpec = MeasureSpec.makeSafeMeasureSpec(
Math.max(0, MeasureSpec.getSize(parentHeightMeasureSpec) - usedTotal),
MeasureSpec.UNSPECIFIED);//UNSPECIFIED模式
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
其中在构建子元素的高度测量规格childHeightMeasureSpec 时,已经把子元素的测量模式设置成了UNSPECIFIED模式。
所以我们看下ListView的测量模式,查看源码,在ListView的onMeasure()方法中:
...
if (widthMode == MeasureSpec.UNSPECIFIED) {
widthSize = mListPadding.left + mListPadding.right + childWidth +
getVerticalScrollbarWidth();
} else {
widthSize |= (childState & MEASURED_STATE_MASK);
}
if (heightMode == MeasureSpec.UNSPECIFIED) {//UNSPECIFIED时显示一行的高度
heightSize = mListPadding.top + mListPadding.bottom + childHeight +
getVerticalFadingEdgeLength() * 2;
}
if (heightMode == MeasureSpec.AT_MOST) {
// TODO: after first layout we should maybe start at the first visible position, not 0
heightSize = measureHeightOfChildren(widthMeasureSpec, 0, NO_POSITION, heightSize, -1);
}
...
如果当高度的测量模式为UNSPECIFIED,此时的ListView的高度就是一行的高度,如果是AT_MOST的话会调用measureHeightOfChildren方法计算高度。
所以解决方法就是自定义listview,重写onMeasure()方法,改变其测试模式:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int newHeightMeasureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE>>2,
MeasureSpec.AT_MOST);
super.onMeasure(widthMeasureSpec, newHeightMeasureSpec);
}
此时疑问为什么设置高度为Integer.MAX_VALUE>>2呢?
因为在上述ListView的measureHeightOfChildren()方法中,如下:
...
for (i = startPosition; i <= endPosition; ++i) {
child = obtainView(i, isScrap);
measureScrapChild(child, i, widthMeasureSpec, maxHeight);
if (i > 0) {
// Count the divider for all but one child
returnedHeight += dividerHeight;
}
// Recycle the view before we possibly return from the method
if (recyle && recycleBin.shouldRecycleViewType(
((LayoutParams) child.getLayoutParams()).viewType)) {
recycleBin.addScrapView(child, -1);
}
returnedHeight += child.getMeasuredHeight();//累加每个item的高度
if (returnedHeight >= maxHeight) {//大于maxHeight
// We went over, figure out which height to return. If returnedHeight > maxHeight,
// then the i'th position did not fit completely.
return (disallowPartialChildPosition >= 0) // Disallowing is enabled (> -1)
&& (i > disallowPartialChildPosition) // We've past the min pos
&& (prevHeightWithoutPartialChild > 0) // We have a prev height
&& (returnedHeight != maxHeight) // i'th child did not fit completely
? prevHeightWithoutPartialChild
: maxHeight;
}
if ((disallowPartialChildPosition >= 0) && (i >= disallowPartialChildPosition)) {
prevHeightWithoutPartialChild = returnedHeight;
}
}
return returnedHeight;//返回listview的真实高度
...
设置为Integer.MAX_VALUE>>2的话,maxHeight为此值,当小于maxHeight的时候,就直接返回ListView的真实高度。
RecyclerView内容只显示了一行,因为RecyclerView高度问题,解决方法如下:
对于RecyclerView的LayoutManager设置自定义GridLayoutManager:
public class FullyGridLayoutManager extends GridLayoutManager {
public FullyGridLayoutManager(Context context, int spanCount) {
super(context, spanCount);
}
public FullyGridLayoutManager(Context context, int spanCount, int orientation,
boolean reverseLayout) {
super(context, spanCount, orientation, reverseLayout);
}
private int[] mMeasuredDimension = new int[2];
@Override
public void onMeasure(RecyclerView.Recycler recycler, RecyclerView.State state, int widthSpec,
int heightSpec) {
final int widthMode = View.MeasureSpec.getMode(widthSpec);
final int heightMode = View.MeasureSpec.getMode(heightSpec);
final int widthSize = View.MeasureSpec.getSize(widthSpec);
final int heightSize = View.MeasureSpec.getSize(heightSpec);
int width = 0;
int height = 0;
int count = getItemCount();
int span = getSpanCount();
for (int i = 0; i < count; i++) {
measureScrapChild(recycler, i,
View.MeasureSpec.makeMeasureSpec(i, View.MeasureSpec.UNSPECIFIED),
View.MeasureSpec.makeMeasureSpec(i, View.MeasureSpec.UNSPECIFIED), mMeasuredDimension);
if (getOrientation() == HORIZONTAL) {
if (i % span == 0) {
width = width + mMeasuredDimension[0];
}
if (i == 0) {
height = mMeasuredDimension[1];
}
} else {
if (i % span == 0) {
height = height + mMeasuredDimension[1];
}
if (i == 0) {
width = mMeasuredDimension[0] * getSpanCount();
}
}
}
switch (widthMode) {
case View.MeasureSpec.EXACTLY:
width = widthSize;
case View.MeasureSpec.AT_MOST:
case View.MeasureSpec.UNSPECIFIED:
}
switch (heightMode) {
case View.MeasureSpec.EXACTLY:
height = heightSize;
case View.MeasureSpec.AT_MOST:
case View.MeasureSpec.UNSPECIFIED:
}
setMeasuredDimension(width, height);
}
private void measureScrapChild(RecyclerView.Recycler recycler, int position, int widthSpec,
int heightSpec, int[] measuredDimension) {
if (position < getItemCount()) {
try {
View view = recycler.getViewForPosition(0);//fix 动态添加时报IndexOutOfBoundsException
if (view != null) {
RecyclerView.LayoutParams p = (RecyclerView.LayoutParams) view.getLayoutParams();
int childWidthSpec =
ViewGroup.getChildMeasureSpec(widthSpec, getPaddingLeft() + getPaddingRight(),
p.width);
int childHeightSpec =
ViewGroup.getChildMeasureSpec(heightSpec, getPaddingTop() + getPaddingBottom(),
p.height);
view.measure(childWidthSpec, childHeightSpec);
measuredDimension[0] = view.getMeasuredWidth() + p.leftMargin + p.rightMargin;
measuredDimension[1] = view.getMeasuredHeight() + p.bottomMargin + p.topMargin;
recycler.recycleView(view);
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
用于ScrollView嵌套grid状态下的RecyclerView完全显示。
Scrollview嵌套ViewPager+Fragment,其中每个Fragment嵌套了RecyclerView:
运行结果发现RecyclerView内容不显示,因为ScrollView嵌套ViewPager的高度问题,解决方法:
ViewPager需要使用自定义ViewPager,设置ViewPager高度,如下:
public class ViewPagerForScrollView extends ViewPager {
public ViewPagerForScrollView(Context context) {
super(context);
}
public ViewPagerForScrollView(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int height = 0;
//取最高的子View为ViewPager的高度
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
child.measure(widthMeasureSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED));
int h = child.getMeasuredHeight();
if (h > height) height = h;
}
heightMeasureSpec = MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY);
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
}
重新onMeasure方法,获取最高的子view高度为ViewPager的高度,解决ScrollView嵌套ViewPager时wrap_content属性不起作用,不能看到ViewPager内容问题。
此时如果几个ViewPager页面的高度不一样,那直接获取最高的子view高度为ViewPager的高度就会存在有的ViewPager子页面会显示一截白色空白页面,影响用户体验,所以需要实现ViewPager高度的自适应。
如下,自定义ViewPager:
public class AutoHeightViewPager extends ViewPager {
public AutoHeightViewPager(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// find the current child view
// and you must cache all the child view
// use setOffscreenPageLimit(adapter.getCount())
View view = getChildAt(getCurrentItem());
if (view != null) {
// measure the current child view with the specified measure spec
view.measure(widthMeasureSpec, heightMeasureSpec);
}
int height = measureHeight(heightMeasureSpec, view);
setMeasuredDimension(getMeasuredWidth(), height);
}
/**
* Determines the height of this view
*
* @param measureSpec A measureSpec packed into an int
* @param view the base view with already measured height
*
* @return The height of the view, honoring constraints from measureSpec
*/
private int measureHeight(int measureSpec, View view) {
int result = 0;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
if (specMode == MeasureSpec.EXACTLY) {
result = specSize;
} else {
if (view != null) {
result = view.getMeasuredHeight();
}
if (specMode == MeasureSpec.AT_MOST) {
result = Math.min(result, specSize);
}
}
return result;
}
}
此时再次运行页面可以正常显示,但点击tab切换Fragment页面时会出现白色闪过,考虑是动画问题,查看源码知通过调用mViewPager.setCurrentItem(tab.getPosition(), false);
可以屏蔽动画,解决上述问题。
现在基本达到了需求想要的效果,但发现设置默认显示的Fragment并未起作用,所以通过:
mViewPager.post(new Runnable() {
@Override
public void run() {
mViewPager.setCurrentItem(mPosition);
}
});
设置post,防止设置默认currentItem的Fragment不显示问题。
后续需求中需要禁止ViewPager的左右滑动问题,通过在自定义AutoHeightViewPager文件中添加以下代码实现:
private boolean isCanScroll = true;
/**
* 设置其是否能滑动换页
* @param isCanScroll false 不能换页, true 可以滑动换页
*/
public void setScanScroll(boolean isCanScroll) {
this.isCanScroll = isCanScroll;
}
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
return isCanScroll && super.onInterceptTouchEvent(ev);
}
@Override
public boolean onTouchEvent(MotionEvent ev) {
return isCanScroll && super.onTouchEvent(ev);
}
至此ViewPager+Fragment问题告一段落。
以上为遇到问题和解决方法,后续补充源码分析…