从View的测量去解决ScorllView嵌套ListView显示不全问题

1,028 阅读5分钟

ScorllView嵌套ListView会出现显示不完整,还有解决了显示不完整问题,却发现无法设置ListView高度。折腾了很久,我把ListView换成RecyclerView,发现没有出现这个问题,这让我很怀疑当时的折腾就是白搭。后来我在ScorllView里面多嵌套几个RecyclerView,也碰到了显示不全的问题,这让我想起了解决ListView显示不全的过程,于是我顺着当初的思路,解决了RecyclerView显示不全的问题。当初的折腾还是有意义的,所以写这篇文章来记录解决ListView问题的过程。

布局一:ScorllView只嵌套ListView

ScorllView只嵌套ListView,布局代码xml文件如下(为了讲解代码方便,暂时不去讨论这种嵌套方式是否具备应用价值。)

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".TestActivity">

    <ScrollView
        android:id="@+id/scrollView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" >
            <ListView
                android:id="@+id/listview"
                android:layout_width="match_parent"
                android:layout_height="match_parent">
            </ListView>
    </ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>

这样嵌套以后,ListView显示的只有一个Item,这就意味着ListView的高度测量有问题,所以先看ListView是如何测量高度的,也就是查看onMeasure()方法里面的代码。代码如下(文中只是粘贴了部分与本文有关的代码)。

@Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // Sets up mListPadding
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        final int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int childHeight = 0;
        mItemCount = mAdapter == null ? 0 : mAdapter.getCount();
        if (mItemCount > 0 && (widthMode == MeasureSpec.UNSPECIFIED
                || heightMode == MeasureSpec.UNSPECIFIED)) {//代码1
            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();//代码2
        }

        if (heightMode == MeasureSpec.UNSPECIFIED) {//代码3
            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);//代码4
        mWidthMeasureSpec = widthMeasureSpec;
    }

在代码里看到,模式为MeasureSpec.UNSPECIFIED时,高度是等于一个Item,刚好和遇到的问题一致,下面详细讲解。
代码1是一个if判断语句,如果Item的条目大于0并且高或宽的测量模式为UNSPECIFIED,那么就进入到这个方法。方法里面我们得到了它的一个item高度,也就是标注为代码2的地方。接下来我们看代码3,如果测量模式为UNSPECIFIED,那么ListView高度就是一个item的高度,代码4中传入的就是代码3中的heightSize。正常情况下item都是大于0的,只要父布局ScorllView给出的测量模式为UNSPECIFIED,判断的结果就是为ture(代码1处为true),就会进入该方法,这样出现问题的原因就能找到了。
想要知道ScorllView给出的测量模式,先看ScorllView里面的onMeasure()方法,没看到设置测量模式的代码。ScorllView继承自FrameLayout,所以就看FrameLayout里面的onMeasure(),发现在遍历子View的时候,会调用measureChildWithMargins()方法。代码如下

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

measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);,这个方法测量了子View,猜测是在这里设置了测量模式。顺着这个思路,发现ScorllView重写此方法,并且默认了高度的测量模式为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);//代码1

      child.measure(childWidthMeasureSpec, childHeightMeasureSpec);//代码2
  }  

从代码1中看到,childHeightMeasureSpec默认的是UNSPECIFIED,然后代码2传入的就是childHeightMeasureSpec,代码看到这里,也就找到了ScorllView嵌套ListView只显示1行的原因。因为正常情况下ListView的item是大于0的,而ScorllView默认的高度的测量模式是UNSPECIFIED,从onMeasure()方法的代码里可知,这种情况下,ListView的高度就是一个item高度。知道了原因,就找到了解决问题的方向。

解决方法

以上知道了ListView只显示一行,是因为ScorllView默认测量模式为UNSPECIFIED,只要我们重写ListView的onMeasure方法,并且把高的测量模式改为MeasureSpec.AT_MOST,就可以解决问题。

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

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

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

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

MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE>>2, View.MeasureSpec.AT_MOST)),返回一个int值(32位,前两位表示模式,后30位得到大小),包含大小和测量模式,Integer.MAX_VALUE>>2表示右移两位,表示大小,这样得到的是最大值。把ListView换成自定义的View,就解决了。由于现实的需求中,通常ScorllView不会只包含一个ListView,还会有一些其它的布局,接下来将讨论加入其它布局能不能解决问题。

布局二:加入TextView

在ScorllView中多加一个TextView,验证自定义的MyListView能不能解决问题。xml如下

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".TestActivity">

    <ScrollView
        android:id="@+id/scrollView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" >
        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical"
            >
            <TextView
                android:layout_width="match_parent"
                android:layout_height="200dp"
                android:text="内容一"
                android:gravity="center"
                android:background="@android:color/holo_orange_light"
                />
            <com.example.testslidingconflict.MyListView
                android:id="@+id/listview"
                android:layout_width="match_parent"
                android:layout_height="match_parent">
            </com.example.testslidingconflict.MyListView>
        </LinearLayout>
    </ScrollView>
</androidx.constraintlayout.widget.ConstraintLayout>  

运行以后,这样的布局也能显示正常。后来有个需求,MyListView的高度需要固定,于是我改一个高度,将高度设为300dp,发现并不起作用。后来一想,将模式改为MeasureSpec.AT_MOST,ListView加载的就是全部Item的高度,并且绘制出来,所以设置的高度就无效了。当时没有想到更好的解决方法,我只能将ListView换成RecyclerView,在这里也希望哪位大神指点一下,用ListView怎么解决这个问题。

以上就是当时解决ListView显示不全的所有过程。现在有RecyclerView和NestedScrollView等性能更好的控件提供给我们使用,这些控件本身已经解决了一些冲突。但是现实的应用场景中,还是会出现滑动冲突,显示不全等问题。虽然现在很少用ScorllView,ListView,但是当时的经历,让我碰到其它控件出现相同问题的时候,处理起来更得心应手。所有解决bug时的折腾,就是为你解决下一个类似的bug做准备,并非无用功。