自定義View實踐篇(2)- 自定義ViewGroup

1. 簡介

上一章:自定義View實踐篇(1)- 自定義單一View
我們實現(xiàn)了自定義單一View,這章我們來看下自定義ViewGroup

2. 自定義ViewGroup

自定義ViewGroup同樣分為兩類,一類是繼承系統(tǒng)已有的ViewGroup(如:LinearLayout),另一類是直接繼承ViewGroup類,我們分開來看下。

2.1 繼承系統(tǒng)已有ViewGroup

這種方式可以去擴展系統(tǒng)已有ViewGroup的功能,最常用的就是組合View。通過組合不同的View來形成一個新的布局,達到多次復(fù)用的效果。比如微信底部導(dǎo)航欄,就是上面一個ImageView,下面一個TextView來組合而成。如下圖:

自定義View-微信導(dǎo)航欄.png

我們這里來簡單實現(xiàn)一下這個布局,主要是組合一個ImageViewTextView,然后自定義屬性可以修改圖標(biāo)和標(biāo)題:

2.1.1 定義組合View的xml布局

首先我們來定義這個View的布局,就是上面一個ImageView,下面一個TextView,外面使用了LinearLayout來包裹。非常簡單,命名為navigation_button.xml

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:gravity="center"
    android:orientation="vertical">

    <ImageView
        android:id="@+id/iv_icon"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

    <TextView
        android:id="@+id/tv_title"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"/>

</LinearLayout>

2.1.2 編寫組合View的Java代碼

接下來,我們編寫組合ViewJava代碼,由于上面的布局是使用了LinearLayout來包裹,因此我們這里的類也是繼承于LinearLayout。

public class NavigationButton extends LinearLayout {//繼承于LinearLayout

    private ImageView mIconIv;
    private TextView mTitleTv;

    public NavigationButton(Context context) {
        super(context);
        init(context);
    }

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

    public NavigationButton(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }
    
    //初始化
    public void init(Context context) {
        //加載布局
        LayoutInflater.from(context).inflate(R.layout.navigation_button, this, true);
        mIconIv = findViewById(R.id.iv_icon);
        mTitleTv = findViewById(R.id.tv_title);
    }

    //提供一個修改標(biāo)題的接口
    public void setText(String text) {
        mTitleTv.setText(mText);
    }
}

重寫了三個構(gòu)造方法并在構(gòu)造方法中加載布局文件,對外提供了一個接口,可以用來設(shè)置標(biāo)題的名字。

2.1.3 自定義屬性

為了方便使用,通常我們都會自定義屬性,這里定義兩個屬性:icontitlevalues目錄下定義attrs_navigation_button.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="NavigationButton">
        <attr name="icon" format="reference"/>
        <attr name="text" format="string"/>
    </declare-styleable>
</resources>

2.1.4 解析自定義屬性

將上面的NavigationButton修改一下:

public class NavigationButton extends LinearLayout {

    private ImageView mIconIv;
    private TextView mTitleTv;
    private Drawable mIcon;
    private CharSequence mText;

    public NavigationButton(Context context) {
        super(context);
        init(context);
    }

    public NavigationButton(Context context, AttributeSet attrs) {
        super(context, attrs);
        
        //解析自定義屬性
        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.NavigationButton);
        mIcon = typedArray.getDrawable(R.styleable.NavigationButton_icon);
        mText = typedArray.getText(R.styleable.NavigationButton_text);
        //獲取資源后要及時回收
        typedArray.recycle();

        init(context);

    }

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

    public void init(Context context) {
        LayoutInflater.from(context).inflate(R.layout.navigation_button, this, true);
        mIconIv = findViewById(R.id.iv_icon);
        mTitleTv = findViewById(R.id.tv_title);
        
        //設(shè)置相關(guān)屬性
        mIconIv.setImageDrawable(mIcon);
        mTitleTv.setText(mText);
    }

    public void setText(String text) {
        mTitleTv.setText(mText);
    }
}

2.1.5 使用組合View

<?xml version="1.0" encoding="utf-8"?>
<!--必須添加schemas聲明才能使用自定義屬性-->
<!--添加的是xmlns:app="http://schemas.android.com/apk/res-auto"-->
<LinearLayout
    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"
    android:background="#fff"
    android:gravity="bottom"
    android:orientation="horizontal">

    <com.april.view.NavigationButton
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="center"
        app:icon="@drawable/chats_green"
        app:text="微信">
    </com.april.view.NavigationButton>

    <com.april.view.NavigationButton
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="center"
        app:icon="@drawable/contacts"
        app:text="通訊錄">
    </com.april.view.NavigationButton>

    <com.april.view.NavigationButton
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="center"
        app:icon="@drawable/discover"
        app:text="發(fā)現(xiàn)">
    </com.april.view.NavigationButton>

    <com.april.view.NavigationButton
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_weight="1"
        android:gravity="center"
        app:icon="@drawable/about_me"
        app:text="我">
    </com.april.view.NavigationButton>

</LinearLayout>

運行結(jié)果為:


自定義View-NavigationButton.png

2.1.7 總結(jié)

組合View非常簡單,只需繼承系統(tǒng)某個已存在的ViewGroup即可,組裝好布局以及添加一些自定義屬性即可使用,一般無需處理測量、布局、繪制等過程。
通過組合View的方式來實現(xiàn)新的效果,可以使得這個View能夠復(fù)用起來,維護修改起來時也更方便簡單一點。
當(dāng)然上面的UI效果有更好的方式去實現(xiàn),這里只是為了舉例說明而已。

2.2 繼承ViewGroup類

繼承ViewGroup類可以用來重新定義一種布局,只是這種方式比較復(fù)雜,需要去實現(xiàn)ViewGroup的測量和布局過程以及處理子元素的測量和布局。組合View也可以采用這種方式來實現(xiàn),只是需要處理的細節(jié)更復(fù)雜而已。

我們這里來實現(xiàn)一個流式布局,什么是流式布局呢?流式布局就是加入此容器的View從左往右依次排列,如果當(dāng)前行的寬度不夠裝進下一個View,就會自動將該View放到下一行中去。如下圖所示:

自定義View-流式布局.png

2.2.1 需求分析

首先我們對這個需求進行簡單的分析:

  1. 流式布局需要對每個子View進行布局,即從左往右依次擺放,當(dāng)前行的寬度不夠則從下一行開始。
  2. 流式布局需要測量和計算自身的寬高。
  3. 流式布局需要處理marginpadding等細節(jié)。
  4. 流式布局需要對外提供一些自定義屬性,方便用戶去使用。比如可以設(shè)置行間距和水平間距等等。

2.2.2 實現(xiàn)步驟

根據(jù)上面的需求分析,其實現(xiàn)步驟如下:

  1. 自定義屬性。
  2. 解析自定義屬性以及對外提供一些設(shè)置屬性的接口等。
  3. 重寫onMeasure(),實現(xiàn)自身的測量過程。
  4. 重寫onLayout(),對子View的位置進行布局。
  5. 使用自定義View。

2.2.3 自定義屬性

這里定義兩個屬性:行間距,水平間距,values目錄下定義attrs_flow_layout.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="FlowLayout">
        <!--水平間距-->
        <attr name="horizontal_spacing" format="dimension|reference"/>
        <!--行間距-->
        <attr name="vertical_spacing" format="dimension|reference"/>
    </declare-styleable>
</resources>

2.2.4 解析自定義屬性

對自定義的屬性進行解析,以及對外提供一些設(shè)置屬性的接口:

public class FlowLayout extends ViewGroup {//繼承ViewGroup

    private int mHorizontalSpacing;//水平間距
    private int mVerticalSpacing;//行間距
    //默認間距
    public static final int DEFAULT_Horizontal_SPACING = 10;
    public static final int DEFAULT_Vertical_SPACING = 10;

    public FlowLayout(Context context) {
        super(context);
    }

    public FlowLayout(Context context, AttributeSet attrs) {
        super(context, attrs);
        
        //解析自定義屬性
        TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout);
        mHorizontalSpacing = typedArray.getDimensionPixelOffset(R.styleable.FlowLayout_horizontal_spacing, DEFAULT_Horizontal_SPACING);
        mVerticalSpacing = typedArray.getDimensionPixelOffset(R.styleable.FlowLayout_vertical_spacing, DEFAULT_Vertical_SPACING);
        //獲取資源后要及時回收
        typedArray.recycle();

    }

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

    //設(shè)置水平間距
    public void setHorizontalSpacing(int pixelSize) {
        mHorizontalSpacing = pixelSize;
        requestLayout();
    }

    //設(shè)置行間距
    public void setVerticalSpacing(int pixelSize) {
        mVerticalSpacing = pixelSize;
        requestLayout();
    }
}

2.2.5 重寫onMeasure()

具體解析見下面代碼注釋,需要注意的是,我們這里支持margin,所以會復(fù)雜點。

    private List<Integer> mHeightLists = new ArrayList<>();//保存每行的最大高度

    @Override
    public ViewGroup.LayoutParams generateLayoutParams(AttributeSet attrs) {
        //因為我們需要支持margin,所以需要重寫generateLayoutParams方法并創(chuàng)建MarginLayoutParams對象
        return new MarginLayoutParams(getContext(), attrs);
    }

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

        // 獲得測量模式和大小
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);

        // 如果是warp_content情況下,記錄寬和高
        int warpWidth = 0;
        int warpHeight = 0;

        int widthUsed = getPaddingLeft() + getPaddingRight();//Padding的寬度
        int lineWidth = widthUsed;//記錄當(dāng)前行的寬度
        int lineHeight = 0;//記錄一行的最大高度

        int childCount = getChildCount();
        //遍歷子View進行測量
        for (int i = 0; i < childCount; i++) {

            View child = getChildAt(i);

            //子View為GONE則跳過
            if (child.getVisibility() == View.GONE) {
                continue;
            }
            //獲得一個支持margin的布局參數(shù)
            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

            //測量每個child的寬高,每個child可用的最大寬高為widthSize-padding-margin
            measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);

            // child實際占據(jù)的寬高
            int childWidth = child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
            int childHeight = child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;

            //判斷這一行是否還能裝得下這個child
            if (lineWidth + childWidth <= widthSize) {
                //裝得下,則累加這一行的寬度,并記錄這一行的最大高度
                lineWidth += childWidth + mHorizontalSpacing;
                lineHeight = Math.max(lineHeight, childHeight);
            } else {//裝不下,需要換行,則記錄這一行的寬度,高度,下一行的初始寬度,初始高度

                //比較當(dāng)前行寬度(當(dāng)前行寬度需減去末尾的水平間距)與下一行寬度,取最大值
                warpWidth = Math.max(lineWidth - mHorizontalSpacing, widthUsed + childWidth);
                //換行,記錄新行的初始寬度
                lineWidth = widthUsed + childWidth + mHorizontalSpacing;

                //累加當(dāng)前高度
                warpHeight += lineHeight + mVerticalSpacing;
                //保存每行的最大高度,onLayout時會用到
                mHeightLists.add(lineHeight);
                //記錄下一行的初始高度,并設(shè)置為當(dāng)前行
                lineHeight = childHeight;
            }

            // 如果是最后一個child,則將當(dāng)前記錄的最大寬度和當(dāng)前l(fā)ineWidth做比較
            if (i == childCount - 1) {
                warpWidth = Math.max(warpWidth, lineWidth - mHorizontalSpacing);
                //累加高度
                warpHeight += lineHeight;
            }
        }
        //根據(jù)測量模式去保存相應(yīng)的測量寬度
        //即如果是MeasureSpec.EXACTLY直接使用父ViewGroup傳入的寬和高
        //否則設(shè)置為自己計算的寬和高,即為warp_content時
        setMeasuredDimension((widthMode == MeasureSpec.EXACTLY) ? widthSize : warpWidth,
                (heightMode == MeasureSpec.EXACTLY) ? heightSize : warpHeight);
    }

2.2.6 重寫onLayout()

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int width = getWidth();
        int line = 0;//當(dāng)前行號
        int widthUsed = getPaddingLeft() + getPaddingRight();//Padding的寬度
        int lineWidth = widthUsed;//記錄當(dāng)前行的寬度
        int left = getPaddingLeft();
        int top = getPaddingTop();
        
        int childCount = getChildCount();
        //遍歷所有子View
        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);

            //child為GONE則跳過
            if (child.getVisibility() == View.GONE) {
                continue;
            }
            
            //獲得一個支持margin的布局參數(shù)
            MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
            //獲取child的測量寬度
            int childWidth = child.getMeasuredWidth();
//            int childHeight = child.getMeasuredHeight();

            //判斷這一行是否還能裝得下這個child,需要把margin值加上
            if (lineWidth + childWidth + lp.leftMargin + lp.rightMargin <= width) {
                //裝得下,則累加這一行的寬度
                lineWidth += childWidth + mHorizontalSpacing;
            } else {//裝不下,需要換行,則記錄新行的寬度,并設(shè)置新的left、top位置
                //重置left
                left = getPaddingLeft();
                //top累加當(dāng)前行的最大高度和行間距
                top += mHeightLists.get(line++) + mVerticalSpacing;
                //開始新行,記錄寬度
                lineWidth = widthUsed + childWidth + mHorizontalSpacing;
            }
            //計算child的left,top,right,bottom
            int lc = left + lp.leftMargin;
            int tc = top + lp.topMargin;
            int rc = lc + child.getMeasuredWidth();
            int bc = tc + child.getMeasuredHeight();
            //計算child的位置
            child.layout(lc, tc, rc, bc);
            //left往右移動一個水平間距
            left = rc + mHorizontalSpacing;
        }
    }

2.2.7 使用自定義View

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#fff">

    <com.april.view.FlowLayout
        android:layout_width="250dp"
        android:layout_height="wrap_content"
        android:background="#0ff"
        android:padding="5dp"
        app:horizontal_spacing="10dp"
        app:vertical_spacing="20dp">

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@drawable/shape_bg"
            android:text="Android"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@drawable/shape_bg"
            android:text="源碼分析"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@drawable/shape_bg"
            android:text="自定義View"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@drawable/shape_bg"
            android:text="繼承系統(tǒng)已有View"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@drawable/shape_bg"
            android:text="繼承View類"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@drawable/shape_bg"
            android:text="繼承ViewGroup類"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@drawable/shape_bg"
            android:text="繼承系統(tǒng)已有ViewGroup"/>

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:background="@drawable/shape_bg"
            android:text="自定義屬性"/>

    </com.april.view.FlowLayout>
</LinearLayout>

運行程序,結(jié)果為:


自定義View-FlowLayout.png

2.2.8 總結(jié)

繼承ViewGroup類來實現(xiàn)一個全新的布局,一般來說都要重寫onMeasure()onLayout(),以及提供自定義屬性。但是onDraw()一般不需要重寫,除非要實現(xiàn)一些分割線之類的需求。

總的來說,繼承ViewGroup類是最復(fù)雜的,要寫出一個好的自定義的ViewGroup,需要注意非常多的細節(jié),比如margin、padding等等。

另外,上面的FlowLayout實際上還不夠好,還有很多細節(jié)沒實現(xiàn),比如支持gravity等等,可能還會有一些bug,但是作為一個例子來說明繼承ViewGroup類這種自定義View的方式還是足夠的。

如果需要使用流式布局,實際google也為我們開源了一個控件,有興趣的可以去看下FlexboxLayout。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容