在Google提供的控件中,有一系列支持触摸滚动的原生控件:ScrollView、ListView、GridView。尽管Google并不推荐我们将这些可滚动的控件进行嵌套使用,但是难免会有业务要求。这时通常会遇到两种问题:

  1. 当设置内层的控件的高度为match_parent或者wrap_content时,高度不正常
  2. 触摸事件冲突 对于触摸事件的冲突可以参考我之前对于View中触摸事件的分发的分析来进行解决ANDROID 触摸事件分发源码。这里我们来分析一下为何会出现高度不正常。

先来解决问题

先来看看如何解决ScrollView嵌套ListView的高度问题吧。

通常常用的有两种方法:

  1. 在ListView设置Adapter之后,获取每一个View,测量每一个View的高度从而得到ListView总的高度,再手动设置
  2. 重写ListView的onMeasure方法

第一种方法我们这里暂且不谈,其实就是通过ListAdapter.getView()获取相应的View对象再进行测量。相对比较简单,可以去网上自行查找

这里我们来看看第二种方法:

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

    int expandSpec = MeasureSpec.makeMeasureSpec(Integer.Max >> 2,
            , MeasureSpec.AT_MOST);
    super.onMeasure(widthMeasureSpec, expandSpec);
}

通过重写onMeasure()方法后就可以让ListView的高度恢复正常了

onMeasure 源码分析

每个View的绘制过程都会经历measure()->layout()->draw()这样一个流程,在measure()方法中我们对View的宽高进行测量

/*
 * The actual measurement work of a view is performed in onMeasure(int, int), called by this method.
 * Therefore, only onMeasure(int, int) can and must be overridden by subclasses.  
 */
public final void measure(int widthMeasureSpec, int heightMeasureSpec) {

    // 省略
    onMeasure(widthMeasureSpec, heightMeasureSpec);
    // 省略
}

可以看到measure()方法是一个final方法,表明子类无法去重写该方法的。在其中调用了onMeasure()方法,在这个方法中完成实际的测量工作,

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
            getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}

onMeasure()方法中调用了setMeasuredDimension(),从方法名就可以看出来这是将测量后的宽高值设置给当前的View的方法

getSuggestedMInimumWidth()中,会判断View是否添加了background。如果没有设置background,会返回android:minWidth属性下设置的值;否则返回的就是android:minWidth和background的较大值

而在getDefaultSize()中,则是根据父控件传递下来的MeasureSpec和getSuggestedMinimumWidth()方法返回的值计算出最终的测量值

此外ViewGroup的测量过程是measureChildren()->measureChild()->child.measure(),依次向下遍历,将所有的子控件进行测量。感兴趣的可以自行查看ViewGroup的源码

MeasureSpec

onMeasure(int widthMeasureSpec, int heightMeasureSpec)中我们对View的宽高进行了测量,那么究竟是如何进行测量的呢?从两个参数名可以看出来,肯定是与测量宽高有关的两个int值。

这里就涉及到View的一个静态内部类 MeasureSpec 了,先来看一看源码:

public static class MeasureSpec {
    private static final int MODE_SHIFT = 30;
    private static final int MODE_MASK  = 0x3 << MODE_SHIFT;

    // MeasureSpec的三种模式
    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; // 确定最大值

    // 通过Mode和Sie去生成MeasureSpec对象
    public static int makeMeasureSpec(int size, int mode) {
        if (sUseBrokenMakeMeasureSpec) {
            return size + mode;
        } else {
            return (size & ~MODE_MASK) | (mode & MODE_MASK);
        }
    }

    public static int makeSafeMeasureSpec(int size, int mode) {
        if (sUseZeroUnspecifiedMeasureSpec && mode == UNSPECIFIED) {<!
        return makeMeasureSpec(size, mode);
    }

    // 获取Mode
    public static int getMode(int measureSpec) {
        return (measureSpec & MODE_MASK);
    }

    // 获取Size
    public static int getSize(int measureSpec) {
        return (measureSpec & ~MODE_MASK);
    }

    static int adjust(int measureSpec, int delta) {
        final int mode = getMode(measureSpec);
        int size = getSize(measureSpec);
        if (mode == UNSPECIFIED) {
            // No need to adjust size for UNSPECIFIED mode.
            return makeMeasureSpec(size, UNSPECIFIED);
        }
        size += delta;
        if (size < 0) {
            Log.e(VIEW_LOG_TAG, "MeasureSpec.adjust: new size would be negative! (" + size +
                    ") spec: " + toString(measureSpec) + " delta: " + delta);
            size = 0;
        }
        return makeMeasureSpec(size, mode);
    }

    public static String toString(int measureSpec) {
        /*
         * 省略
         */
    }
}

MeasureSpec代表了测量的标准,实际上是一个32位的二进制的值,其中30~31位代表了测量的模式:UNSPECIFIED(0x00)、EXACTLY(0x01)和AT_MOST(0x02),而0~29位封装了测量的大小

此外,在MeasureSpec的makeMeasureSpec()方法中,通过传入代表模式和大小的值,通过位运算我们就可以得到指定的MeasureSpec对象了

那么每个View在调用onMeasure(int widthMeasureSpec, int heightMeasureSpec)时,方法中的参数时哪里的来的呢?我们已经知道了,触摸事件的分发是由根节点的ViewGroup依次向下传递,其实View的测量也是这样由根节点依次测量子控件(也就是上面提到的ViewGroup的测量过程),而作为参数传递的MeasureSpec就是由父控件传来的

父控件属性 子控件属性 子控件的MeasureSpec
EXACTLY+size dp EXACTLY+dp
match_parent EXACTLY+size
wrap_content AT_MOST+size
AT_MOST+size dp EXACTLY+dp
match_parent AT_MOST+size
wrap_content AT_MOST+size
UNSPECIFIED+size dp EXACTLY+dp
match_parent UPSPECIFIED+0
wrap_content UPSPECIFIED+0

所以最终的MeasureSpec是由父控件的测量时得到的MeasureSpec与当前View的属性来决定的。这也是我们之前在LayoutInflater填充XML布局文件时留下的问题,为什么必须要通过父控件才能使当前View的LayoutParams生效,原因也在这里了。

解决高度的原理

回过头来再看看我们一开始解决ListView高度的解决方式,在重写的onMeasure()方法中,我们通过MeasureSpec.makeMeasureSpec()方法,手动的生成了一个AT_MOST+size的 MeasureSpec。这样就避免了系统由于两层可滚动的控件嵌套导致的测量错误。当然,这里的size使用的是Integer.MAX >> 2,获得的值是远远大于屏幕的高度的,所以最终显示的高度还是只有屏幕高度这样

如果我们使用Debug工具在onMeasure()中获取父控件传递过来的MeasureSpec对象,会发现其实这时候的heightMeasureSpec的值是0,也就是MODE为UNSPECIFIED,size为0。既然是这样为什么还是可以显示ListView中一个element的高度呢?

原来在ListView的onMeasure()方法中,对最低的高度进行了计算:

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

在不设置padding的情况下,默认得到的高度正好就是一个element的高度。这就是我们不重写默认显示的情况了