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來組合而成。如下圖:

我們這里來簡單實現(xiàn)一下這個布局,主要是組合一個
ImageView和TextView,然后自定義屬性可以修改圖標(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代碼
接下來,我們編寫組合View的Java代碼,由于上面的布局是使用了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 自定義屬性
為了方便使用,通常我們都會自定義屬性,這里定義兩個屬性:icon和title。values目錄下定義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é)果為:

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放到下一行中去。如下圖所示:

2.2.1 需求分析
首先我們對這個需求進行簡單的分析:
- 流式布局需要對每個子
View進行布局,即從左往右依次擺放,當(dāng)前行的寬度不夠則從下一行開始。- 流式布局需要測量和計算自身的寬高。
- 流式布局需要處理
margin和padding等細節(jié)。- 流式布局需要對外提供一些自定義屬性,方便用戶去使用。比如可以設(shè)置行間距和水平間距等等。
2.2.2 實現(xiàn)步驟
根據(jù)上面的需求分析,其實現(xiàn)步驟如下:
- 自定義屬性。
- 解析自定義屬性以及對外提供一些設(shè)置屬性的接口等。
- 重寫
onMeasure(),實現(xiàn)自身的測量過程。- 重寫
onLayout(),對子View的位置進行布局。- 使用自定義
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é)果為:

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。