1. 什麼是自定義 View?
1.1 定義
在 Android 系統中,介面中所有能看到的元素都是 View。預設情況下,Android 系統為開發者提供了很多 View,比如用於展示文字資訊的 TextView,用於展示圖片的 ImageView 等等。但有時,這並不能滿足開發者的需求,例如,開發者想要用一個餅狀圖來展示一組資料,這時如果用系統提供的 View 就不能實現了,只能通過自定義 View 來實現。那到底什麼是自定義 View 呢?
自定義 View 就是通過繼承 View 或者 View 的子類,並在新的類裡面實現相應的處理邏輯(重寫相應的方法),以達到自己想要的效果。
1.2 繼承結構
Android 中的所有 UI 元素都是 View 的子類:
PS:由於涉及的類太多,如果將所有涉及到的類全部加到類圖裡面,類圖將十分大,所以此處只列出了 View 的直接子類。
1.3 檢視體系用到的設計模式
Android View 體系如下:
仔細觀察,你會發現,Android View 的體系結構和設計模式中的組合模式的結構如出一轍:
Android View 體系結構中的 ViewGroup 對應於組合模式中抽象構件(Component 和 Composite),Android View 體系結構中的 View 對應於組合模式中的葉子構件(Leaf):
Android View 構件 | Composite Pattern 構件 |
---|---|
ViewGroup | Component、Composite |
View | Leaf |
2. 為什麼要自定義 View?
大多數情況下,開發者常常會因為下面四個原因去自定義 View:
- 讓介面有特定的顯示風格、效果;
- 讓控制元件具有特殊的互動方式;
- 優化佈局;
- 封裝;
2.1 讓介面有特定的顯示風格、效果
預設情況下,Android 系統為開發者提供了很多控制元件,但有時,這並不能滿足開發者的需求。例如,開發者想要用一個餅狀圖來展示一組資料,這時如果用系統提供的 View 就不能實現了,只能通過自定義 View 來實現。
If none of the prebuilt widgets or layouts meets your needs, you can create your own View subclass.
2.2 讓控制元件具有特殊的互動方式
預設情況下,Android 系統為開發者提供的控制元件都有屬於它們自己的特定的互動方式,但有時,控制元件的預設互動方式並不能滿足開發者的需求。例如,開發者想要縮放 ImageView 中的圖片內容,這時如果用系統提供的 ImageView 就不能實現了,只能通過自定義 ImageView 來實現。
2.3 優化佈局
有時,有些佈局如果用系統提供的控制元件實現起來相當複雜,需要各種巢狀,雖然最終也能實現了想要的效果,但效能極差,此時就可以通過自定義 View 來減少巢狀層級、優化佈局。
2.4 封裝
有些控制元件可能在多個地方使用,如大多數 App 裡面的底部 Tab,像這樣的經常被用到的控制元件就可以通過自定義 View 將它們封裝起來,以便在多個地方使用。
3. 如何自定義 View?
在說「如何自定義 View?」之前,我們需要知道「自定義 View 都包括哪些內容」?
自定義 View 包括三部分內容:
- 佈局(Layout)
- 繪製(Drawing)
- 觸控反饋(Event Handling)
佈局階段:確定 View 的位置和尺寸。
繪製階段:繪製 View 的內容。
觸控反饋:確定使用者點選了哪裡。
其中佈局階段包括測量(measure)和佈局(layout)兩個過程,另外,佈局階段是為繪製和觸控反饋階段做支援的,它並沒有什麼直接作用。正是因為在佈局階段確定了 View 的尺寸和位置,繪製階段才知道往哪裡繪製,觸控反饋階段才知道使用者點的是哪裡。
另外,由於觸控反饋是一個大的話題,限於篇幅,就不在這裡講解了,後面有機會的話,我會再補上一篇關於觸控反饋的文章。
在自定義 View 和自定義 ViewGroup 中,佈局和繪製流程雖然整體上都是一樣的,但在細節方面,自定義 View 和自定義 ViewGroup 還是不一樣的,所以,接下來分兩類進行討論:
- 自定義 View 佈局、繪製流程
- 自定義 ViewGroup 佈局、繪製流程
3.1 自定義 View 佈局、繪製流程
「自定義 View 佈局、繪製」主要包括三個階段:
- 測量階段(measure)
- 佈局階段(layout)
- 繪製階段(draw)
3.1.1 自定義 View 測量階段
在 View 的測量階段會執行兩個方法(在測量階段,View 的父 View 會通過呼叫 View 的 measure() 方法將父 View 對 View 尺寸要求傳進來。緊接著 View 的 measure() 方法會做一些前置和優化工作,然後呼叫 View 的 onMeasure() 方法,並通過 onMeasure() 方法將父 View 對 View 的尺寸要求傳入。在自定義 View 中,只有需要修改 View 的尺寸的時候才需要重寫 onMeasure() 方法。在 onMeasure() 方法中根據業務需求進行相應的邏輯處理,並在最後通過呼叫 setMeasuredDimension() 方法告知父 View 自己的期望尺寸):
- measure()
- onMeasure()
measure() : 排程方法,主要做一些前置和優化工作,並最終會呼叫 onMeasure() 方法執行實際的測量工作;
onMeasure() : 實際執行測量任務的方法,主要用與測量 View 尺寸和位置。在自定義 View 的 onMeasure() 方法中,View 根據自己的特性和父 View 對自己的尺寸要求算出自己的期望尺寸,並通過 setMeasuredDimension() 方法告知父 View 自己的期望尺寸。
onMeasure() 計算 View 期望尺寸方法如下:
-
參考父 View 的對 View 的尺寸要求和實際業務需求計算出 View 的期望尺寸:
- 解析 widthMeasureSpec;
- 解析 heightMeasureSpec;
- 將「根據實際業務需求計算出 View 的尺寸」根據「父 View 的對 View 的尺寸要求」進行相應的修正得出 View 的期望尺寸(通過呼叫 resolveSize() 方法);
-
通過 setMeasuredDimension() 儲存 View 的期望尺寸(實際上是通過 setMeasuredDimension() 告知父 View 自己的期望尺寸);
注意:
多數情況下,這裡的期望尺寸就是 View 的最終尺寸。不過最終 View 的期望尺寸和實際尺寸是不是一樣還要看它的父 View 會不會同意。View 的父 View 最終會通過呼叫 View 的 layout() 方法告知 View 的實際尺寸,並且在 layout() 方法中 View 需要將這個實際尺寸儲存下來,以便繪製階段和觸控反饋階段使用,這也是 View 需要在 layout() 方法中儲存自己實際尺寸的原因——因為繪製階段和觸控反饋階段要使用啊!
3.1.2 自定義 View 佈局階段
在 View 的佈局階段會執行兩個方法(在佈局階段,View 的父 View 會通過呼叫 View 的 layout() 方法將 View 的實際尺寸(父 View 根據 View 的期望尺寸確定的 View 的實際尺寸)傳給 View,View 需要在 layout() 方法中將自己的實際尺寸儲存(通過呼叫 View 的 setFrame() 方法儲存,在 setFrame() 方法中,又會通過呼叫 onSizeChanged() 方法告知開發者 View 的尺寸修改了)以便在繪製和觸控反饋階段使用。儲存 View 的實際尺寸之後,View 的 layout() 方法又會呼叫 View 的 onLayout() 方法,不過 View 的 onLayout() 方法是一個空實現,因為它沒有子 View):
- layout()
- onLayout()
layout() : 儲存 View 的實際尺寸。呼叫 setFrame() 方法儲存 View 的實際尺寸,呼叫 onSizeChanged() 通知開發者 View 的尺寸更改了,並最終會呼叫 onLayout() 方法讓子 View 佈局(如果有子 View 的話。因為自定義 View 中沒有子 View,所以自定義 View 的 onLayout() 方法是一個空實現);
onLayout() : 空實現,什麼也不做,因為它沒有子 View。如果是 ViewGroup 的話,在 onLayout() 方法中需要呼叫子 View 的 layout() 方法,將子 View 的實際尺寸傳給它們,讓子 View 儲存自己的實際尺寸。因此,在自定義 View 中,不需重寫此方法,在自定義 ViewGroup 中,需重寫此方法。
注意:
layout() & onLayout() 並不是「排程」與「實際做事」的關係,layout() 和 onLayout() 均做事,只不過職責不同。
3.1.3 自定義 View 繪製階段
在 View 的繪製階段會執行一個方法——draw(),draw() 是繪製階段的總排程方法,在其中會呼叫繪製背景的方法 drawBackground()、繪製主體的方法 onDraw()、繪製子 View 的方法 dispatchDraw() 和 繪製前景的方法 onDrawForeground():
- draw()
draw() : 繪製階段的總排程方法,在其中會呼叫繪製背景的方法 drawBackground()、繪製主體的方法 onDraw()、繪製子 View 的方法 dispatchDraw() 和 繪製前景的方法 onDrawForeground();
drawBackground() : 繪製背景的方法,不能重寫,只能通過 xml 佈局檔案或者 setBackground() 來設定或修改背景;
onDraw() : 繪製 View 主體內容的方法,通常情況下,在自定義 View 的時候,只用實現該方法即可;
dispatchDraw() : 繪製子 View 的方法。同 onLayout() 方法一樣,在自定義 View 中它是空實現,什麼也不做。但在自定義 ViewGroup 中,它會呼叫 ViewGroup.drawChild() 方法,在 ViewGroup.drawChild() 方法中又會呼叫每一個子 View 的 View.draw() 讓子 View 進行自我繪製;
onDrawForeground() : 繪製 View 前景的方法,也就是說,想要在主體內容之上繪製東西的時候就可以在該方法中實現。
注意:
Android 裡面的繪製都是按順序的,先繪製的內容會被後繪製的蓋住。如,你在重疊的位置「先畫圓再畫方」和「先畫方再畫圓」所呈現出來的結果是不同的,具體表現為下表:
3.1.4 自定義 View 佈局、繪製流程時序圖
3.2 自定義 ViewGroup 佈局、繪製流程
「自定義 ViewGroup 佈局、繪製」主要包括三個階段:
- 測量階段(measure)
- 佈局階段(layout)
- 繪製階段(draw)
3.2.1 自定義 ViewGroup 測量階段
同自定義 View 一樣,在自定義 ViewGroup 的測量階段會執行兩個方法:
- measure()
- onMeasure()
measure() : 排程方法,主要做一些前置和優化工作,並最終會呼叫 onMeasure() 方法執行實際的測量工作;
onMeasure() : 實際執行測量任務的方法,與自定義 View 不同,在自定義 ViewGroup 的 onMeasure() 方法中,ViewGroup 會遞迴呼叫子 View 的 measure() 方法,並通過 measure() 將 ViewGroup 對子 View 的尺寸要求(ViewGroup 會根據開發者對子 View 的尺寸要求、自己的父 View(ViewGroup 的父 View) 對自己的尺寸要求和自己的可用空間計算出自己對子 View 的尺寸要求)傳入,對子 View 進行測量,並把測量結果臨時儲存,以便在佈局階段使用。測量出子 View 的實際尺寸之後,ViewGroup 會根據子 View 的實際尺寸計算出自己的期望尺寸,並通過 setMeasuredDimension() 方法告知父 View(ViewGroup 的父 View) 自己的期望尺寸。
具體流程如下:
- 執行前,開發者在 xml 中寫入對 ViewGroup 和 ViewGroup 子 View 的尺寸要求 layout_xxx;
- ViewGroup 在自己的 onMeasure() 方法中,根據開發者在 xml 中寫的對 ViewGroup 子 View 的尺寸要求、自己的父 View(ViewGroup 的父 View) 對自己的尺寸要求和自己的可用空間計算出自己對子 View 的尺寸要求,並呼叫每個子 View 的 measure() 將 ViewGroup 對子 View 的尺寸要求傳入,測量子 View 尺寸;
- ViewGroup 在子 View 計算出期望尺寸之後(在 ViewGroup 的 onMeasure() 方法中,ViewGroup 遞迴呼叫每個子 View 的 measure() 方法,子 View 在自己的 onMeasure() 方法中會通過呼叫 setMeasuredDimension() 方法告知父 View(ViewGroup) 自己的期望尺寸),得出子 View 的實際尺寸和位置,並暫時儲存計算結果,以便佈局階段使用;
- ViewGroup 根據子 View 的尺寸和位置計算自己的期望尺寸,並通過 setMeasuredDimension() 方法告知父 View 自己的期望尺寸。如果想要做的更好,可以在「 ViewGroup 根據子 View 的尺寸和位置計算出自己的期望尺寸」之後,再結合 ViewGroup 的父 View 對 ViewGroup 的尺寸要求進行修正(通過 resolveSize() 方法),這樣得出的 ViewGroup 的期望尺寸更符合 ViewGroup 的父 View 對 ViewGroup 的尺寸要求。
3.2.2 自定義 ViewGroup 佈局階段
同自定義 View 一樣,在自定義 ViewGroup 的佈局階段會執行兩個方法:
- layout()
- onLayout()
layout() : 儲存 ViewGroup 的實際尺寸。呼叫 setFrame() 方法儲存 ViewGroup 的實際尺寸,呼叫 onSizeChanged() 通知開發者 ViewGroup 的尺寸更改了,並最終會呼叫 onLayout() 方法讓子 View 佈局;
onLayout() : ViewGroup 會遞迴呼叫每個子 View 的 layout() 方法,把測量階段計算出的子 View 的實際尺寸和位置傳給子 View,讓子 View 儲存自己的實際尺寸和位置。
3.2.3 自定義 ViewGroup 繪製階段
同自定義 View 一樣,在自定義 ViewGroup 的繪製階段會執行一個方法——draw()。draw() 是繪製階段的總排程方法,在其中會呼叫繪製背景的方法 drawBackground()、繪製主體的方法 onDraw()、繪製子 View 的方法 dispatchDraw() 和 繪製前景的方法 onDrawForeground():
- draw()
draw() : 繪製階段的總排程方法,在其中會呼叫繪製背景的方法 drawBackground()、繪製主體的方法 onDraw()、繪製子 View 的方法 dispatchDraw() 和 繪製前景的方法 onDrawForeground();
在 ViewGroup 中,你也可以重寫繪製主體的方法 onDraw()、繪製子 View 的方法 dispatchDraw() 和 繪製前景的方法 onDrawForeground()。但大多數情況下,自定義 ViewGroup 是不需要重寫任何繪製方法的。因為通常情況下,ViewGroup 的角色是容器,一個透明的容器,它只是用來盛放子 View 的。
3.2.4 自定義 ViewGroup 佈局、繪製流程時序圖
3.3 自定義 View 步驟
- 自定義屬性的宣告與獲取;
- 重寫測量階段相關方法(onMeasure());
- 重寫佈局階段相關方法(onLayout()(僅 ViewGroup 需要重寫));
- 重寫繪製階段相關方法(onDraw() 繪製主體、dispatchDraw() 繪製子 View 和 onDrawForeground() 繪製前景);
- onTouchEvent();
- onInterceptTouchEvent()(僅 ViewGroup 有此方法);
4. 實戰演練
4.1 自定義 View
4.1.1 自定義 View ——自定義 View 的繪製內容
自定義 View,它的內容是「三個半徑不同、顏色不同的同心圓」,效果圖如下:
- 自定義屬性的宣告與獲取
//1.1 在 xml 中自定義 View 屬性
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--CircleView-->
<declare-styleable name="CircleView">
<attr name="circle_radius" format="dimension" />
<attr name="outer_circle_color" format="reference|color" />
<attr name="middle_circle_color" format="reference|color" />
<attr name="inner_circle_color" format="reference|color" />
</declare-styleable>
</resources>
//1.2 在 View 建構函式中獲取自定義 View 屬性
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
mRadius = typedArray.getDimension(R.styleable.CircleView_circle_radius, getResources().getDimension(R.dimen.avatar_size));
mOuterCircleColor = typedArray.getColor(R.styleable.CircleView_outer_circle_color, getResources().getColor(R.color.purple_500));
mMiddleCircleColor = typedArray.getColor(R.styleable.CircleView_middle_circle_color, getResources().getColor(R.color.purple_500));
mInnerCircleColor = typedArray.getColor(R.styleable.CircleView_inner_circle_color, getResources().getColor(R.color.purple_500));
typedArray.recycle();
複製程式碼
- 重寫測量階段相關方法(onMeasure())
由於不需要自定義 View 的尺寸,所以,不用重寫該方法。
- 重寫佈局階段相關方法(onLayout()(僅 ViewGroup 需要重寫))
由於沒有子 View 需要佈局,所以,不用重寫該方法。
- 重寫繪製階段相關方法(onDraw() 繪製主體、dispatchDraw() 繪製子 View 和 onDrawForeground() 繪製前景)
//4. 重寫 onDraw() 方法,自定義 View 內容
@Override
protected void onDraw(Canvas canvas) {
mPaint.setColor(mOuterCircleColor);
canvas.drawCircle(mRadius, mRadius, mRadius, mPaint);
mPaint.setColor(mMiddleCircleColor);
canvas.drawCircle(mRadius, mRadius, mRadius * 2/3, mPaint);
mPaint.setColor(mInnerCircleColor);
canvas.drawCircle(mRadius, mRadius, mRadius/3, mPaint);
}
複製程式碼
- onTouchEvent()
由於 View 不需要和使用者互動,所以,不用重寫該方法。
- onInterceptTouchEvent()(僅 ViewGroup 有此方法)
ViewGroup 的方法。
完整程式碼如下:
//1. 自定義屬性的宣告
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--CircleView-->
<declare-styleable name="CircleView">
<attr name="circle_radius" format="dimension" />
<attr name="outer_circle_color" format="reference|color" />
<attr name="middle_circle_color" format="reference|color" />
<attr name="inner_circle_color" format="reference|color" />
</declare-styleable>
</resources>
//2. CircleView
public class CircleView extends View {
private float mRadius;
private int mOuterCircleColor, mMiddleCircleColor, mInnerCircleColor;
private Paint mPaint;
public CircleView(Context context) {
this(context, null);
}
public CircleView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initData(context, attrs);
}
private void initData(Context context, AttributeSet attrs) {
//1. 自定義屬性的宣告與獲取
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
mRadius = typedArray.getDimension(R.styleable.CircleView_circle_radius, getResources().getDimension(R.dimen.avatar_size));
mOuterCircleColor = typedArray.getColor(R.styleable.CircleView_outer_circle_color, getResources().getColor(R.color.purple_500));
mMiddleCircleColor = typedArray.getColor(R.styleable.CircleView_middle_circle_color, getResources().getColor(R.color.purple_500));
mInnerCircleColor = typedArray.getColor(R.styleable.CircleView_inner_circle_color, getResources().getColor(R.color.purple_500));
typedArray.recycle();
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(mOuterCircleColor);
}
//2. 重寫測量階段相關方法(onMeasure());
//由於不需要自定義 View 的尺寸,所以不用重寫該方法
// @Override
// protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// }
//3. 重寫佈局階段相關方法(onLayout()(僅 ViewGroup 需要重寫));
//由於沒有子 View 需要佈局,所以不用重寫該方法
// @Override
// protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
// super.onLayout(changed, left, top, right, bottom);
// }
//4. 重寫繪製階段相關方法(onDraw() 繪製主體、dispatchDraw() 繪製子 View 和 onDrawForeground() 繪製前景);
@Override
protected void onDraw(Canvas canvas) {
mPaint.setColor(mOuterCircleColor);
canvas.drawCircle(mRadius, mRadius, mRadius, mPaint);
mPaint.setColor(mMiddleCircleColor);
canvas.drawCircle(mRadius, mRadius, mRadius * 2/3, mPaint);
mPaint.setColor(mInnerCircleColor);
canvas.drawCircle(mRadius, mRadius, mRadius/3, mPaint);
}
}
//3. 在 xml 中應用 CircleView
<?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"
android:gravity="center"
tools:context=".custom_view_only_draw.CustomViewOnlyDrawActivity">
<com.smart.a03_view_custom_view_example.custom_view_only_draw.CircleView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:circle_radius="@dimen/padding_ninety_six"
app:inner_circle_color="@color/yellow_500"
app:middle_circle_color="@color/cyan_500"
app:outer_circle_color="@color/green_500" />
</LinearLayout>
複製程式碼
最終效果如下:
此時,即使你在 xml 中將 CircleView 的寬、高宣告為「match_parent」,你會發現最終的顯示效果都是一樣的。
主要原因是:預設情況下,View 的 onMeasure() 方法在通過 setMeasuredDimension() 告知父 View 自己的期望尺寸時,會呼叫 getDefaultSize() 方法。在 getDefaultSize() 方法中,又會呼叫 getSuggestedMinimumWidth() 和 getSuggestedMinimumHeight() 獲取建議的最小寬度和最小高度,並根據最小尺寸和父 View 對自己的尺寸要求進行修正。最主要的是,在 getDefaultSize() 方法中修正的時候,會將 MeasureSpec.AT_MOST 和 MeasureSpec.EXACTLY 一視同仁,直接返回父 View 對 View 的尺寸要求:
//1. 預設 onMeasure 的處理
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
//2. getSuggestedMinimumWidth()
protected int getSuggestedMinimumWidth() {
return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
}
//3. getSuggestedMinimumHeight()
protected int getSuggestedMinimumHeight() {
return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight());
}
//4. getDefaultSize()
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
//MeasureSpec.AT_MOST、MeasureSpec.EXACTLY 一視同仁
result = specSize;
break;
}
return result;
}
複製程式碼
正是因為在 getDefaultSize() 方法中處理的時候,將 MeasureSpec.AT_MOST 和 MeasureSpec.EXACTLY 一視同仁,所以才有了上面「在 xml 中應用 CircleView 的時候,無論將 CircleView 的尺寸設定為 match_parent 還是 wrap_content 效果都一樣」的現象。
具體分析如下:
開發者對 View 的尺寸要求 | View 的父 View 對 View 的尺寸要求 | View 的期望尺寸 |
---|---|---|
android:layout_width="wrap_content" android:layout_height="wrap_content" |
MeasureSpec.AT_MOST specSize |
specSize |
android:layout_width="match_parent" android:layout_height="match_parent" |
MeasureSpec.EXACTLY specSize |
specSize |
注:
上表中,「View 的父 View 對 View 的尺寸要求」是 View 的父 View 根據「開發者對子 View 的尺寸要求」、「自己的父 View(View 的父 View 的父 View) 對自己的尺寸要求」和「自己的可用空間」計算出自己對子 View 的尺寸要求。
另外,由執行結果可知,上表中的 specSize 實際上等於 View 的尺寸:
2019-08-13 17:28:26.855 16024-16024/com.smart.a03_view_custom_view_example E/TAG: Width(getWidth()): 1080 Height(getHeight()): 1584
複製程式碼
4.1.2 自定義 View ——自定義 View 的尺寸和繪製內容
自定義 View,它的內容是「三個半徑不同、顏色不同的同心圓」,效果圖如下:
- 自定義屬性的宣告與獲取
//1.1 在 xml 中自定義 View 屬性
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--CircleView-->
<declare-styleable name="CircleView">
<attr name="circle_radius" format="dimension" />
<attr name="outer_circle_color" format="reference|color" />
<attr name="middle_circle_color" format="reference|color" />
<attr name="inner_circle_color" format="reference|color" />
</declare-styleable>
</resources>
//1.2 在 View 建構函式中獲取自定義 View 屬性
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
mRadius = typedArray.getDimension(R.styleable.CircleView_circle_radius, getResources().getDimension(R.dimen.avatar_size));
mOuterCircleColor = typedArray.getColor(R.styleable.CircleView_outer_circle_color, getResources().getColor(R.color.purple_500));
mMiddleCircleColor = typedArray.getColor(R.styleable.CircleView_middle_circle_color, getResources().getColor(R.color.purple_500));
mInnerCircleColor = typedArray.getColor(R.styleable.CircleView_inner_circle_color, getResources().getColor(R.color.purple_500));
typedArray.recycle();
複製程式碼
- 重寫測量階段相關方法(onMeasure())
//2. onMeasure()
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//2.1 根據 View 特點或業務需求計算出 View 的尺寸
mWidth = (int)(mRadius * 2);
mHeight = (int)(mRadius * 2);
//2.2 通過 resolveSize() 方法修正結果
mWidth = resolveSize(mWidth, widthMeasureSpec);
mHeight = resolveSize(mHeight, heightMeasureSpec);
//2.3 通過 setMeasuredDimension() 儲存 View 的期望尺寸(通過 setMeasuredDimension() 告知父 View 的期望尺寸)
setMeasuredDimension(mWidth, mHeight);
}
複製程式碼
- 重寫佈局階段相關方法(onLayout()(僅 ViewGroup 需要重寫))
由於沒有子 View 需要佈局,所以,不用重寫該方法。
- 重寫繪製階段相關方法(onDraw() 繪製主體、dispatchDraw() 繪製子 View 和 onDrawForeground() 繪製前景)
//4. 重寫 onDraw() 方法,自定義 View 內容
@Override
protected void onDraw(Canvas canvas) {
mPaint.setColor(mOuterCircleColor);
canvas.drawCircle(mRadius, mRadius, mRadius, mPaint);
mPaint.setColor(mMiddleCircleColor);
canvas.drawCircle(mRadius, mRadius, mRadius * 2/3, mPaint);
mPaint.setColor(mInnerCircleColor);
canvas.drawCircle(mRadius, mRadius, mRadius/3, mPaint);
}
複製程式碼
- onTouchEvent()
由於 View 不需要和使用者互動,所以,不用重寫該方法。
- onInterceptTouchEvent()(僅 ViewGroup 有此方法)
ViewGroup 的方法。
完整程式碼如下:
//1. 自定義屬性的宣告
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!--CircleView-->
<declare-styleable name="CircleView">
<attr name="circle_radius" format="dimension" />
<attr name="outer_circle_color" format="reference|color" />
<attr name="middle_circle_color" format="reference|color" />
<attr name="inner_circle_color" format="reference|color" />
</declare-styleable>
</resources>
//2. MeasuredCircleView
public class MeasuredCircleView extends View {
private int mWidth, mHeight;
private float mRadius;
private int mOuterCircleColor, mMiddleCircleColor, mInnerCircleColor;
private Paint mPaint;
public MeasuredCircleView(Context context) {
this(context, null);
}
public MeasuredCircleView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public MeasuredCircleView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initData(context, attrs);
}
private void initData(Context context, AttributeSet attrs) {
//1. 自定義屬性的宣告與獲取
TypedArray typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
mRadius = typedArray.getDimension(R.styleable.CircleView_circle_radius, getResources().getDimension(R.dimen.avatar_size));
mOuterCircleColor = typedArray.getColor(R.styleable.CircleView_outer_circle_color, getResources().getColor(R.color.purple_500));
mMiddleCircleColor = typedArray.getColor(R.styleable.CircleView_middle_circle_color, getResources().getColor(R.color.purple_500));
mInnerCircleColor = typedArray.getColor(R.styleable.CircleView_inner_circle_color, getResources().getColor(R.color.purple_500));
typedArray.recycle();
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(mOuterCircleColor);
}
//2. 重寫測量階段相關方法(onMeasure());
//由於不需要自定義 View 的尺寸,所以不用重寫該方法
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//2.1 根據 View 特點或業務需求計算出 View 的尺寸
mWidth = (int)(mRadius * 2);
mHeight = (int)(mRadius * 2);
//2.2 通過 resolveSize() 方法修正結果
mWidth = resolveSize(mWidth, widthMeasureSpec);
mHeight = resolveSize(mHeight, heightMeasureSpec);
//2.3 通過 setMeasuredDimension() 儲存 View 的期望尺寸(通過 setMeasuredDimension() 告知父 View 的期望尺寸)
setMeasuredDimension(mWidth, mHeight);
}
//3. 重寫佈局階段相關方法(onLayout()(僅 ViewGroup 需要重寫));
//由於沒有子 View 需要佈局,所以不用重寫該方法
// @Override
// protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
// super.onLayout(changed, left, top, right, bottom);
// }
//4. 重寫繪製階段相關方法(onDraw() 繪製主體、dispatchDraw() 繪製子 View 和 onDrawForeground() 繪製前景);
@Override
protected void onDraw(Canvas canvas) {
mPaint.setColor(mOuterCircleColor);
canvas.drawCircle(mRadius, mRadius, mRadius, mPaint);
mPaint.setColor(mMiddleCircleColor);
canvas.drawCircle(mRadius, mRadius, mRadius * 2/3, mPaint);
mPaint.setColor(mInnerCircleColor);
canvas.drawCircle(mRadius, mRadius, mRadius/3, mPaint);
}
}
//3. 在 xml 中應用 MeasuredCircleView
<?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"
android:gravity="center"
android:orientation="vertical"
tools:context=".custom_view_measure_draw.CustomViewMeasureDrawActivity">
<com.smart.a03_view_custom_view_example.custom_view_measure_draw.MeasuredCircleView
android:layout_width="match_parent"
android:layout_height="match_parent"
app:circle_radius="@dimen/padding_ninety_six"
app:inner_circle_color="@color/yellow_500"
app:middle_circle_color="@color/cyan_500"
app:outer_circle_color="@color/green_500" />
</LinearLayout>
複製程式碼
最終效果如下:
當在 xml 中將 MeasuredCircleView 的寬、高宣告為「match_parent」時,顯示效果跟 CircleView 顯示效果一樣。
開發者對 View 的尺寸要求 | View 的父 View 對 View 的尺寸要求 | View 的期望尺寸 |
---|---|---|
android:layout_width="match_parent" android:layout_height="match_parent" |
MeasureSpec.EXACTLY specSize |
specSize |
但是,當在 xml 中將 MeasuredCircleView 的寬、高宣告為「wrap_content」時,顯示效果是下面這個樣子:
其實,也很好理解:
開發者對 View 的尺寸要求 | View 的父 View 對 View 的尺寸要求 | View 的期望尺寸 |
---|---|---|
android:layout_width="wrap_content" android:layout_height="wrap_content" |
MeasureSpec.AT_MOST specSize |
if(childSize < specSize) childSize if(childSize > specSize) specSize |
4.2 自定義 ViewGroup
自定義 ViewGroup,標籤佈局,效果圖如下:
無論是自定義 View 還是自定義 ViewGroup,大致的流程都是一樣的:
- 自定義屬性的宣告與獲取;
- 重寫測量階段相關方法(onMeasure());
- 重寫佈局階段相關方法(onLayout()(僅 ViewGroup 需要重寫));
- 重寫繪製階段相關方法(onDraw() 繪製主體、dispatchDraw() 繪製子 View 和 onDrawForeground() 繪製前景);
- onTouchEvent();
- onInterceptTouchEvent()(僅 ViewGroup 有此方法);
只不過,大多數情況下,ViewGroup 不需要「自定義屬性」和「重寫繪製階段相關方法」,但有些時候還是需要的,如,開發者想在 ViewGroup 的所有子 View 上方繪製一些內容,就可以通過重寫 ViewGroup 的 onDrawForeground() 來實現。
- 自定義屬性的宣告與獲取
在自定義 ViewGroup 中「自定義屬性的宣告與獲取」的方法與在自定義 View 中「自定義屬性的宣告與獲取」的方法一樣,且因為大多數情況下,在自定義 ViewGroup 中是不需要自定義屬性的,所以,在這裡就不自定義屬性了。
- 重寫測量階段相關方法(onMeasure())
//2. 重寫測量階段相關方法(onMeasure());
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//2.1 解析 ViewGroup 的父 View 對 ViewGroup 的尺寸要求
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(widthMeasureSpec);
//2.2 ViewGroup 根據「開發者在 xml 中寫的對 ViewGroup 子 View 的尺寸要求」、「自己的父 View(ViewGroup 的父 View)對自己的尺寸要求」和
//「自己的可用空間」計算出自己對子 View 的尺寸要求,並將該尺寸要求通過子 View 的 measure() 方法傳給子 View,讓子 View 測量自己(View)的期望尺寸
int widthUsed = 0;
int heightUsed = getPaddingTop();
int lineHeight = 0;
int lineWidthUsed = getPaddingLeft();
int maxRight = widthSize - getPaddingRight();
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, heightUsed);
//是否需要換行
if(widthMode != MeasureSpec.UNSPECIFIED && (lineWidthUsed + child.getMeasuredWidth() > maxRight)){
lineWidthUsed = getPaddingLeft();
heightUsed += lineHeight + mRowSpace;
lineHeight = 0;
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, heightUsed);
}
//2.3 ViewGroup 暫時儲存子 View 的尺寸,以便佈局階段和繪製階段使用
Rect childBound;
if(mChildrenBounds.size() <= i){
childBound = new Rect();
mChildrenBounds.add(childBound);
}else{
childBound = mChildrenBounds.get(i);
}
//此處不能用 child.getxxx() 獲取子 View 的尺寸值,因為子 View 只是量了尺寸,還沒有佈局,這些值都是 0
// childBound.set(child.getLeft(), child.getTop(), child.getRight(), child.getBottom());
childBound.set(lineWidthUsed, heightUsed, lineWidthUsed + child.getMeasuredWidth(), heightUsed + child.getMeasuredHeight());
lineWidthUsed += child.getMeasuredWidth() + mItemSpace;
widthUsed = Math.max(lineWidthUsed, widthUsed);
lineHeight = Math.max(lineHeight, child.getMeasuredHeight());
}
//2.4 ViewGroup 將「根據子 View 的實際尺寸計算出的自己(ViewGroup)的尺寸」結合「自己父 View 對自己的尺寸要求」進行修正,並通
//過 setMeasuredDimension() 方法告知父 View 自己的期望尺寸
int measuredWidth = resolveSize(widthUsed, widthMeasureSpec);
int measuredHeight = resolveSize((heightUsed + lineHeight + getPaddingBottom()), heightMeasureSpec);
setMeasuredDimension(measuredWidth, measuredHeight);
}
//重寫generateLayoutParams()
//2.2.1 在自定義 ViewGroup 中呼叫 measureChildWithMargins() 方法計算 ViewGroup 對子 View 的尺寸要求時,
//必須在 ViewGroup 中重寫 generateLayoutParams() 方法,因為 measureChildWithMargins() 方法中用到了 MarginLayoutParams,
//如果不重寫 generateLayoutParams() 方法,那呼叫 measureChildWithMargins() 方法時,MarginLayoutParams 就為 null,
//所以在自定義 ViewGroup 中呼叫 measureChildWithMargins() 方法時,必須重寫 generateLayoutParams() 方法。
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new MarginLayoutParams(getContext(), attrs);
}
複製程式碼
- 重寫佈局階段相關方法(onLayout()(僅 ViewGroup 需要重寫))
//3. 重寫佈局階段相關方法(onLayout()(僅 ViewGroup 需要重寫));
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
for (int i = 0; i < getChildCount(); i++) {
//應用測量階段計算出的子 View 的尺寸值佈局子 View
View child = getChildAt(i);
Rect childBound = mChildrenBounds.get(i);
child.layout(childBound.left, childBound.top, childBound.right, childBound.bottom);
}
}
複製程式碼
- 重寫繪製階段相關方法(onDraw() 繪製主體、dispatchDraw() 繪製子 View 和 onDrawForeground() 繪製前景)
預設情況下,自定義 ViewGroup 時是不需要重寫任何繪製階段的方法的,因為 ViewGroup 的角色是容器,一個透明的容器,它只是用來盛放子 View 的。
注意:
- 預設情況下,系統會自動呼叫 View Group 的 dispatchDraw() 方法,所以不需要重寫該方法;
- 出於效率的考慮,ViewGroup 預設會繞過 draw() 方法,換而直接執行 dispatchDraw(),以此來簡化繪製流程。所以如果你自定義了一個 ViewGroup ,並且需要在它的除 dispatchDraw() 方法以外的任何一個繪製方法內繪製內容,你可能會需要呼叫 View.setWillNotDraw(false) 方法來切換到完整的繪製流程(是「可能」而不是「必須」的原因是,有些 ViewGroup 是已經呼叫過 setWillNotDraw(false) 了的,例如 ScrollView)。除了可以通過呼叫 View.setWillNotDraw(false) 方法來切換到完整的繪製流程之外,你還可以通過給 ViewGroup 設定背景來切換到完整的繪製流程。
- onTouchEvent()
由於 ViewGroup 不需要和使用者互動,所以,不用重寫該方法。
- onInterceptTouchEvent()(僅 ViewGroup 有此方法)
由於 ViewGroup 不需要和使用者互動且 ViewGroup 不需要攔截子 View 的 MotionEvent,所以,不用重寫該方法。
完整程式碼如下:
//1. TabLayout
public class TabLayout extends ViewGroup {
private ArrayList<Rect> mChildrenBounds;
private int mItemSpace;
private int mRowSpace;
public TabLayout(Context context) {
this(context, null);
}
public TabLayout(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public TabLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
initData();
}
private void initData(){
mChildrenBounds = new ArrayList<>();
mItemSpace = (int)getResources().getDimension(R.dimen.padding_small);
mRowSpace = (int)getResources().getDimension(R.dimen.padding_small);
}
//2. 重寫測量階段相關方法(onMeasure());
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
//2.1 解析 ViewGroup 的父 View 對 ViewGroup 的尺寸要求
int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int heightMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSize = MeasureSpec.getSize(widthMeasureSpec);
//2.2 ViewGroup 根據「開發者在 xml 中寫的對 ViewGroup 子 View 的尺寸要求」、「自己的父 View(ViewGroup 的父 View)對自己的尺寸要求」和
//「自己的可用空間」計算出自己對子 View 的尺寸要求,並將該尺寸要求通過子 View 的 measure() 方法傳給子 View,讓子 View 測量自己(View)的期望尺寸
int widthUsed = 0;
int heightUsed = getPaddingTop();
int lineHeight = 0;
int lineWidthUsed = getPaddingLeft();
int maxRight = widthSize - getPaddingRight();
for (int i = 0; i < getChildCount(); i++) {
View child = getChildAt(i);
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, heightUsed);
//是否需要換行
if(widthMode != MeasureSpec.UNSPECIFIED && (lineWidthUsed + child.getMeasuredWidth() > maxRight)){
lineWidthUsed = getPaddingLeft();
heightUsed += lineHeight + mRowSpace;
lineHeight = 0;
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, heightUsed);
}
//2.3 ViewGroup 暫時儲存子 View 的尺寸,以便佈局階段和繪製階段使用
Rect childBound;
if(mChildrenBounds.size() <= i){
childBound = new Rect();
mChildrenBounds.add(childBound);
}else{
childBound = mChildrenBounds.get(i);
}
//此處不能用 child.getxxx() 獲取子 View 的尺寸值,因為子 View 只是量了尺寸,還沒有佈局,這些值都是 0
// childBound.set(child.getLeft(), child.getTop(), child.getRight(), child.getBottom());
childBound.set(lineWidthUsed, heightUsed, lineWidthUsed + child.getMeasuredWidth(), heightUsed + child.getMeasuredHeight());
lineWidthUsed += child.getMeasuredWidth() + mItemSpace;
widthUsed = Math.max(lineWidthUsed, widthUsed);
lineHeight = Math.max(lineHeight, child.getMeasuredHeight());
}
//2.4 ViewGroup 將「根據子 View 的實際尺寸計算出的自己(ViewGroup)的尺寸」結合「自己父 View 對自己的尺寸要求」進行修正,並通
//過 setMeasuredDimension() 方法告知父 View 自己的期望尺寸
int measuredWidth = resolveSize(widthUsed, widthMeasureSpec);
int measuredHeight = resolveSize((heightUsed + lineHeight + getPaddingBottom()), heightMeasureSpec);
setMeasuredDimension(measuredWidth, measuredHeight);
}
//2.2.1 在自定義 ViewGroup 中呼叫 measureChildWithMargins() 方法計算 ViewGroup 對子 View 的尺寸要求時,
//必須在 ViewGroup 中重寫 generateLayoutParams() 方法,因為 measureChildWithMargins() 方法中用到了 MarginLayoutParams,
//如果不重寫 generateLayoutParams() 方法,那呼叫 measureChildWithMargins() 方法時,MarginLayoutParams 就為 null,
//所以在自定義 ViewGroup 中呼叫 measureChildWithMargins() 方法時,必須重寫 generateLayoutParams() 方法。
@Override
public LayoutParams generateLayoutParams(AttributeSet attrs) {
return new MarginLayoutParams(getContext(), attrs);
}
//3. 重寫佈局階段相關方法(onLayout()(僅 ViewGroup 需要重寫));
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
for (int i = 0; i < getChildCount(); i++) {
//應用測量階段計算出的子 View 的尺寸值佈局子 View
View child = getChildAt(i);
Rect childBound = mChildrenBounds.get(i);
child.layout(childBound.left, childBound.top, childBound.right, childBound.bottom);
}
}
@Override
public boolean onInterceptHoverEvent(MotionEvent event) {
return super.onInterceptHoverEvent(event);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
return super.onTouchEvent(event);
}
}
//2. 在 xml 中應用 TabLayout
<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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:scrollbars="none"
tools:context=".MainActivity">
<com.smart.a04_view_custom_viewgroup_example.custom_layout.TabLayout
android:id="@+id/tag_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/grey_400"
android:padding="@dimen/padding_small">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/common_bg"
android:text="@string/spending_clothes" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/common_bg"
android:text="@string/spending_others" />
...
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/common_bg"
android:text="@string/november" />
</com.smart.a04_view_custom_viewgroup_example.custom_layout.TabLayout>
</ScrollView>
複製程式碼
最終效果如下:
5. 相關問題
5.1 大方向
- Activity、Window、View 之間的關係
- View 是如何顯示出來的?
- View 是如何顯示出來的?
- View 新增子 View 的時候是將子 View 新增到原來的 View Tree,那 Toast 顯示的時候呢?它是怎樣顯示的?
- View(ViewGroup) 佈局、繪製流程
- View(ViewGroup) 事件分發
5.2 小細節
- 用過 View 中的 onSaveInstanceState()/onRestoreInstanceState() 嗎?一般在什麼情況下使用?
- onMeasure() 會執行多次嗎?為什麼?舉例說明
- 能手動觸發嗎?如果能,怎麼做?如果能觸發,會出現什麼情況?
- onLayout() 會執行多次嗎?為什麼?
- 能手動觸發嗎?如果能,怎麼做?如果能觸發,會出現什麼情況?
- onDraw() 會執行多次嗎?為什麼?
- 能手動觸發嗎?如果能,怎麼做?如果能觸發,會出現什麼情況?
- requestLayout() 作用、使用場景、注意事項
- invalidate() 作用、使用場景、注意事項
- postInvalidate() 作用、使用場景、注意事項
- invalidate()、postInvalidate() 異同
- scrollBy、scrollTo 作用、使用場景、注意事項、二者的區別
5.3 如何優化自定義 View?
- 如何優化自定義 View?
- 如何優化自定義 ViewGroup?
6. 如何擴充?
- 結合 Drawable
- 結合動畫,讓 View 的內容變化顯得更加流暢
7. 總結
自定義 View 包括三部分內容:
- 佈局(Layout)
- 繪製(Drawing)
- 觸控反饋(Event Handling)
其中佈局階段確定了 View 的位置和尺寸,該階段主要是為了後面的繪製和觸控反饋做支援;繪製階段主要用於繪製 View 的內容(大多數情況下,只用實現 OnDraw 方法(Where)方法、按照指定順序呼叫相關 API(How)即可實現自定義繪製(What));觸控反饋階段確定了使用者點選了哪裡,三者相輔相成,缺一不可。