Android 入門(三)簡單自定義 View
知識點摘要:只需要會簡單的自定義 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 、固定尺寸之間的關係,可以看到 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 可不可以自己定義一個這樣的佈局屬性呢?
我們在重寫建構函式時,其實埋下了一個伏筆,為什麼我們要實現 public CustomView(Context context, AttributeSet attrs) 方法呢?AttributeSet 引數又有什麼作用呢?
我們在使用 view 時會發現,defaultSize 值被我們寫死了,如果有別的開發者想使用我們的 CustomView,但是預設大小想設定為 200,就需要去修改原始碼,這就破壞了程式碼的封裝特性,有的人會說我們可以增加 getDefaultSize、setDefaultSize 方法,這個方法沒有問題,但是還不夠優雅,其實 Google 已經幫我們優雅的實現了,就本節要講到的 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。比如小米時鐘,你可以先嚐試自己實現,不會的再參考我的程式碼。
