作者:李旺成
時間:2016年5月7日
該 Hack 將介紹如何自定義 ViewGroup。
顯示撲克牌布局
假設現在要開發一款撲克牌游戲,需要創建類似下圖的布局來顯示玩家手里的牌。應該如何創建這樣的布局呢?
要創建上面的布局并不困難,使用 margin 屬性便足以實現上述效果。例如,直接在 merge 標簽下排布 View 即可。
使用 <merge /> 標簽實現撲克牌布局
效果如下:
看代碼:
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:context="com.diygreen.androidhackslayout.Hack5Activity">
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:background="#FF0000" />
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:layout_marginLeft="30dp"
android:layout_marginTop="20dp"
android:background="#00FF00" />
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:layout_marginLeft="60dp"
android:layout_marginTop="40dp"
android:background="#0000FF" />
</merge>
使用 RelativeLayout 實現撲克牌布局
使用 RelativeLayout 那更沒有問題了,效果如下:
代碼:
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingBottom="@dimen/activity_vertical_margin"
android:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
tools:context="com.diygreen.androidhackslayout.Hack5Activity">
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:background="#FF0000" />
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:layout_marginLeft="30dp"
android:layout_marginTop="20dp"
android:background="#00FF00" />
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:layout_marginLeft="60dp"
android:layout_marginTop="40dp"
android:background="#0000FF" />
</RelativeLayout>
創建自定義 ViewGroup
使用 margin 并不是這個 Hack 要講解的內容,這里將介紹另一種方法:創建自定義 ViewGroup。
該方法相對于在 xml 文件中手工指定 margin 有如下優點:
- 在不同 Activity 中復用該視圖時,更容易維護
- 開發者可以使用自定義屬性來定制 ViewGroup 中子視圖的位置
- 布局文件更簡明,更容易理解
- 如果需要修改 margin,不必重新手動計算每個子視圖的 margin
(參考自:《50 Android Hacks》)
要自定義 ViewGroup 那需要先了解下 Android 中是怎么繪制 View 的。
View 的繪制流程
Android 的官方文檔上有一篇文章專門講解了 View 是怎么繪制的,有興趣的可以去看看:How Android Draws Views (Google 的官方文檔要翻墻,你也可以去這里看:How Android Draws Views,國內的 Android 在線文檔網站,在以前的文章中推薦過)。
可能沒有很多人喜歡看英文,我搬運一下《50 Android Hacks》上的介紹:
繪制布局由兩個遍歷過程組成:測量過程和布局過程。測量過程由 measure(int, int) 方法完成,該方法從上到下遍歷視圖樹。在遞歸遍歷過程中,每個視圖都會向下層傳遞尺寸和規格。當 measure 方法遍歷結束,每個視圖都保存了各自的尺寸信息。第二個過程由 layout(int, int, int) 方法完成,該方法也是由上而下遍歷視圖樹,在遍歷過程中,每個父視圖通過測量過程的結果定位所有子視圖的位置信息。(參考自:《50 Android Hacks》)
簡而言之:第一步,在 onMeasure() 方法中測量 ViewGroup 的尺寸;第二步,在 onLayout() 方法中利用第一步獲取的尺寸信息布局子視圖。
可以看一下流程圖:
(參考了:Android應用層View繪制流程與源碼分析)
說明:
關于 View 的繪制流程有不少結合源碼分析的文章,有興趣的可以去看看,如:
Android應用層View繪制流程與源碼分析
Android中View繪制流程
這些不是本文重點,就不展開了。
創建自定義 ViewGroup
上面已經演示過要實現的效果了,下面我們使用自定義 ViewGroup 來實現。效果如下:
創建 CascadeLayout
將自定義 ViewGroup 命名為 CascadeLayout,繼承自 ViewGroup。根據 AndroidStudio 的提示,添加必要的方法:
public class CascadeLayout extends ViewGroup {
public CascadeLayout(Context context, AttributeSet attrs) {
super(context, attrs);
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
}
}
定義自定義屬性
我們要創建的 CascadeLayout 的實現思路是這樣的,使用自定義屬性來定制其內部子視圖的位置。在 Framelayout 或者 RelativeLayout 中實現撲克牌布局使用的是 margin,也就是間距;再結合上面的效果圖仔細分析一下,這完全可以通過規定視圖的水平方向間距和垂直方向間距來實現。所以,可以定義兩個屬性,來分別表示水平方向間距和垂直方向間距。
1、創建屬性文件
在 res/values 目錄下創建一個 attrs.xml 文件,如果有這個文件那就直接打開。添加如下內容:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CascadeLayout">
<attr name="horizontal_spacing" format="dimension" />
<attr name="vertical_spacing" format="dimension" />
</declare-styleable>
</resources>
對于自定義屬性不是很清楚的可以去看看這篇文章:Android 深入理解Android中的自定義屬性,這里就不展開了。
2、在構造方法中獲取自定義屬性
public class CascadeLayout extends ViewGroup {
private int mHorizontalSpacing;
private int mVerticalSpacing;
public CascadeLayout(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray a = context.obtainStyledAttributes(attrs,
R.styleable.CascadeLayout);
try {
mHorizontalSpacing = a.getDimensionPixelSize(
R.styleable.CascadeLayout_horizontal_spacing,
getResources().getDimensionPixelSize(
R.dimen.cascade_horizontal_spacing));
mVerticalSpacing = a.getDimensionPixelSize(
R.styleable.CascadeLayout_vertical_spacing, getResources()
.getDimensionPixelSize(R.dimen.cascade_vertical_spacing));
} finally {
a.recycle();
}
}
}
3、重寫 onMeasure() 方法
一般的布局 ViewGroup 中都會有一個取名為 LayoutParams 的靜態內部類,在這里我們的 CascadeLayout 中也需要創建自定義 LayoutParamas 類,該類的作用是保存每個子視圖的 x、y 軸位置。
先看看 LayoutParamas 類:
public static class LayoutParams extends ViewGroup.LayoutParams {
int x;
int y;
public int verticalSpacing;
public LayoutParams(Context context, AttributeSet attrs) {
super(context, attrs);
TypedArray a = context.obtainStyledAttributes(attrs,
R.styleable.CascadeLayout_LayoutParams);
try {
verticalSpacing = a
.getDimensionPixelSize(
R.styleable.CascadeLayout_LayoutParams_layout_vertical_spacing,
-1);
} finally {
a.recycle();
}
}
public LayoutParams(int w, int h) {
super(w, h);
}
}
在該類中使用了子視圖的自定義屬性,該屬性的作用是為特定子視圖指定垂直間距。
第一步,在 attrs.xml 中添加一個新屬性,如下:
<declare-styleable name="CascadeLayout_LayoutParams">
<attr name="layout_vertical_spacing" format="dimension" />
</declare-styleable>
因為屬性名的前綴是 layout_,沒有包含一個視圖屬性,因此該屬性會被添加到 LayoutParamas 的屬性表中。(這是從《50 Android Hacks》上直接摘抄下來的,話說我感覺不好理解)
第二步,在 LayoutParamas 類的構造函數中讀取屬性值
注意,LayoutParamas 中的 verticalSpacing 是一個公共字段,該字段會在 CascadeLayout 類的 onMeasure() 方法中使用到。如果子視圖的 LayoutParamas 包含了 verticalSpacing,就可以使用它(也就是設置了 layout_vertical_spacing 屬性,CascadeLayout_LayoutParams 中只定義了這一個屬性)。
關于子視圖的自定義屬性就介紹到這,繼續往下閱讀。
要使用新定義的 CascadeLayout.LayoutParamas 類,還需要重寫下面這些方法:
- checkLayoutParams(ViewGroup.LayoutParams p)
- generateDefaultLayoutParams()
- generateLayoutParams(AttributeSet attrs)
- generateLayoutParams(ViewGroup.LayoutParams p)
這些方法在不同的 ViewGroup 之間往往差別不大,關鍵是使用自己的靜態內部類 LayoutParamas,這里給出簡單的實現,你也可以參考一下 LinearLayout 是如何實現的,看代碼:
@Override
protected boolean checkLayoutParams(ViewGroup.LayoutParams p) {
return p instanceof LayoutParams;
}
@Override
protected LayoutParams generateDefaultLayoutParams() {
return new LayoutParams(LayoutParams.WRAP_CONTENT,
LayoutParams.WRAP_CONTENT);
}
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new LayoutParams(getContext(), attrs);
}
@Override
protected LayoutParams generateLayoutParams(ViewGroup.LayoutParams p) {
return new LayoutParams(p.width, p.height);
}
好了,到 onMeasure() 方法了,這是 CascadeLayout 類的核心,里面有注釋,直接看代碼:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// 使用寬和高計算布局的最終大小以及子視圖的 x 與 y 軸位置
int width = getPaddingLeft();
int height = getPaddingTop();
int verticalSpacing;
final int count = getChildCount();
for (int i = 0; i < count; i++) {
verticalSpacing = mVerticalSpacing;
View child = getChildAt(i);
// 讓每個子視圖測量自身
measureChild(child, widthMeasureSpec, heightMeasureSpec);
LayoutParams lp = (LayoutParams) child.getLayoutParams();
width = getPaddingLeft() + mHorizontalSpacing * i;
// 在 LayoutParamas 中保存每個子視圖的 x 和 y 坐標
lp.x = width;
lp.y = height;
if (lp.verticalSpacing >= 0) {
verticalSpacing = lp.verticalSpacing;
}
width += child.getMeasuredWidth();
height += verticalSpacing;
}
width += getPaddingRight();
height += getChildAt(getChildCount() - 1).getMeasuredHeight()
+ getPaddingBottom();
// 使用計算所得的寬和高設置整個布局的測量尺寸
setMeasuredDimension(resolveSize(width, widthMeasureSpec),
resolveSize(height, heightMeasureSpec));
}
4、重寫 onLayout() 方法
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
final int count = getChildCount();
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
LayoutParams lp = (LayoutParams) child.getLayoutParams();
child.layout(lp.x, lp.y, lp.x + child.getMeasuredWidth(), lp.y
+ child.getMeasuredHeight());
}
}
這個方法就很簡單了,遍歷所有的子視圖,以 onMeasure() 方法計算出的值為參數傳入子視圖的 layout() 方法中即可。
在布局中使用
直接看代碼:
<?xml version="1.0" encoding="utf-8"?>
<com.diygreen.androidhackslayout.view.CascadeLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:cascade="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:padding="@dimen/activity_vertical_margin"
tools:context="com.diygreen.androidhackslayout.Hack5Activity">
<View
android:layout_width="100dp"
android:layout_height="150dp"
cascade:layout_vertical_spacing="90dp"
android:background="#FF0000" />
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:background="#00FF00" />
<View
android:layout_width="100dp"
android:layout_height="150dp"
android:background="#0000FF" />
</com.diygreen.androidhackslayout.view.CascadeLayout>
小結
使用自定義 View 和 ViewGroup 組織應用程序布局是一個好方法。建議大家多多模仿系統提供的 View 和 Layout,不僅可了解 Android 的設計思路,也可以加深對 Android 視圖機制的理解。
提供我學習自定義 View 的思路:
- 了解自定義 View 的基本流程
- 學習簡單的自定義 View 的實現
- 看一些開源項目
說白了就是從易到難,循序漸進。當然我認為先了解流程是比較重要的,你會知道閱讀源碼的時候從何處著手。
項目地址
示例用到代碼見:
Hack5Activity.java
CascadeLayout.java
attrs.xml
activity_hack5.xml
activity_hack5_1.xml
activity_hack5_2.xml
參考
Android應用層View繪制流程與源碼分析
Android中View繪制流程
Android 深入理解Android中的自定義屬性