從原始碼角度分析SrcollView巢狀ListView顯示不全的問題

sun_xin發表於2017-12-14

從原始碼角度分析SrcollView巢狀ListView顯示不全的問題

Sunxin's Bolg Sunxin's Github

問題描述

在之前開發的時候會碰到列表滑動佈局中ScrollView巢狀ListView的情況,當巢狀了之後發現ListView只能顯示一行資料。碰到這種情況也是

真讓人頭大

於是乎開始Google,很快就找到解決方法,自定義view繼承自ListView,重寫onMeasure()方法,然後再加入一行程式碼即可解決問題。

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

複製程式碼

But... why?

真讓人摸不著頭腦

問題分析

首先應該可以想到,ScrollView巢狀ListView,ListView相當於ScrollView的子View了,我們應該去看看父View如何去測量子View的,開啟原始碼先看ScrollView的onMeasure方法。

    
@Override
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方法,點進去看看做了什麼,ScrollView的父類是FrameLayout

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

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

複製程式碼

看名稱是測量Child的,跟進,發現進入了ViewGroup裡面。

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

複製程式碼

一眼望去,感覺應該在裡面計運算元View的寬高,並且把padding值和margin值都算進去了。不管那麼多了,ViewGroup是ScrollView的爺爺了吧,ScrollView裡面應該會重寫這個方法,因為ScrollView裡面也能包裹其他的View,跟進。

@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被ScrollView包裹,它的寬高應該是在這裡被計算的,看看到底為啥只有一個條目的高度。看到這裡

final int childHeightMeasureSpec = MeasureSpec.makeSafeMeasureSpec(
            Math.max(0, MeasureSpec.getSize(parentHeightMeasureSpec) - usedTotal),
            MeasureSpec.UNSPECIFIED);

複製程式碼

這裡就是計算childHeight的地方,到這裡我們就應該去了解一下MeasureSpec了. MeasureSpec: 從字面意思翻譯像是測量規格,它在很大程度上決定了一個View的尺寸規則。實際上MeasureSpec 是一個32位的int值,高2位代表SpecMode(測量模式),低30位代表SpecSize(某種測量模式下的規格大小)。重點就在於這個SpecMode

SpecMode: 測量模式有3種

  • UNSPECFIED: 父容器不對View有任何限制,要多大給多大。這種情況一般用於系統內部,表示一種測量狀態。
  • EXACTLY: 父容器已經檢測出View所需要的精確大小,這個時候View的最終大小就是SpecSize所指定的值。它對應於LayoutParams中的match_parent和具體的數值這兩種模式。
  • AT_MOST: 父容器指定了一個可用大小即SpecSize,View的大小不能大於這個值,具體是什麼值要看不同View的具體實現。它對應於LayoutParams中的wrap_content。

private static final int MODE_SHIFT = 30;
private static final int MODE_MASK  = 0x3 << MODE_SHIFT;

/** @hide */
@IntDef({UNSPECIFIED, EXACTLY, AT_MOST})
@Retention(RetentionPolicy.SOURCE)
public @interface MeasureSpecMode {}

/**
 * Measure specification mode: The parent has not imposed any constraint
 * on the child. It can be whatever size it wants.
 */
public static final int UNSPECIFIED = 0 << MODE_SHIFT;

/**
 * Measure specification mode: The parent has determined an exact size
 * for the child. The child is going to be given those bounds regardless
 * of how big it wants to be.
 */
public static final int EXACTLY     = 1 << MODE_SHIFT;

/**
 * Measure specification mode: The child can be as large as it wants up
 * to the specified size.
 */
public static final int AT_MOST     = 2 << MODE_SHIFT;

複製程式碼

在瞭解了測量模式之後,在看這段程式碼

final int childHeightMeasureSpec = MeasureSpec.makeSafeMeasureSpec(
            Math.max(0, MeasureSpec.getSize(parentHeightMeasureSpec) - usedTotal),
            MeasureSpec.UNSPECIFIED);

複製程式碼

在這裡ScrollView對子View的高的測量模式都制定成了MeasureSpec.UNSPECIFIED,也就是說,ListView要多高就給多高,這時候我們就可以去看看ListView到底要了多高。原始碼轉入ListView,看看onMesaure()方法。

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

複製程式碼

看到這一段程式碼

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

複製程式碼

當測量模式是MeasureSpec.UNSPECIFIED的時候,我們只看到了 heightSize = ** + childHeight **..,oh my god, 難道ListView只向他爸爸要了一個孩子的高度,這就可以解釋清楚了為什麼ScrollView巢狀ListView只顯示一行的高度了。

問題解決

解決問題的方法在開頭已經給出了,但是為什麼要這麼做呢。

我們在觀察ListView的onMesaure方法的時候發現,當heightMode == MeasureSpec.UNSPECIFIED時,ListView只測量了一個child的高度,下面看看當heightMode == 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);
}

複製程式碼

看這個方法貌似又去測量每個孩子的高度,然後賦值給heightSize了,看看方法的具體實現。

/**
 * Measures the height of the given range of children (inclusive) and
 * returns the height with this ListView's padding and divider heights
 * included. If maxHeight is provided, the measuring will stop when the
 * current height reaches maxHeight.
 *
 * @return The height of this ListView with the given children.
 */
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;
}

複製程式碼

從註釋我們就可以看出,這個方法return的是The height of this ListView with the given children. 也就是ListView包含孩子的高度。看程式碼中

returnedHeight += child.getMeasuredHeight();
複製程式碼

returnHeight也是在不斷的累加每個孩子的高度,所以最終會得到ListView的真實高度。

所以,現在的問題就是如何指定ListView的MeasureSpec.SpecMode == MeasureSpec.AT_MOST 再來看開頭的解決方法

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

複製程式碼

makeMeasureSpec方法就能重新指定測量規則,第一個引數提供一個30位的SpecSize,第二個引數提供一個2位的SpecMode,然後該方法將其合併成一個新的MeasureSpec.

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

複製程式碼

走完這個方法之後在呼叫父類的也就是ListView的onMeasure方法,重新獲取測量模式和測量尺寸,這次拿到的測量模式就是AT_MOST,就可以去測量每個孩子的高度累加了。

問題 為什麼要使用Integer.MAX_VALUE

這是整型的最大值,很大很大有32位,我們在上面 measureHeightOfChildren這個方法的時候,注意一段程式碼

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


複製程式碼

當returnedHeight >= maxHeight的時候,它會返回一個我們並不想要的值,我們需要的是returnedHeight,所以我們不能讓這個判斷成立,就選擇了整型最大值。這個maxHeight追溯一下,就是我們設定的Integer.MAX_VALUE >> 2, 右移兩位就剩下30位剛好和2位的模式配對。

結束語

此致 敬禮

從原始碼角度分析SrcollView巢狀ListView顯示不全的問題

相關文章