Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> 一個FlowLayout帶你學會自定義ViewGroup

一個FlowLayout帶你學會自定義ViewGroup

編輯:關於Android編程

時間過得真快,又到了寫博客的時候了(/▽╲)。這次按照計劃記錄一個簡單的自定義ViewGroup:流布局FlowLayout的實現過程,將View的繪制流程和LayoutParams的那些事裡的知識點結合起來,付諸實踐。

1. 前言

早在學習Java的Swing基礎知識的時候,就見到過裡面的流布局FlowLayout,基本的效果就是讓加入此容器的控件自左往右依次排列,如果當前行的寬度不足以容納下一個控件,就會將此控件放置到下一行。其實這也跟css裡向左浮動的效果很相似。

在Android的世界裡,系統是沒有提供類似FlowLayout布局的容器的。當然了,現在官方給我們提供了更強大也更復雜的FlexLayout了。不過嘛,本篇博客是總結一個自定義ViewGroup的實現流程,所以需要找一個難易適中的實例來進行分析,也就是FlowLayout了。(是的,我就是挑軟柿子捏︿( ̄︶ ̄)︿)。

2. 效果

閒話少說,還是先來看看蘑菇君寫的FlowLayout的功能:

支持最基本的從左至右的排序,空間不足則換行 支持設置子控件間的水平和豎直的間隔(也可以通過給每個child設置margin來實現,不過沒有統一設置來的方便) 支持繪制行之間的分割線 支持FlowLayout本身的Gravity和child views的Gravity 處理好FlowLayout的padding和child views的margin

這些都是FlowLayout基本的功能,效果如下圖所示:

FlowLayout效果展示

是不是感覺還行?至少一般的情況下是能滿足大部分人的需求滴。o( ̄▽ ̄)d<喎?/kf/ware/vc/" target="_blank" class="keylink">vcD4NCjxoMiBpZD0="3-分析">3. 分析

列舉一下自定義ViewGroup的流程:

自定義屬性:如果ViewGroup需要用到自定義屬性,則需要聲明、設置、解析並獲取自定義屬性值。 測量:在onMeasure方法裡處理AT_MOSTEXACTLY兩種測量模式下ViewGroup的寬高和children的寬高。(UNSPECIFIED模式可以暫不考慮) 布局:在onLayout方法裡確定children的位置。 繪制:如果ViewGroup裡需要繪制,則重寫onDraw方法,按邏輯繪制。比如FlowLayout可以在每一行之間繪制一條分隔線。 處理LayoutParams:如果要為children定義布局屬性,如layout_gravity,則需要自定義LayoutParams,並且重寫ViewGroup相關的方法。 處理滑動事件:在本FlowLayout裡暫時用不上…( ╯▽╰)

上面的步驟可能有所遺漏,不過也差不多啦。下面蘑菇君要根據上述的流程來一步一步的分析FlowLayout的源碼,源碼可能有點長,有些細節上的邏輯看不懂也莫方,只要了解流程對應的實現方式和注意事項就好,有興趣的話可以稍後自己下載源碼分析具體的邏輯實現。

好滴,那就讓我們來一步一步的看,這個FlowLayout是如何在我手裡…被玩殘的…

3.1 自定義屬性

3.1.1 聲明屬性

首先,自定義屬性的第一步當然是聲明屬性,而最常使用的方式當然是在xml資源文件裡(一般來說就是attrs.xml文件)聲明需要使用的屬性:

   
        
        
        
        
        
    

    
        
    

這裡需要注意兩個地方:

我們聲明了兩個declare-styleable,一個是為FlowLayout自身設置自定義屬性;另一個是為孩子們提供額外屬性,需要在自定義的LayoutParams裡解析獲取屬性值。

大家都知道,我們在xml布局文件裡使用自定義屬性時,需要引入命名空間

xmlns:app="http://schemas.android.com/apk/res-auto"

使用自定義屬性時,需要加上前綴app(或者是其它命名,只要一一對應)。但是有時候啊,我們自定義的屬性名已經在系統中存在了,而且語義與我們想要的也很符合,比如如andrioid:textandroid:gravity等等。這個時候估計誰都會有一種“拿來主義”的沖動:直接使用系統裡已經存在的屬性名就好了嘛,多“原生”!既然有這種“邪惡”的需求,那Google工程師自然是要滿足滴(~ ̄▽ ̄)~。

gravity屬性為例,我們只要在declare-styleable裡直接寫上即可,不過這裡要注意的是不需要也不能再加上format屬性,加上format屬性就代表著這是在聲明一個新的屬性,不加則代表這是在使用已存在的一個屬性。

3.1.2 使用屬性

使用屬性就比較簡單了:

3.1.3 解析並獲取屬性

在xml設置了相應的屬性後,就需要在FlowLayout裡解析並獲取屬性值了:


public static final int DEFAULT_SPACING = 8;
    public static final int DEFAULT_DIVIDER_COLOR = Color.parseColor("#ececec");
    public static final int DEFAULT_DIVIDER_WIDTH = 3;

    private int mGravity = (isIcs() ? Gravity.START : Gravity.LEFT) | Gravity.TOP;

    private int mVerticalSpacing; //vertical spacing
    private int mHorizontalSpacing; //horizontal spacing
    private int mDividerColor;
    private int mDividerWidth;

private void init(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) {

        TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout, defStyleAttr, defStyleRes);

        try {
            mHorizontalSpacing = (int) ta.getDimension(R.styleable.FlowLayout_horizonSpacing, DEFAULT_SPACING);
            mVerticalSpacing = (int) ta.getDimension(R.styleable.FlowLayout_verticalSpacing, DEFAULT_SPACING);
            mDividerWidth = (int) ta.getDimension(R.styleable.FlowLayout_dividerWidth, DEFAULT_DIVIDER_WIDTH);
            mDividerColor = ta.getColor(R.styleable.FlowLayout_dividerColor, DEFAULT_DIVIDER_COLOR);
            int index = ta.getInt(R.styleable.FlowLayout_android_gravity, -1);
            if (index > 0) {
                setGravity(index);
            }
            initPaint();
        } finally {
            ta.recycle();
        }
        setWillNotDraw(false);

    }

一般來說,我們的自定義屬性都得給個默認值,大家都這麼懶,不能強人所難對不對。這默認值可以通過常量直接寫在自定義類裡,如上述代碼所示。也可以寫在xml資源文件裡,提供給別人統一修改。

其次呢,英明神武的蘑菇君自然也得提供方法讓別人方便的通過代碼去動態修改這些屬性啦(真不要臉( ﹁ ﹁ ) ~):

 public void setHorizontalSpacing(int pixelSize) {
        mHorizontalSpacing = pixelSize;
        requestLayout();
    }

    public void setVerticalSpacing(int pixelSize) {
        mVerticalSpacing = pixelSize;
        requestLayout();
    }

    public void setDividerColor(@ColorInt int color) {
        mDividerColor = color;
        mDividerPaint.setColor(color);
        invalidate();
    }
    ...

關於自定義屬性的一些詳細知識可以參考文章: Android 深入理解Android中的自定義屬性

3.2 測量

在自定義ViewGroup時,測量流程一般是所有流程中最為復雜的一環。因為我們不僅要測量ViewGroup自身的尺寸,還得測量所有孩子的尺寸。而ViewGroup和孩子們之間的尺寸又是相互影響的。

如下圖所示,在我們的FlowLayout裡,當寬的測量模式為AT_MOST(比如FlowLayout的布局屬性android:layout_widthwrap_content時),FlowLayout的測量寬度應該是所有行裡最長的那一行的寬度,在下圖中就是第二行的寬度。而當高的測量模式為AT_MOST,FlowLayout的測量高度應該是所有行的高度總和。

而對於child view來說,也有個小小的限制:當FlowLayout的layout_heightwrap_content,而child的layout_heightmatch_parent時,我希望child的測量高為它所處那一行的高度,而不是整個FlowLayout的高度或者是wrap_content。這也挺合情合理的吧,比如下圖中第一行的child 再見這群坑比layout_heightmatch_parent,所以它就和第一行的高度一樣高。

寬高為wrap_content時的FlowLayout

可能說得大家都有點暈了X﹏X,還是來一起看看onMeasure方法的源碼吧:

 //保存所有child view
private final List> mLines = new ArrayList<>();
//保存所有行高
private final List mLineHeights = new ArrayList<>();
//保存所有行寬
private final List mLineWidths = new ArrayList<>();

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

        mLines.clear();
        mLineHeights.clear();
        mLineWidths.clear();

        int sizeWidth = MeasureSpec.getSize(widthMeasureSpec);
        int sizeHeight = MeasureSpec.getSize(heightMeasureSpec);
        int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
        int modeHeight = MeasureSpec.getMode(heightMeasureSpec);

        int widthUsed = getPaddingLeft() + getPaddingRight() + mHorizontalSpacing;
        int lineWidth = widthUsed;
        int lineHeight = 0;

        int childCount = getChildCount();
        List lineViews = new ArrayList<>();

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

            View child = getChildAt(i);

            if (child.getVisibility() == View.GONE) {
                continue;
            }

            LayoutParams lp = (LayoutParams) child.getLayoutParams();
            //測量每個child的寬高,每個child可用的最大寬高為sizeWidth-spacing-padding-margin
            measureChildWithMargins(child, widthMeasureSpec, mHorizontalSpacing * 2, heightMeasureSpec, mVerticalSpacing * 2);

            int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
            //判斷這一行是否還能容下這個child
            if (lineWidth + childWidth + mHorizontalSpacing > sizeWidth) {
                //需要換行,則記錄這一行的寬度,高度,下一行的初始寬度,初始高度

                mLineWidths.add(lineWidth);
                lineWidth = widthUsed + childWidth + mHorizontalSpacing;

                mLineHeights.add(lineHeight);
                lineHeight = childHeight;

                mLines.add(lineViews);
                lineViews = new ArrayList<>();
            } else {//容得下,則累加這一行的寬度,記錄這一行的高度
                lineWidth += childWidth + mHorizontalSpacing;
                lineHeight = Math.max(lineHeight, childHeight);
            }

            lineViews.add(child);

        }
        //最後一行的處理
        mLineHeights.add(lineHeight);
        mLineWidths.add(lineWidth);
        mLines.add(lineViews);

        int maxWidth = Collections.max(mLineWidths);

        processChildHeights();//計算所有行的累積高度
        int totalHeight = getChildHeights();

        //TODO 處理getMinimumWidth/height的情況

        //設置自身的測量寬高
        setMeasuredDimension(
                (modeWidth == MeasureSpec.EXACTLY) ? sizeWidth : Math.min(maxWidth, sizeWidth),
                (modeHeight == MeasureSpec.EXACTLY) ? sizeHeight : Math.min(totalHeight, sizeHeight));

        //重新測量child的lp.height為MATCH_PARENT時的child的尺寸
        remeasureChild(widthMeasureSpec);
    }


上面的代碼邏輯都有注釋,相信大家都能理清大概的邏輯。暫時沒理解也沒關系,稍後自己去看代碼再加上自己的思考肯定能看懂滴。(蘑菇君自我感覺腦子轉的算慢的,看Github上的FlowLayout源碼花了蠻久時間才弄懂大概邏輯,自己畫圖呀,運行demo呀,弄懂了以後,才開始自己動手寫自己的FlowLayout…(??????)??)

這裡要特別注意的是對children的測量過程。在上面的代碼中,我使用了ViewGroup類裡提供的measureChildWithMargins方法去測量每個child,對這個方法的具體剖析,可以去看自定義控件知識儲備-View的繪制流程,這篇文章講的很詳細。但在上文中有提到過,我們對child有個限制:

當child的layout_heightmatch_parent時,child的測量高為它所處那一行的高度,而不是整個FlowLayout的高度或者是wrap_content

但是這個child所處那一行的高度是那一行所有child的高度的最大值,所以只有在完成這一行所有child的測量後,才知道這一行的高度是多少。所以上面的要求無法滿足呀!我在測量該child的高度的時候,還不知道這一行的高度是多少啊!

 

該怎麼辦呢?其實也簡單,既然當時測量某child的時候還不知道那一行的高度,那就在第一次所有child都測量完成後,再對那些layout_heightmatch_parent的child測量一遍就好啦。所以在上面onMeasure方法裡的最後調用了remeasureChild這個方法去重新測量一遍child:

private void remeasureChild(int parentWidthSpec) {
        int numLines = mLines.size();
        for (int i = 0; i < numLines; i++) {//遍歷每一行
            int lineHeight = mLineHeights.get(i);
            List lineViews = mLines.get(i);
            int children = lineViews.size();
            for (int j = 0; j < children; j++) {
                View child = lineViews.get(j);
                LayoutParams lp = (LayoutParams) child.getLayoutParams();
                if (lp.height == LayoutParams.MATCH_PARENT) {//對高為match_parent的child進行處理
                    if (child.getVisibility() == View.GONE) {
                        continue;
                    }

                    int widthUsed = lp.leftMargin + lp.rightMargin +
                            getPaddingLeft() + getPaddingRight() + 2 * mHorizontalSpacing;
                    //再次調用child的measure方法進行測量        
                    child.measure(
                            getChildMeasureSpec(parentWidthSpec, widthUsed, lp.width),
                            MeasureSpec.makeMeasureSpec(lineHeight - lp.topMargin - lp.bottomMargin, MeasureSpec.EXACTLY)
                    );
                }
            }
        }
    }

從這裡我們也看得出來,一個View的onMeasure方法是很有可能被調用多次來確定最終的測量寬高的,所以下次遇到打印日志裡或者斷點調試下發現 onMeasure方法多次運行,莫要方呀o( ̄??)。

3.3 布局

布局過程呢,就稍微簡單一些,因為我們在onMeasure方法裡已經將所有child的寬高和位於哪一行等信息都計算好了,只要遍歷children調用它們的layout方法放置好它們就行。不過這裡有點麻煩的就是,我們需要支持FlowLayout自身的gravity屬性和children的 gravity屬性。那就得根據具體的gravity來計算相應的偏移量了,代碼如下:

//根據gravity計算FlowLayout的垂直方向上的偏移量
private void processVerticalGravityMargin() {
        int verticalGravityMargin;
        int childHeights = getChildHeights();
        switch ((mGravity & Gravity.VERTICAL_GRAVITY_MASK)) {
            case Gravity.TOP://頂部
            default:
                verticalGravityMargin = 0;
                break;
            case Gravity.CENTER_VERTICAL://垂直居中
                verticalGravityMargin = Math.max((getHeight() - childHeights) / 2, 0);
                break;
            case Gravity.BOTTOM://底部
                verticalGravityMargin = Math.max(getHeight() - childHeights, 0);
                break;
        }
        mVerticalGravityMargin = verticalGravityMargin;
    }

//根據gravity計算FlowLayout的水平方向上的偏移量
    private void processHorizontalGravityMargins() {
        mLineMargins.clear();
        float horizontalGravityFactor;
        switch ((mGravity & Gravity.HORIZONTAL_GRAVITY_MASK)) {
            case Gravity.LEFT://水平靠左
            default:
                horizontalGravityFactor = 0;
                break;
            case Gravity.CENTER_HORIZONTAL://水平居中
                horizontalGravityFactor = .5f;
                break;
            case Gravity.RIGHT://水平靠右
                horizontalGravityFactor = 1;
                break;
        }

        int linesNum = mLineWidths.size();
        for (int i = 0; i < linesNum; i++) {
            int lineWidth = mLineWidths.get(i);
            mLineMargins.add((int) ((getWidth() - lineWidth) * horizontalGravityFactor) + getPaddingLeft() + mHorizontalSpacing);
        }
    }

給FlowLayout設置gravity的效果如下:

內容居中:

FlowLayout內容居中

內容在右下角:

FlowLayout內容在右下角

計算好了每行的偏移量後,layout方法的邏輯就很清晰了:

protected void onLayout(boolean changed, int l, int t, int r, int b) {

        processHorizontalGravityMargins();
        processVerticalGravityMargin();

        int numLines = mLines.size();
        int left;
        int top = getPaddingTop() + mVerticalSpacing + mVerticalGravityMargin;

        for (int i = 0; i < numLines; i++) {

            int lineHeight = mLineHeights.get(i);
            List lineViews = mLines.get(i);
            left = mLineMargins.get(i);
            int children = lineViews.size();

            for (int j = 0; j < children; j++) {

                View child = lineViews.get(j);

                if (child.getVisibility() == View.GONE) {
                    continue;
                }

                LayoutParams lp = (LayoutParams) child.getLayoutParams();

                int childWidth = child.getMeasuredWidth();
                int childHeight = child.getMeasuredHeight();

                int gravityMargin = 0;
                //根據child的gravity計算child的相應偏移量
                if (Gravity.isVertical(lp.gravity)) {
                    switch (lp.gravity) {
                        case Gravity.TOP:
                        default:
                            gravityMargin = 0;
                            break;
                        case Gravity.CENTER_VERTICAL:
                        case Gravity.CENTER:
                            gravityMargin = (lineHeight - childHeight - lp.topMargin - lp.bottomMargin) / 2;
                            break;
                        case Gravity.BOTTOM:
                            gravityMargin = lineHeight - childHeight - lp.topMargin - lp.bottomMargin;
                            break;
                        //TODO 水平方向上可以支持gravity麼?
                    }
                }

                child.layout(left + lp.leftMargin,
                        top + lp.topMargin + gravityMargin,
                        left + lp.leftMargin + childWidth,
                        top + lp.topMargin + gravityMargin + childHeight);

                Log.i(TAG, String.format("child (%d,%d) position: (%d,%d,%d,%d)",
                        i, j, child.getLeft(), child.getTop(), child.getRight(), child.getBottom()));

                left += childWidth + lp.leftMargin + lp.rightMargin + mHorizontalSpacing;

            }

            top += lineHeight + mVerticalSpacing;
        }

    }

3.4 繪制

本FlowLayout支持繪制分割線,這也是很容易的繪制,只要找准每條分割線的位置就行。不過萬變不離其宗嘛,我現在能畫一條線,下次就能畫一個圓,再下次就能畫個雞蛋,再再下次我就能飛上天,畫出太陽肩並肩…。咳咳,扯遠了,我們還是來看看onDraw方法裡的繪制邏輯:

@Override
    protected void onDraw(Canvas canvas) {

        int top = getPaddingTop() + mVerticalSpacing + mVerticalGravityMargin;
        int numLines = mLines.size();
        for (int i = 0; i < numLines; i++) {
            int lineHeight = mLineHeights.get(i);
            top += lineHeight + mVerticalSpacing;
            canvas.drawLine(getPaddingLeft(), top - mVerticalSpacing / 2, 
            getWidth() - getPaddingRight(), top - mVerticalSpacing / 2, mDividerPaint);
        }

    }

確實很簡單,遍歷每一行,在兩行的中間根據配置的顏色和寬度畫出一條線段即可。

不過這裡要注意View的一個特殊方法:setWillNotDraw,來看一下這個方法的源碼:

/**
     * If this view doesn't do any drawing on its own, set this flag to
     * allow further optimizations. By default, this flag is not set on
     * View, but could be set on some View subclasses such as ViewGroup.
     *
     * Typically, if you override {@link #onDraw(android.graphics.Canvas)}
     * you should clear this flag.
     *
     * @param willNotDraw whether or not this View draw on its own
     */
    public void setWillNotDraw(boolean willNotDraw) {
        setFlags(willNotDraw ? WILL_NOT_DRAW : 0, DRAW_MASK);
    }

從這個方法的注釋中可以看出,如果一個View不需要繪制任何內容,那麼設置這個標記位為true後,系統會進行相應的優化。默認情況下,View沒有啟用這個優化標記位,而ViewGroup會默認啟用這個標記位。

當我們的自定義ViewGroup需要通過重寫onDraw來繪制內容時,我們需要顯式地關閉WILL_NOT_DRAW這個標記位。

所以,在這個FlowLayout的構造方法裡,我們可以調用setWillNotDraw(false)來進行優化。

3.5 處理LayoutParams

幾乎每個自定義ViewGroup都得自定義自己的LayoutParams,來給children提供更好的服務。在本FlowLayout裡,能給children帶來的就是gravity屬性的支持。來看看自定義的LayoutParams:

 public static class LayoutParams extends MarginLayoutParams {

        public int gravity = -1;

        public LayoutParams(Context c, AttributeSet attrs) {
            super(c, attrs);

            TypedArray a = c.obtainStyledAttributes(attrs, R.styleable.FlowLayout_Layout);

            try {
                gravity = a.getInt(R.styleable.FlowLayout_Layout_android_layout_gravity, -1);
            } finally {
                a.recycle();
            }
        }

        public LayoutParams(int width, int height) {
            super(width, height);
            gravity = Gravity.TOP;
        }

        public LayoutParams(int width, int height, int gravity) {
            super(width, height);
            this.gravity = gravity;
        }

        public LayoutParams(ViewGroup.LayoutParams source) {
            super(source);
        }

    }

同時,FlowLayout還需要對以下幾個方法進行重寫:

@Override
    protected LayoutParams generateDefaultLayoutParams() {
        return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
    }

    @Override
    protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
        return super.checkLayoutParams(p) && p instanceof LayoutParams;
    }

    @Override
    protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
        return new LayoutParams(p);
    }

    @Override
    public LayoutParams generateLayoutParams(AttributeSet attrs) {
        return new LayoutParams(getContext(), attrs);
    }

啥?不知道為啥要按上述代碼那樣做?那是時候去看看自定義控件知識儲備-LayoutParams的那些事了。看完了你就大徹大悟,遁入……咳咳。

3. 展示

哎呀呀,這篇文章已經夠長了,我就不貼資源文件,截圖等東西啦,大家有需要的話,可以去Github上下載源碼進行學習。

Github地址: https://github.com/yisizhu520/FlowLayout

PS:蘑菇君寫的這個FlowLayout肯定還存在bug,而且我自己也知道幾個不影響使用的小bug,但是我沒有去改,等待有緣人去發現哈(≧?≦)?。

也歡迎大家去提交issue和pull request,一起交流,一起進步。

4. 總結

終於寫完這篇博客了,真是寫死我了?(T?T)。希望這篇文章除了能加深自己對自定義ViewGroup的理解外,還能幫助到大家。以前一直以為自己了解了自定義ViewGroup的一些知識,想要寫一個容器控件出來應該不難的。然而,紙上得來終覺淺,當自己真的開始寫的時候,發現滿滿的都是細節,滿滿的都是套路。比如在FlowLayout裡的測量、布局、繪制都得考慮到間距的問題,什麼margin啊,padding啊,spacing啊,都需要小心對待。不過,最終還是在不斷的調試和修改中寫出來了這個FlowLayout,想想還有點小激動呢!以後要做的應該就是不斷的練習和總結,畢竟編程這件事,沒啥好說的,just code it!

 

  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved