知識點摘要:只需要會簡單的自定義 View、ViewGroup,不必瞭解 onMeasure、onLayout 和 onDraw 的過程。最後還提供了一個比較複雜的小米時鐘 View。
自定義 View
自定義 View 其實很簡單,只需要繼承 View,然後重寫建構函式、 onMeasure 和 onDraw 方法即可,下面我們就來學習學習他們的用法。
重寫建構函式
在繼承 View 之後,編譯器提醒我們必須實現建構函式,我們一般實現如下兩種即可
public CustomView(Context context) {
super(context);
}
public CustomView(Context context, AttributeSet attrs) {
super(context, attrs);
}
複製程式碼
重寫 onMeasure()
onMeasure 顧名思義就是測量當前 View 的大小,你可能會有疑惑,我們不是在佈局 xml 中已經指定 View 的 layout_width 和 layout_height,這兩個屬性不就是 View 的高寬嗎?沒錯,這兩個屬性就是設定 View 的大小,不過如果你應該使用過 wrap_content 和 match_parent 這樣的值。我們知道它們分別代表「包裹內容」和「填充父容器」,我們還知道所有程式碼最後通過編譯器都會編譯成機器碼,但是 cpu 肯定不可能明白「包裹內容」和「填充父類」是什麼意思,所以我們應該將它們轉化成具體的數值,如 100 px(100 個畫素點,最後在螢幕根據畫素點顯示)。
囉嗦了半天,我們還是來看程式碼更為直觀,我們如果想畫一個正方形,並且這個正方形的寬度需要填滿整個父容器,這個時候就需要重寫 onMeasure 來設定 View 的具體值。
這是重寫 onMeasure 的基礎程式碼,有兩個引數 widthMeasureSpec 和 heightMeasureSpec,它們儲存了 view 的長寬和「測量模式」資訊。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}
複製程式碼
長寬我們懂,這個「測量模式」又是什麼東西?簡單來說「測量模式」包含三種 UNSPECIFIED、EXACTLY 和 AT_MOST。
UNSPECIFIED : 父容器對當前 view 沒有任何限制,可以設定任意的尺寸。
EXACTLY : 當前讀到的尺寸就是 view 的尺寸。
AT_MOST : 當前讀到的尺寸是 view 能夠設定的最大尺寸。
我們在寫佈局介面的時候設定控制元件的大小常用的時三種情況 match_parent 、wrap_content 和固定尺寸,三種測量模式與 match_parent 、wrap_content 和固定尺寸之間的關係如下,可以看到 UNSPECIFIED 模式我們基本上不會觸發。
match_parent –> EXACTLY。match_parent 就是要利用父 View 給我們提供的所有剩餘空間,而父 View 剩餘空間是確定的,也就是這個測量模式的整數裡面存放的尺寸。
wrap_content –> AT_MOST。wrap_content 就是我們想要將大小設定為包裹我們的 view 內容,那麼尺寸大小就是父 View 給我們作為參考的尺寸,只要不超過這個尺寸就可以啦,具體尺寸就根據我們的需求去設定。
固定尺寸(如 100dp)–> EXACTLY。使用者自己指定了尺寸大小,我們就不用再去幹涉了,當然是以指定的大小為主啦。
我們弄懂了 onMeasure 方法的作用以及引數,接下來就設定正方形 view 的尺寸。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int width = getMySize(100, widthMeasureSpec); //從 widthMeasureSpec 得到寬度
int height = getMySize(100, heightMeasureSpec); //從 heightMeasureSpec 得到高度
if (width < height) { // 取最小的那個值
height = width;
} else {
width = height;
}
setMeasuredDimension(width, height); //設定 view 具體的尺寸
}
private int getMySize(int defaultSize, int measureSpec) {
int mySize = defaultSize;
int mode = MeasureSpec.getMode(measureSpec); //得到測量模式
int size = MeasureSpec.getSize(measureSpec); //得到建議尺寸
switch (mode) {
case MeasureSpec.UNSPECIFIED: { //如果沒有指定大小,就設定為預設值
mySize = defaultSize;
break;
}
case MeasureSpec.AT_MOST: { //如果測量模式是最大值,就設定為 size
//我們將大小取最大值,你也可以取其他值
mySize = size;
break;
}
case MeasureSpec.EXACTLY: { //如果是固定的大小,那就不要去改變它
mySize = size;
break;
}
default:
break;
}
return mySize;
}
複製程式碼
重寫 onDraw()
我們已經設定好了 view 的尺寸,也就是將畫板準備好。接下來需要在畫板上繪製圖形,我們只需要重寫 onDraw 方法。引數 Canvas 是官方為我們提供的畫圖工具箱,我們可以利用它繪製各種各樣的圖形。
@Override
protected void onDraw(Canvas canvas) {
//呼叫父 View 的 onDraw 函式,因為 View 這個類幫我們實現了一些
// 基本的而繪製功能,比如繪製背景顏色、背景圖片等
super.onDraw(canvas);
int r = getMeasuredHeight() / 2;
//圓心的從橫座標
int centerX = r;
//圓心的從縱座標
int centerY = r;
Paint p = new Paint(); //畫筆
p.setColor(Color.GREEN); //設定畫筆的顏色
//開始繪製
canvas.drawCircle(centerX, centerY, r, p);
}
複製程式碼
這樣一個簡單的正方形控制元件就完成了,我們只需要在佈局 xml 中加入 CustomView 控制元件,就能看到效果
<?xml version="1.0" encoding="utf-8"?>
<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"
tools:context=".MainActivity">
<com.wendraw.customviewexample.CustomView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#f00" />
</LinearLayout>
複製程式碼
自定義佈局屬性
不知道你在寫佈局檔案的時候,有沒有想過這樣的問題,在佈局檔案中設定控制元件的 layout_width 屬性的值之後,相應的 View 物件就會改變,這是怎麼實現的呢?我們的 CustomView 可不可以自己定義一個這樣的佈局檔案上可以用的屬性呢?
我們在使用 view 時會發現,defaultSize 值被我們寫死了,如果有別的開發者想使用我們的 CustomView,但是預設大小想設定為 200,就需要去修改原始碼,這就破壞了程式碼的封裝特性,有的人會說我們可以增加 getDefaultSize、setDefaultSize 方法,這個方法沒有問題,但是還不夠優雅,其實 Google 已經幫我們優雅的實現了,就本節要講到的 AttributeSet。
我們在重寫建構函式時,其實埋下了一個伏筆,為什麼我們要實現 public CustomView(Context context, AttributeSet attrs) 方法呢?AttributeSet 引數又有什麼作用呢?
首先我們需要新建一個 res/values/attr.xml 檔案,用來存放各種自定義的佈局屬性:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- name 為宣告的"屬性集合"名,可以隨便取,但是最好是設定為跟我們的 View 一樣的名稱-->
<declare-styleable name="CustomView">
<!-- 宣告我們的屬性,名稱為 default_size,取值型別為尺寸型別(dp,px等)-->
<attr name="default_size" format="dimension" />
</declare-styleable>
</resources>
複製程式碼
接下來就能在佈局檔案中使用這個屬性了
<?xml version="1.0" encoding="utf-8"?>
<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"
tools:context=".MainActivity">
<com.wendraw.customviewexample.CustomView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#f00"
app:default_size="100dp" />
</LinearLayout>
複製程式碼
注意:需要在根標籤(LinearLayout)裡面設定名稱空間,名稱空間名稱可以隨便取,比如 app,名稱空間後面取得值是固定的:”schemas.android.com/apk/res-aut…“
我們在佈局檔案中使用剛剛定義的屬性還不會產生效果,因為我們沒有將它解析到 CustomView 類中,解析的過程也很簡單,使用我們前面介紹過帶 AttributeSet 引數的建構函式即可:
public CustomView(Context context, AttributeSet attrs) {
super(context, attrs);
//第二個引數就是我們在styles.xml檔案中的<declare-styleable>標籤
//即屬性集合的標籤,在R檔案中名稱為R.styleable+name
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomView);
//第一個引數為屬性集合裡面的屬性,R檔名稱:R.styleable+屬性集合名稱+下劃線+屬性名稱
//第二個引數為,如果沒有設定這個屬性,則設定的預設的值
mDefaultSize = typedArray.getDimensionPixelSize(R.styleable.CustomView_default_size, 100);
//最後將 TypedArray 回收
typedArray.recycle();
}
複製程式碼
全域性變數 mDefaultSize 就是從佈局檔案中的 default_size 屬性中解析來的值。
至此一個簡單的自定義 view 就建立成功了,跟我們平時使用的 Buttom 控制元件是一樣的。並且我們還可以在 activity_main.xml 的 Design 介面的左上角看到我們剛剛建立的控制元件:
自定義 ViewGroup
我們寫一個佈局檔案用到的就是兩個元素,控制元件、佈局。控制元件在上一節已經講了,這一節我們一起來學習佈局 ViewGroup。佈局就是一個 View 容器,其作用就是決定控制元件的擺放位置。
其實官方給我們提供的六個佈局已經夠用了,我們學習自定義 view 主要是為了在使用佈局的時候更好的理解其原理。既然是佈局就要滿足幾個條件:
- 首先要知道子 view 的大小,才能根據子 View 才能設定 ViewGroup 的大小。
- 然後要知道佈局功能,也就是子 View 需要怎麼擺放,知道子 View 的尺寸和擺放方式才能確定 ViewGroup 的大小。
- 最後就是將子 View 填到相應的位置。
接下來就通過一個簡單的案例學習一下,要求自定義一個將子 View 按垂直方向依此擺放的佈局。
我們先建立一個 CustomViewLayout 類並繼承 ViewGroup。
實現 onMeasure,測量子 View 的大小,設定 ViewGroup 的大小
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
//將所有的子View進行測量,這會觸發每個子View的onMeasure函式
//注意要與measureChild區分,measureChild是對單個view進行測量
measureChildren(widthMeasureSpec, heightMeasureSpec);
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(heightMeasureSpec);
int heightSize = MeasureSpec.getSize(heightMeasureSpec);
int childCount = getChildCount();
if (childCount == 0) {
//如果沒有子View,當前ViewGroup沒有存在的意義,不用佔用空間
setMeasuredDimension(0, 0);
} else {
//如果高寬都是包裹內容
if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {
//我們就將高度設為所有子 View 的高度相加,寬度設為子 View 最大的。
int width = getMaxChildWidth();
int height = getTotalHeight();
setMeasuredDimension(width, height);
} else if (widthMode == MeasureSpec.AT_MOST) { //只有寬度是包裹內容
//高度設定為 ViewGroup 的測量值,寬度為子 View 的最大寬度
setMeasuredDimension(getMaxChildWidth(), heightSize);
} else if (heightMode == MeasureSpec.AT_MOST) { //只有高度是包裹內容
//高度設定為 ViewGroup 的測量值,寬度為子 View 的最大寬度
setMeasuredDimension(widthSize, getTotalHeight());
}
}
}
/**
* 獲取子 View 中寬度最大的值
*
* @return 子 View 中寬度最大的值
*/
private int getMaxChildWidth() {
int childCount = getChildCount();
int maxWidth = 0;
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
if (childView.getMeasuredWidth() > maxWidth) {
maxWidth = childView.getMeasuredWidth();
}
}
return maxWidth;
}
/**
* 將所有子 View 的高度相加
*
* @return 所有子 View 的高度的總和
*/
private int getTotalHeight() {
int childCount = getChildCount();
int height = 0;
for (int i = 0; i < childCount; i++) {
View childView = getChildAt(i);
height += childView.getMeasuredHeight();
}
return height;
}
複製程式碼
程式碼已經註釋的比較詳細了,我就不贅述了。現在我們解決了 ViewGroup 的大小問題,接下來就是解決子 View 的擺放問題。
實現 onLayout 擺放子 View
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int count = getChildCount();
//記錄當前的高度位置
int curHeight = t;
//將子 View 逐個拜訪
for (int i = 0; i < count; i++) {
View child = getChildAt(i);
int width = child.getMeasuredWidth();
int height = child.getMeasuredHeight();
//擺放子 View,引數分別是子 View 矩形區域的左、上、右、下邊
child.layout(l, curHeight, l + width, curHeight + height);
curHeight += height;
}
}
複製程式碼
程式碼很簡單,用一個迴圈將子 View 按照順序一次執行 layout,設定子 View 的擺放位置。
至此一個簡單的自定義佈局我們也完成了,我們來測試一下:
<?xml version="1.0" encoding="utf-8"?>
<com.wendraw.customviewexample.CustomViewLayout 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"
tools:context=".MainActivity">
<com.wendraw.customviewexample.CustomViewLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#0f0">
<com.wendraw.customviewexample.CustomView
android:layout_width="300dp"
android:layout_height="100dp"
android:background="#f00"
app:default_size="200dp" />
<Button
android:layout_width="300dp"
android:layout_height="wrap_content"
android:text="xxxxxxxxxx" />
<com.wendraw.customviewexample.CustomView
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#f00"
app:default_size="200dp" />
</com.wendraw.customviewexample.CustomViewLayout>
<View
android:layout_width="match_parent"
android:layout_height="100dp" />
<com.wendraw.customviewexample.CustomView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="#f00"
app:default_size="100dp" />
</com.wendraw.customviewexample.CustomViewLayout>
複製程式碼
可以看到我們建立的自定義的 View 和 ViewGroup 跟平常使用控制元件、佈局的方式一樣,我們組合起來其效果如下:
深入學習自定義 View
通過上面的學習你應該對自定義 View 和 ViewGroup 有一定的認識,甚至覺得還有一點點簡單,接下來你就可以學習一下更復雜的 View。比如小米時鐘,你可以先嚐試自己實現,不會的再參考我的程式碼。
結束
在入門階段我們不需要去詳細 onMeasure、onLayout 和 onDraw 的過程,只需要會簡單的自定義 View、ViewGroup 即可,切記只見樹木不見森林。最後還提供了一個比較複雜小米時針 View, 非常值得自己動手學習。
所有的程式碼都上傳到了 GayHub CustomViewExample,歡迎拍磚。