Android 深入探究自定義view之流式佈局FlowLayout的使用

引子

文章開始前思考個問題,view到底是如何擺放到屏幕上的?在xml佈局中,我們可能用到match_parent、wrap_content或是具體的值,那我們如何轉為具體的dp?對於層層嵌套的佈局,他們用的都不是具體的dp,我們又該如何確定它們的尺寸?

下圖是實現效果

在這裡插入圖片描述

自定義View的流程

想想自定義view我們都要做哪些事情

  • 佈局,我們要確定view的尺寸以及要擺放的位置,也就是 onMeasure() 、onLayout() 兩方法
  • 顯示,佈局之後是怎麼把它顯示出來,主要用的是onDraw,可能用到 :canvas paint matrix clip rect animation path(貝塞爾) line
  • 交互,onTouchEvent

本文要做的是流式佈局,繼承自ViewGroup,主要實現函數是onMeasure() 、onLayout() 。下圖是流程圖

在這裡插入圖片描述

onMeasure

onMeasure是測量方法,那測量的是什麼?我們不是在xml已經寫好view的尺寸瞭嗎,為什麼還要測量?

有這麼幾個問題,我們在xml寫view的時候確實寫瞭view的width與height,但那玩意是具體的dp嗎?我們可能寫的是match_parent,可能是wrap_content,可能是權重,可能是具體的長度。對於不是確定值的我們要給它什麼尺寸?哪怕寫的是確定值就一定能給它嗎,如果父佈局最大寬度是100dp,子佈局寫的是200dp咋辦?對於多層級的view,我們隻是調用本個view的onMeasure方法嗎?

以下面的圖為栗子

在這裡插入圖片描述

如果上圖圈紅的View就是我們要自定義的view,我們該怎麼測量它?

  • 首先我們要知道它的父佈局能給它多大的空間
  • 對於容器類型的view,根據其所有子view需要的空間計算出view所需的尺寸

首先要明確一點:測量是自上而下遞歸的過程!以FlowLayout的高度舉例,它的height要多少合適?根據佈局的擺放逐個測量每行的高度得出其所需的height,這個測出的高度再根據父佈局給出的參考做計算,最後得到真正的高度。在測量子view的時候,子view可能也是容器,其內部也有很多view,其本身的不確定性需要遍歷其子佈局,這是一個遞歸的過程!

下面開始我們的測量過程,假設FlowLayout的父佈局是LinearLayout,整體UI佈局如下

在這裡插入圖片描述

LinearLayout給它的空間有多大,還記得onMeasure的兩個參數嘛,這倆是父佈局給的參考值,也是父佈局對其約束限制

 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)

MeasureSpec由兩部分構成,高2位表示mode,低30位表示size;父佈局給的參考寬高

 int selfWidth = MeasureSpec.getSize(widthMeasureSpec);
 int selfHeight = MeasureSpec.getSize(heightMeasureSpec);

由此我們可以得到父佈局給的空間大小,也就是FlowLayout的最大空間。那我們實際需要多大空間呢,我們需要測量所以的子view。

子view的擺放邏輯:

  • 本行能放下則放到本行,即滿足條件 lineUsed + childWidthMeasured + mHorizontalSpacing < selfWidth
  • 本行放不下則另起一行

擺放邏輯有瞭,怎麼測量子view

  • 獲得子view的LayoutParams從而獲得xml裡設置的layout_width與layout_height
  • 調用getChildMeasureSpec方法算出MeasureSpec
  • 子view調用measure方法測量
//	獲得LayoutParams
LayoutParams childParams = childView.getLayoutParams();
//	計算measureSpec
int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec, parentLeft + parentRight, childParams.width);
int childHeightMeasureSpec = getChildMeasureSpec(widthMeasureSpec, parentTop + parentBottom, childParams.height);
//	測量
childView.measure(childWidthMeasureSpec, childHeightMeasureSpec);

下面是 getChildMeasureSpec 內部實現,以橫向尺寸為例

	//	以橫向尺寸為例,第一個參數是父佈局給的spec,第二個參數是扣除自己使用的尺寸,第三個是layoutParams
    public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
        int specMode = MeasureSpec.getMode(spec);
        int specSize = MeasureSpec.getSize(spec);

        int size = Math.max(0, specSize - padding);

        int resultSize = 0;
        int resultMode = 0;

        switch (specMode) {
        // Parent has imposed an exact size on us
        //	老王的錢是確定的,小王有三種可能
        case MeasureSpec.EXACTLY:
            if (childDimension >= 0) {
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size. So be it.
                resultSize = size;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size. It can't be
                // bigger than us.
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;

        // Parent has imposed a maximum size on us
        //	老王的錢最多有多少,小王有三種可能
        case MeasureSpec.AT_MOST:
            if (childDimension >= 0) {
                // Child wants a specific size... so be it
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size, but our size is not fixed.
                // Constrain child to not be bigger than us.
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size. It can't be
                // bigger than us.
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;

        // Parent asked to see how big we want to be
        //	老王的錢不確定,小王有三種可能
        case MeasureSpec.UNSPECIFIED:
            if (childDimension >= 0) {
                // Child wants a specific size... let him have it
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size... find out how big it should
                // be
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size.... find out how
                // big it should be
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            }
            break;
        }
        //noinspection ResourceType
        return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
    }

上面的算法其實很簡單,根據父佈局給的mode和size結合自身的尺寸算出自己的mode和size,具體規則如下

在這裡插入圖片描述

算法的實現是父佈局有三種可能,子佈局三種可能,總共9種可能。就像下面的小王想跟老王借錢買房,有幾種可能?

在這裡插入圖片描述

測量完子view後怎麼確定佈局的大小?

  • 測量所有行,得到最大的值作為佈局的width
  • 測量所有行的高度,高度的總和是佈局的height
  • 調用 setMeasuredDimension 函數設置最終的尺寸

onLayout

基於測量工作我們基本確定瞭所有子view的擺放位置,這階段要做的就是把所有的view擺放上去,調用子view的layout函數即可

具體代碼實現

public class FlowLayout extends ViewGroup {

    private int mHorizontalSpacing = dp2px(16); //每個item橫向間距
    private int mVerticalSpacing = dp2px(8); //每個item橫向間距

    //  記錄所有的行
    private List<List<View>> allLines = new ArrayList<>();
    //  記錄所有的行高
    private List<Integer> lineHeights = new ArrayList<>();

    /**
     *      new FlowLayout(context) 的時候用
     * @param context
     */
    public FlowLayout(Context context) {
        super(context);
    }

    /**
     *  xml是序列化格式,裡面都是鍵值對;所有的都在LayoutInflater解析
     *反射
     *
     * @param context
     * @param attrs
     */
    public FlowLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    /**
     *      主題style
     * @param context
     * @param attrs
     * @param defStyleAttr
     */
    public FlowLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    /**
     *      自定義屬性
     * @param context
     * @param attrs
     * @param defStyleAttr
     * @param defStyleRes
     */
    public FlowLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {
        super(context, attrs, defStyleAttr, defStyleRes);
    }


    /**
     *      onMeasure 可能會被調用多次
     */
    private void clearMeasureParams() {
        //  不斷創建回收會造成內存抖動,clear即可
        allLines.clear();
        lineHeights.clear();
    }

    /**
     *      度量---大部分是先測量孩子再測量自己。孩子的大小可能是一直在變的,父佈局隨之改變
     *      隻有ViewPager是先測量自己再測量孩子
     *      spec 是一個參考值,不是一個具體的值
     * @param widthMeasureSpec      父佈局給的。這是個遞歸的過程
     * @param heightMeasureSpec     父佈局給的
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

        clearMeasureParams();

        //  先測量孩子
        int childCount = getChildCount();

        int parentTop = getPaddingTop();
        int parentLeft = getPaddingLeft();
        int parentRight = getPaddingRight();
        int parentBottom = getPaddingBottom();

        //  爺爺給的參考值
        int selfWidth = MeasureSpec.getSize(widthMeasureSpec);
        int selfHeight = MeasureSpec.getSize(heightMeasureSpec);

        //  保存一行所有的 view
        List<View> lineViews = new ArrayList<>();
        //  記錄這行已使用多寬 size
        int lineWidthUsed = 0;
        //  一行的高
        int lineHeight = 0;

        //  measure過程中,子view要求的父佈局寬高
        int parentNeedWidth = 0;
        int parentNeedHeight = 0;

        for (int i = 0; i < childCount; i++) {
            View childView = getChildAt(i);

            LayoutParams childParams = childView.getLayoutParams();
            //  將LayoutParams轉為measureSpec
            /**
             *      測量是個遞歸的過程,測量子View確定自身大小
             *      getChildMeasureSpec的三個參數,第一個是父佈局傳過來的MeasureSpec,第二個參數是去除自身用掉的padding,第三個是子佈局需要的寬度或高度
             */
            int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec, parentLeft + parentRight, childParams.width);
            int childHeightMeasureSpec = getChildMeasureSpec(widthMeasureSpec, parentTop + parentBottom, childParams.height);

            childView.measure(childWidthMeasureSpec, childHeightMeasureSpec);

            //  獲取子View測量的寬高
            int childMeasuredWidth = childView.getMeasuredWidth();
            int childMeasuredHeight = childView.getMeasuredHeight();

            //  需要換行
            if (childMeasuredWidth + lineWidthUsed + mHorizontalSpacing > selfWidth) {

                //  換行時確定當前需要的寬高
                parentNeedHeight = parentNeedHeight + lineHeight + mVerticalSpacing;
                parentNeedWidth = Math.max(parentNeedWidth, lineWidthUsed + mHorizontalSpacing);

                //  存儲每一行的數據 !!! 最後一行會被漏掉
                allLines.add(lineViews);
                lineHeights.add(lineHeight);

                //  數據清空
                lineViews = new ArrayList<>();
                lineWidthUsed = 0;
                lineHeight = 0;
            }

            lineViews.add(childView);
            lineWidthUsed = lineWidthUsed + childMeasuredWidth + mHorizontalSpacing;
            lineHeight = Math.max(lineHeight, childMeasuredHeight);

            //處理最後一行數據
            if (i == childCount - 1) {
                allLines.add(lineViews);
                lineHeights.add(lineHeight);
                parentNeedHeight = parentNeedHeight + lineHeight + mVerticalSpacing;
                parentNeedWidth = Math.max(parentNeedWidth, lineWidthUsed + mHorizontalSpacing);
            }

        }


        //  測量完孩子後再測量自己

        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        //  如果父佈局給的是確切的值,測量子view則變得毫無意義
        int realWidth = (widthMode == MeasureSpec.EXACTLY) ? selfWidth : parentNeedWidth;
        int realHeight = (heightMode == MeasureSpec.EXACTLY) ? selfHeight : parentNeedHeight;
        setMeasuredDimension(realWidth, realHeight);
    }

    /**
     *      佈局
     */
    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {

        int currentL = getPaddingLeft();
        int currentT = getPaddingTop();

        for (int i = 0; i < allLines.size(); i++) {
            List<View> lineViews = allLines.get(i);
            int lineHeight = lineHeights.get(i);
            for (int j = 0; j < lineViews.size(); j++) {
                View view = lineViews.get(j);
                int left = currentL;
                int top = currentT;
                //  此處為什麼不用 int right = view.getWidth(); getWidth是調用完onLayout才有的
                int right = left + view.getMeasuredWidth();
                int bottom = top + view.getMeasuredHeight();
                //  子view位置擺放
                view.layout(left, top, right, bottom);
                currentL = right + mHorizontalSpacing;
            }
            currentT = currentT + lineHeight + mVerticalSpacing;
            currentL = getPaddingLeft();
        }


    }

    public static int dp2px(int dp) {
        return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, Resources.getSystem().getDisplayMetrics());
    }

}

實現效果如文章開頭

FlowLayout 的 onMeasure方法是什麼時候被調用的

FlowLayout的onMeasure是在上面什麼調用的,肯定是在其父佈局做測量遞歸的時候調用的。比如FlowLayout的父佈局是LinearLayout,咱們去LinearLayout中找實現
LinearLayout.onMeasure()

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
   if (mOrientation == VERTICAL) {
        measureVertical(widthMeasureSpec, heightMeasureSpec);
    } else {
        measureHorizontal(widthMeasureSpec, heightMeasureSpec);
    }
}

void measureVertical(widthMeasureSpec, heightMeasureSpec){
	//	獲取子view 的 LayoutParams 
	final LayoutParams lp = (LayoutParams) child.getLayoutParams();
	...
	...
	//	開始測量
	measureChildBeforeLayout(child, i, widthMeasureSpec, 0,heightMeasureSpec, usedHeight);
}

void measureChildBeforeLayout(View child, int childIndex,int widthMeasureSpec, int totalWidth, int heightMeasureSpec,int totalHeight) {
        measureChildWithMargins(child, widthMeasureSpec, totalWidth, heightMeasureSpec, totalHeight);
    }


protected void measureChildWithMargins(View child,
        int parentWidthMeasureSpec, int widthUsed,
        int parentHeightMeasureSpec, int heightUsed) {
        
    final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

	//	去除自己的使用,padding、margin剩下的再給子view
    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);

	//	此處子view調用其測量函數,也就是FlowLayout的測量
    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}

一些其他概念

MeasureSpec 是什麼

自定義view常用的一個屬性MeasureSpec,是View的內部類,封裝瞭對子View的佈局要求,由尺寸和模式組成。由於int類型由32位構成,所以他用高2位表示 Mode,低30位表示Size。

MeasureMode有三種 00 01 11

  • UNSPECIFIED:不對View大小做限制,系統使用
  • EXACTLY:確切的大小,如100dp
  • AT_MOST:大小不可超過某值,如:matchParent,最大不能超過父佈局

LayoutParams 與 MeasureSpec 的關系

我們在xml寫的鍵值對是不能直接轉化為具體的dp的,根據父佈局給的尺寸與模式計算出自己的MeasureSpec,通過不斷的遞歸測量,得到最後的測量值。LayoutParams.width獲取的就是xml裡寫的或許是match_parent,或許是wrap_content,這些是不能直接用的,根據父佈局給出的參考值再通過測量子佈局的尺寸最後才能得到一個具體的測量值

onLayout為什麼不用 int right = view.getWidth() 而用 getMeasuredWidth

這要對整個流程有完整的理解才能回答,getWidth 是在 onLayout 調用後才有的值,getMeasuredWidth在測量後有值

到此這篇關於Android 深入探究自定義view之流式佈局FlowLayout的使用的文章就介紹到這瞭,更多相關Android 流式佈局FlowLayout內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: