ScrollView嵌套ListView问题解析

前言

在MD设计出来之前ScrollView嵌套ListView是很常见的用来处理嵌套滑动解决方案,但是直接将ListView放入到ScrollView它只会展示一行,无法正常显示出一屏幕的的条目。现在通过阅读ScrollView和ListView的源码来查看这个问题出现的原因。

代码分析

我们知道View对象都需要经历measure、layout和draw三个阶段才能够展示出来,而ListView只展示一行说明在measure阶段出现了问题,直接查看ScrollView的源码查看它的onMeasure方法实现。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);

    if (!mFillViewport) {
        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);
        }
    }
}

在onMeasure方法中首先调用了super.onMeasure,ScrollView继承自FrameLayout布局其实也就是调用了FrameLayout的onMeasure方法实现。接着查看FrameLayout布局的onMeasure方法实现,在这里FrameLayout遍历它的所有子控件并且使用measureChildWithMargins方法来测量子控件的大小。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    ...
    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);
                }
            }
        }
    }

}

FrameLayout的measureChildWithMargins在ScrollView中被覆盖,所以需要去查看ScrollView里面的实现代码,注意这里的childHeightMeasureSpec使用的是MeasureSpec.UNSPECIFIED方法,也就是说由子控件自己负责测量自己的高度,ListView在onMeasure方法中收到的高度测量类型是MeasureSpec.UNSPECIFIED。

@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);

    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

接下来查看ListView的onMeasure方法,在MeasureSpec.UNSPECIFIED情况下会如何测量自己的高度值,

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    // Sets up mListPadding
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);

    final int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);

    int childWidth = 0;
    int childHeight = 0;
    int childState = 0;

    mItemCount = mAdapter == null ? 0 : mAdapter.getCount();
    // heightMode == MeasureSpec.UNSPECIFIED匹配,会调用if块里的逻辑
    if (mItemCount > 0 && (widthMode == MeasureSpec.UNSPECIFIED
            || heightMode == MeasureSpec.UNSPECIFIED)) {
        // 随便取出一个子View测量它的高度    
        final View child = obtainView(0, mIsScrap);
        measureScrapChild(child, 0, widthMeasureSpec, heightSize);

        childWidth = child.getMeasuredWidth();
        childHeight = child.getMeasuredHeight();
        childState = combineMeasuredStates(childState, child.getMeasuredState());

        if (recycleOnMeasure() && mRecycler.shouldRecycleViewType(
                ((LayoutParams) child.getLayoutParams()).viewType)) {
            mRecycler.addScrapView(child, 0);
        }
    }

    ...
    // 如果高度是MeasureSpec.UNSPECIFIED
    if (heightMode == MeasureSpec.UNSPECIFIED) {
        heightSize = mListPadding.top + mListPadding.bottom + childHeight +
                getVerticalFadingEdgeLength() * 2;
    }

    // 如果高度是MeasureSpec.AT_MOST
    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);
    }

    setMeasuredDimension(widthSize, heightSize);

    mWidthMeasureSpec = widthMeasureSpec;
}

可以看到在高度是MeasureSpec.UNSPECIFIED情况下ListView会直接测量一个child的高度,然后在加上上下padding和边沿高度值,最终作为当前ListView的实际测量高度,这也就是为什么默认情况下ScrollView内嵌的ListView只会展示一条数据的高度。

解决方案

目前网上主要有两种解决方案,一种是开发者自己手动计算ListView中所有内容的高度之和并且在代码中设置给内嵌的ListView,另一种是自定义一个ListView并且设置在onMeasure之前为ListView设置MeasureSpec.AT_MOST。

手动计算高度

如果我们手动的计算当前ListView所有内容条目的高度,那么就可以设置ListView的高度类型为MeasureSpec.EXACTLY,上面的ListView布局里的逻辑都不会执行,最终设置的高度就是我们自己测量的高度,也就保证了ListView正确的展示在ScrollView当中。

public void setListViewHeightBasedOnChildren(ListView listView) {
    // 获取ListView对应的Adapter
    ListAdapter listAdapter = listView.getAdapter();
    if (listAdapter == null) {
        return;
    }
    int totalHeight = 0;
    for (int i = 0, len = listAdapter.getCount(); i < len; i++) {
        // listAdapter.getCount()返回数据项的数目
        View listItem = listAdapter.getView(i,null, listView);
        // 计算子项View 的宽高
        listItem.measure(0, 0);
        // 统计所有子项的总高度
        totalHeight += listItem.getMeasuredHeight();
    }
    ViewGroup.LayoutParams params = listView.getLayoutParams();
    params.height = totalHeight + (listView.getDividerHeight() *
            (listAdapter.getCount() - 1));
    // listView.getDividerHeight()获取子项间分隔符占用的高度
    // params.height最后得到整个ListView完整显示需要的高度
    listView.setLayoutParams(params);
    listView.invalidate();
}

自定义ListView

还有一种就是自定义ListView使用的是设置ListView为MeasureSpec.AT_MOST,而且它的高度值是一个非常大的数字,这样也可以将ListView的内容全部展示出来。为什么会这样呢,继续查看前面的ListView的onMeasure方法,发现在MeasureSpec.AT_MOST情况下它会调用measureHeightOfChildren方法来计算自己的高度。

public class WrapContentListView extends ListView{
    public WrapContentListView(Context context) {
        super(context);
    }

    public WrapContentListView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public WrapContentListView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }


    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int expandSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2,MeasureSpec.AT_MOST);
        super.onMeasure(widthMeasureSpec, expandSpec);
    }
}

查看measureHeightOfChildren方法的实现它会从0到endPosition,也就是adapter.getCount() - 1最后一条数据所有的数据高度全部计算出来相加到一起,将这个值作为测量出来的ListView实际高度。

 final int measureHeightOfChildren(int widthMeasureSpec, int startPosition, int endPosition,
            int maxHeight, int disallowPartialChildPosition) {
    final ListAdapter adapter = mAdapter;
    if (adapter == null) {
        return mListPadding.top + mListPadding.bottom;
    }

    int returnedHeight = mListPadding.top + mListPadding.bottom;
    final int dividerHeight = mDividerHeight;
    int prevHeightWithoutPartialChild = 0;
    int i;
    View child;

    endPosition = (endPosition == NO_POSITION) ? adapter.getCount() - 1 : endPosition;
    for (i = startPosition; i <= endPosition; ++i) {
        child = obtainView(i, isScrap);

        measureScrapChild(child, i, widthMeasureSpec, maxHeight);

        if (i > 0) {
            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();

        // 由于maxHeight非常大,不会从这里退出
        if (returnedHeight >= maxHeight) {
            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;
        }
    }

    // 返回所有ListView条目计算的总高度
    return returnedHeight;
}

总结

ScrollView嵌套ListView只展示一行主要是因为ScrollView在测量ListView会传递MeasureSpec.UNSPECIFIED类型的测量方式,ListView在MeasureSpec.UNSPECIFIED情况下只会取一条数据高度作为总高度。解决方法是开发者手动测量ListView总高度并且设置给ListView或者设置ListView默认测量方式为MeasureSpec.AT_MOST并提供一个很大的高度值,这样ListView就会自己测量内部所有数据的总高度值。

猜你喜欢

转载自blog.csdn.net/xingzhong128/article/details/81359861