ScrollView嵌套ListView为什么只能显示一个item?

1. ScrollView嵌套ListView,无论有多少个item都只会显示一个,为什么?

明明有多个item,但只显示一个,猜测view的测量出了问题。

1.1 查看ListView的onMeasure方法源码

查看ListView的onMeasure方法源码, 发现一段疑似问题所在的代码:

        if (heightMode == MeasureSpec.UNSPECIFIED) {
            heightSize = mListPadding.top + mListPadding.bottom + childHeight +
                    getVerticalFadingEdgeLength() * 2;
        }
复制代码

这里的childHeight就是一个item的高度。
那么什么时候走这个代码逻辑呢,也就是当heightModeMeasureSpec.UNSPECIFIED的时候走这里的代码逻辑。

什么时候heightModeMeasureSpec.UNSPECIFIED

MeasureSpec.UNSPECIFIED有另外两个兄弟模式 MeasureSpec.EXACTLYMeasureSpec.AT_MOST,他们各自代表的含义:

  • MeasureSpec.UNSPECIFIED 想要多大就多大,系统内部的测量,开发者很少需要使用
  • MeasureSpec.EXACTLY 布局设置宽高match_parent或具体数值
  • MeasureSpec.AT_MOST 布局设置宽高wrap_content

这就说明得到的测量模式MeasureSpec.UNSPECIFIED不是我们的xml布局导致的,我们日常开发只可能设置布局宽高wrap_content或match_parent或具体数值。那这里的MeasureSpec.UNSPECIFIED是怎么来的,平常使用ListView没问题,多嵌套一层ScrollView就出问题。ScrollView是一个ViewGroup,ViewGroup除了测量自己还要测量子View。ListView被ScrollView嵌套,作为ScrollView的子View显示。

1.2 查看ScrollView的onMeasure方法源码

看ScrollView实现的onMeasure方法源码,没发现什么猫腻,但是ScrollView在实现自己的代码前调用了super.onMeasure(widthMeasureSpec, heightMeasureSpec),点进去查看发现是FrameLayout的onMeasure方法

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    
        // count为1
        int count = getChildCount();

        final boolean measureMatchParentChildren =
                MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY ||
                MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY;
        mMatchParentChildren.clear();

        int maxHeight = 0;
        int maxWidth = 0;
        int childState = 0;

        // count为1,走这里
        for (int i = 0; i < count; i++) {
            final View child = getChildAt(i);
            if (mMeasureAllChildren || child.getVisibility() != GONE) {
            
                // 测量子view(ScrollView中的ListView)
                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);
                    }
                }
            }
        }

        ...

        count = mMatchParentChildren.size();
        
        // 因为ScrollView最多只能有一个子View, 所以这里count最多为1, 不会走这里
        if (count > 1) {
            ...
        }
    }
复制代码

在FrameLayout的onMeasure方法的for循环中发现调用measureChildWithMargins方法测量子view(也就是ScrollView中的ListView)。ScrollView是直接继承FrameLayout的,所以直接到ScrollView中查找measureChildWithMargins方法看看是否有覆写实现,有的话这里调用的measureChildWithMargins方法就是ScrollView中的measureChildWithMargins方法,还真有覆写

    @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);
    }
复制代码

这里可以明确看到获取childHeightMeasureSpec时给了MeasureSpec.UNSPECIFIED模式。如果还不确定可以继续查看MeasureSpec中makeSafeMeasureSpec静态方法的代码

        @UnsupportedAppUsage
        public static int makeSafeMeasureSpec(int size, int mode) {
            if (sUseZeroUnspecifiedMeasureSpec && mode == UNSPECIFIED) {
                return 0;
            }
            return makeMeasureSpec(size, mode);
        }
复制代码

MeasureSpec的makeSafeMeasureSpec方法return调用了makeMeasureSpec

        public static int makeMeasureSpec(
        @IntRange(from = 0, to = (1 << MeasureSpec.MODE_SHIFT) - 1) int size,
                                          @MeasureSpecMode int mode) {
            if (sUseBrokenMakeMeasureSpec) {
                return size + mode;
            } else {
                return (size & ~MODE_MASK) | (mode & MODE_MASK);
            }
        }
复制代码

同时我们也知道了MeasureSpec的格式是size + mode的int,这对我们后面解决ScrollView嵌套ListView的问题也是关键的一点。

2. ScrollView嵌套ListView,无论有多少个item都只会显示一个,怎么解决?

2.1 重写ListView覆写onMeasure方法

现在网上搜一搜都能搜到解决方案:

    public class ListViewOnScrollView extends ListView {

        ...

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

继承ListView覆写它的onMeasure方法重新获取onMeasure,替换原来的ListView使用就可以解决问题。

问题是解决了,但是别人为什么可以这么解决,为什么这样写?(习武之人不只关注输赢,更关心招式的精妙所在)

2.2 为什么这样解决?

为什么重写ListView的onMeasure方法应该没什么疑问,布局显示前都会经过测量,都会走onMeasure方法,大家更关注的是为什么在super.onMeasure(widthMeasureSpec, heightMeasureSpec)调用之前heightMeasureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);这行代码,为什么这样写就可以解决问题。

这就要再一次查看ListView的onMeasure代码

    @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();
        if (mItemCount > 0 && (widthMode == MeasureSpec.UNSPECIFIED
                || heightMode == MeasureSpec.UNSPECIFIED)) {
            final View child = obtainView(0, mIsScrap);

            // Lay out child directly against the parent measure spec so that
            // we can obtain exected minimum width and height.
            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);
            }
        }

        if (widthMode == MeasureSpec.UNSPECIFIED) {
            widthSize = mListPadding.left + mListPadding.right + childWidth +
                    getVerticalScrollbarWidth();
        } else {
            widthSize |= (childState & MEASURED_STATE_MASK);
        }

        if (heightMode == MeasureSpec.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);
        }

        setMeasuredDimension(widthSize, heightSize);

        mWidthMeasureSpec = widthMeasureSpec;
    }
复制代码

回顾之前我们的分析,首先确定的是这里得到的heightMode不能为MeasureSpec.UNSPECIFIED,那就应该让heightMode == MeasureSpec.AT_MOST的模式得到确定,从而执行measureHeightOfChildren方法

在这里,MeasureSpec.AT_MOST的模式得到确定

    public class ListViewOnScrollView extends ListView {
        ...
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            // 确定了传入参数 MeasureSpec.AT_MOST
            heightMeasureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        }
    }
复制代码

measureHeightOfChildren方法

    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;
        }

        // Include the padding of the list
        int returnedHeight = mListPadding.top + mListPadding.bottom;
        final int dividerHeight = mDividerHeight;
        // The previous height value that was less than maxHeight and contained
        // no partial children
        int prevHeightWithoutPartialChild = 0;
        int i;
        View child;

        // mItemCount - 1 since endPosition parameter is inclusive
        endPosition = (endPosition == NO_POSITION) ? adapter.getCount() - 1 : endPosition;
        final AbsListView.RecycleBin recycleBin = mRecycler;
        final boolean recyle = recycleOnMeasure();
        final boolean[] isScrap = mIsScrap;

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

            // 不让代码走这里
            if (returnedHeight >= 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;
            }
        }

        // At this point, we went through the range of children, and they each
        // completely fit, so return the returnedHeight
        return returnedHeight;
    }
复制代码

returnedHeight是测量得到的ListView里面所有item和分割线的高度值,要让measureHeightOfChildren方法return这个值就不能让returnedHeight >= maxHeight条件成立,这个maxHeight就是前面ListView的onMeasure方法调用传入的heightSize, int heightSize = MeasureSpec.getSize(heightMeasureSpec)heightMeasureSpec就是我们要处理的参数,所以为了保证returnedHeight >= maxHeight条件成立,直接让maxHeight取得最大值Integer.MAX_VALUE,所以处理这个问题的时候我们可以覆写ListView的onMeasure方法

    public class ListViewOnScrollView extends ListView {
        ...
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            // 确定了传入参数 MeasureSpec.AT_MOST
            // 也确定了传入参数 Integer.MAX_VALUE
            heightMeasureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        }
    }
复制代码

不对啊,你说确定了传入高度参数值Integer.MAX_VALUE,那这里怎么是Integer.MAX_VALUE >> 2移位运算右移2位,牛头不对马嘴?

这就要结合MeasureSpec额外分析了。
在前面分析为什么出现问题的时候我们看过MeasureSpec的makeMeasureSpec方法的代码

        public static int makeMeasureSpec(
        @IntRange(from = 0, to = (1 << MeasureSpec.MODE_SHIFT) - 1) int size,
                                          @MeasureSpecMode int mode) {
            if (sUseBrokenMakeMeasureSpec) {
                return size + mode;
            } else {
                return (size & ~MODE_MASK) | (mode & MODE_MASK);
            }
        }
复制代码

可以明确知道heightMeasureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);这个heightMeasureSpec包含了size和mode信息

MeasureSpec.UNSPECIFIED、MeasureSpec.EXACTLY、MeasureSpec.AT_MOST的参数值如下

    public static class MeasureSpec {
        private static final int MODE_SHIFT = 30;
        ...
        
        public static final int UNSPECIFIED = 0 << MODE_SHIFT;
        
        public static final int EXACTLY     = 1 << MODE_SHIFT;

        public static final int AT_MOST     = 2 << MODE_SHIFT;
        ...
    }
复制代码

比如MeasureSpec.AT_MOST的值就是2 << MODE_SHIFT,表示2的二进制值左移30位

在Java中int的存储范围是32位,0的二进制值是[前面30个0]00,1的二进制值是[前面30个0]01,2的二进制值是[前面30个0]10

2的二进制值左移30位就是10[后面30个0]Integer.MAX_VALUE >> 2即二进制右移2位,原先的第0位置和第1位置变成0,这样得到的

heightMeasureSpec = mode  size 
          二进制位置 0 1   2-31
复制代码

这下总算知道为什么解决ScrollView嵌套ListView只能显示一个item的问题是下面这样子处理了吧

    public class ListViewOnScrollView extends ListView {
        ...
        @Override
        protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
            heightMeasureSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST);
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        }
    }
复制代码
© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享