這是【Android 修煉手冊】系列第 9 篇文章,若是尚未看過前面系列文章,歡迎點擊 這裏 查看~php
自定義 View 內容整體來講仍是比較簡單,更多的是要知足具體的需求,因此本文內容並不太難,看起來比較愉悅。html
在學習如何自定義 View 以前,須要先了解一下 Android 系統裏,View 的繪製流程,熟悉了各個流程,咱們在自定義過程當中也就駕輕就熟了。java
Android View 的繪製流程是從 ViewRootImpl 的 performTraversals 開始的,會經歷下面的過程。 android
因此一個 view 的繪製主要有三個流程,measure 肯定寬度和高度,layout 肯定擺放的位置,draw 繪製 view 內容。 下面就依次看看這三個步驟。git
onMeasure 是用來測量 View 寬度和高度的,通常狀況下能夠理解爲在 onMeasure 之後 View 的寬度和高度就肯定了,而後咱們就可使用 getMeasuredWidth 和 getMeasuredHeight 來獲取 View 的寬高了。 咱們先看看 View 默認的 onMeasure 裏作了什麼事情。github
class View {
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
protected final void setMeasuredDimension(int measuredWidth, int measuredHeight) {
// ...
setMeasuredDimensionRaw(measuredWidth, measuredHeight);
}
private void setMeasuredDimensionRaw(int measuredWidth, int measuredHeight) {
mMeasuredWidth = measuredWidth;
mMeasuredHeight = measuredHeight;
mPrivateFlags |= PFLAG_MEASURED_DIMENSION_SET;
}
}
複製代碼
能夠看到裏面是調用了 setMeasuredDimension,這個方法是設置 View 的測量寬高的,其實內部就是給 mMeasuredWidth 和 mMeasuredHeight 設置了值。以後 getMeasuredWidth 和 getMeasuredHeight 就是獲取的這兩個值。canvas
這裏說一下 getMeasuredWidth/getMeasuredHeight 和 getWidth/getHeight 的區別,getMeasuredWidth/getMeasuredHeight 是獲取測量寬度和高度,也就是 onMeasure 之後肯定的值,至關因而通知了系統個人 View 應該是這麼大,可是 View 最終的寬度和高度是在 layout 之後才肯定的,也就是 getWidth 和 getHeight 的值。而 getWidth 的值是 right - left,getHeight 也相似。
通常狀況下 getMeasuredWidth/getMeasuredHeight 和 getWidth/getHeight 的值是相同的,可是要記住,這兩個值是能夠不一樣的。咱們能夠寫個小 demo 看看。api
class MyView constructor(context: Context?, attributes: AttributeSet?, defaultAttrStyle: Int) : TextView(context, attributes, defaultAttrStyle) {
constructor(context: Context?, attributes: AttributeSet?) : this(context, attributes, 0)
constructor(context: Context?) : this(context, null)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
setMeasuredDimension(100, 100)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
setFrame(0, 0, 100, 20)
super.onLayout(changed, 0, 0, 100, 20)
}
}
複製代碼
在上面的 MyView 中,onMeasure 裏經過 setMeasuredDimension 設置了寬高是 100 * 100,可是在 onLayout 中咱們設置了 setFrame 爲 (0, 0, 100, 20),經過計算,高度是 bottom - top。因此最後展現的高度就是 20。bash
在 onMeasure 函數中,有兩個參數,widthMeasureSpec 和 heightMeasureSpec,這個是傳入的父 View 能給予的最大寬高,和測量模式。 widthMeasureSpec 分爲 mode 和 size,經過 MeasureSpec.getMode(widthMeasureSpec) 能夠得到模式,MeasureSpec.getSize(widthMeasureSpec) 能夠獲取寬度。
其中 mode 有三種類型,UNSPECIFIED,AT_MOST,EXACTLY。
UNSPECIFIED 是不限制 View 的尺寸,根據實際狀況,想多大能夠設置多大。
AT_MOST 是最大就是父 View 的寬度/高度,也就是咱們在 xml 中設置。 wrap_content 的效果
EXACTLY 是肯定的 View 尺寸,咱們在 xml 中設置 一個固定的值或者父 View 是一個固定的值且子 View 設置了 match_parent。app
因此在 onMeasure 中,咱們要根據上面的狀況,正確的處理對應 mode 下的尺寸。
這裏咱們額外看一下 View 默認的 onMeasure 方法對各類 mode 的處理。
class View {
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:
result = specSize;
break;
}
return result;
}
}
複製代碼
默認對 AT_MOST 和 EXACTLY 的處理方式是同樣的,因此咱們對一個 View 設置 wrap_content 和 match_parent 的效果實際上是同樣的。
onLayout 是對 View 的位置進行擺放。在 layout 中經過 setFrame(left, top, right, bottom) 設置 View 的上下左右位置。這一步只要處理好 View 的位置便可。若是是 ViewGroup 及其子類,還要處理子 View 的位置。
onDraw 過程也比較簡單,就是繪製 View 的內容。分爲幾個步驟(基於 Sdk 28 源碼):
drawBackground 繪製背景
onDraw 繪製自身
dispatchDraw 繪製子 View
onDrawForeground 繪製前景
在繪製過程當中,有兩個類 Canvas 和 Paint。 須要特別注意一下。這兩個類是繪製過程當中經常使用的。
Canvas 中經常使用的一些 api 以下:
drawBitmap 繪製圖片
drawCircle 繪製圓形
drawLine 繪製直線
drawPoint 繪製點
drawText 繪製文字
具體的 api 在 developer.android.com/reference/a… 這裏查看。其實在開發中要養成查看官方文檔的習慣,畢竟官方的纔是權威的。
Paint 中一些經常使用的 api 以下:
setColor 設置顏色 setAntiAlias 抗鋸齒
setStyle 設置線條或者填充風格
setStrokeWidth 設置線條寬度
setStrokeCap 設置線頭形狀
setStrokeJoin 設置線條拐角的形狀
具體的 api 在 developer.android.com/reference/a… 在這裏查看。
關於觸摸事件以及滑動衝突,也是自定義 View 常常遇到的問題。在此以前,先要了解一下 View 的事件分發機制。
在 Android 系統中,觸摸事件是以 MotionEvent 傳遞給 View 的。在其中定義了一些經常使用的操做,ACTION_DOWN,ACTION_UP,ACTION_MOVE,ACTION_CANCEL 等等。分別表明了按下,擡起,以及中間的移動,事件取消等操做。
而咱們處理事件的本質,就是對這些操做進行判斷,在正確的時機去作正確的事情。而一些列事件操做就構成一次事件操做流,也就是一次用戶完整的操做。
觸摸事件的操做流都是以 ACTION_DOWN 爲起始。以 ACTION_UP 或者 ACTION_CANCEL 結束。
ACTION_DOWN -> ACTION_UP
ACTION_DOWN -> ACTION_MOVE -> ACTION_MOVE -> ... -> ACTION_UP
ACTION_DOWN -> ACTION_MOVE -> ACTION_MOVE -> ... -> ACTION_CANCEL
複製代碼
這裏須要注意的就是事件和事件流的區別。
在 View 中,處理觸摸事件的方法是 onTouchEvent(MotionEvent event),傳入的參數就是操做,咱們要處理觸摸事件的時候,就要重寫 onTouchEvent 方法,在其中作自定義的處理。
這裏值得注意的是,onTouchEvent 是有一個 boolean 類型的返回值的,這個返回值也很重要。返回值表明了本次【次事件流】是否要執行處理,若是返回 true,那麼就表示本次事件流都由本身全權負責,後續的【事件】就不出再傳遞給其餘 View 了。
由於表明的是整個事件流的處理,因此這個返回值只在 ACTION_DOWN 的時候有效,若是 ACTINO_DOWN 的時候返回 false,那麼後面就不會收到其餘的事件了。
若是 View 設置了 OnClickListener,那麼在默認的 View 裏的 onTouchEvent 中會在 ACTION_UP 的時候調用其 onClick。
上面說了 View 中觸摸事件的處理,若是是在 ViewGroup 中,在 onTouchEvent 以前還會有一個校驗 onInterceptTouchEvent,意思是是否攔截觸摸事件。
若是 onInterceptTouchEvent 返回 true,那麼說明須要攔截這次事件,就不會再分發事件給子 View 了。增長這個攔截之後,父 View 能夠把一些事件下方給子 View,在合適的還能進行攔截,把事件收回來作本身的處理。典型的應用就是列表中 item 的點擊和列表的滑動。
這裏強調一點,onInterceptTouchEvent 是事件流中的每一個【事件】到來時都會調用,而 onTouchEvent 若是在 ACTION_DOWN 之後返回 false,那麼【事件流】後續的事件就不會再收到了。
從上面的分析咱們知道了,ViewGroup 中若是遇到本身須要處理的事件,就會經過 onIntercepTouchEvent 攔截這個事件,這樣這個事件就不會傳遞到子 View 裏了。可是事情總有例外,若是某些事件子 View 想要本身來處理,不須要父 View 來插手,那麼就能夠調用 requestDisallowInterceptTouchEvent 告訴父 View 後面的事件不須要攔截。
這個只在一次【事件流】中有效,由於在父 View 收到 ACTION_DOWN 之後,會重置此標識位。
還有一個點是 onTouchListener,對於一個 View,能夠設置 OnTouchListener,在其 onTouch 方法中也能夠處理觸摸事件。若是 onTouch 中返回了 true,就表明消耗了此次事件,就不會再去調用 onTouchEvent 了。
上面說的幾個 View 以及 ViewGroup 的事件處理方法,都是在 dispatchTouchEvent 中進行分發的。整個事件分發機制能夠用下面的僞代碼來表示。
public boolean dispatchTouchEvent() {
boolean res = false;
if (onInterceptTouchEvent()) { // View 不會調用這個,直接執行下面的 touchlistener 判斷
if (mOnTouchListener && mOnTouchListener.onTouch()) { // 處理 OnTouchListener
return true;
}
// 沒有設置 OnTouchListener 或者其 onTouch 返回 false,就調用 onTouchEvent
res = onTouchEvent(); // -> clicklistener.onClick()
} else {
// 本次事件不須要攔截,就分發給子 View 去處理
for (child in childrenView) {
res = child.dispatchTouchEvent();
}
}
return res;
}
複製代碼
瞭解了上面自定義 View 的一些基礎知識,咱們看看自定義 View 經常使用的幾種方法。
這種方式通常是已有的控件功能沒法知足需求,須要在已有控件上進行擴展。一般只要實現咱們須要擴展的功能便可,比較簡單。
這種方式通常是對已有的一些控件的封裝,使用起來比較方便。
這種方式通常是已有控件沒法知足需求,因此須要咱們本身來繪製 View
在自定義 View 的時候,咱們常常須要加一些自定義的屬性,方便在 xml 中進行配置,相似 TextView 的 text。下面就看看自定義 View 屬性的方法。咱們以建立一個 MyView 的 message 屬性爲例。
先在 res/valuse 目錄下建立 attrs.xml,在其中添加自定義的屬性
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="MyView">
<attr name="message" format="string" />
</declare-styleable>
</resources>
複製代碼
<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:orientation="vertical">
<com.zy.myview.MyView android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="#ff6633" android:text="Hello World!" app:message="this is my view" />
</LinearLayout>
複製代碼
在使用自定義的屬性時,須要注意命名空間的問題,默認屬性的命名空間是 android,咱們這裏須要新增一個 xmlns:app="schemas.android.com/apk/res-aut…",使用自定義屬性的時候須要用這個命名空間 app:message=""。不過命名空間這一步,通常 AndroidStudio 會自動加上。
class MyView constructor(context: Context?, attributes: AttributeSet?, defaultAttrStyle: Int) : TextView(context, attributes, defaultAttrStyle) {
constructor(context: Context?, attributes: AttributeSet?) : this(context, attributes, 0)
constructor(context: Context?) : this(context, null)
init {
// 獲取 TypeArray
val typedArray = context?.obtainStyledAttributes(attributes, R.styleable.MyView)
// 獲取 message 屬性
val message = typedArray?.getString(R.styleable.MyView_message)
typedArray?.recycle() //注意回收
}
}
複製代碼
經過上面三個步驟,咱們就把自定義屬性用起來了。
經過上面的分析咱們知道了自定義 View 的關鍵點以及如何去自定義 View,下面就寫個例子實戰一下。
我這裏簡單寫了一個相似音量條的控件,能夠跟隨手指滑動提升下降音量,僅作自定義控件的演示,因此裏面的邏輯和 ui 可能比較醜,重點關注上面關鍵點的處理~
完整代碼在這裏查看
咱們這裏先看一下如何使用的這個控件
<?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:orientation="vertical" tools:context=".MainActivity">
<com.zy.myview.VolumeBar android:layout_width="match_parent" android:layout_height="50dp" android:layout_marginTop="10dp" app:col_color="@color/colorPrimaryDark" app:count="20" app:tip="vol" />
</LinearLayout>
複製代碼
這裏咱們把 VolumeBar 做爲一個總體控件來引用的,其中定義了 col_color,count,tip 三個屬性,分別表示音量條的顏色,音量條的數量,提示文案。屬性的定義以下:
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="VolumeBar">
<attr name="count" format="integer" />
<attr name="col_color" format="color" />
<attr name="tip" format="string" />
</declare-styleable>
</resources>
複製代碼
而後咱們再來分析一下 VolumeBar 這個控件,因爲這個控件內部還有音量條和文案,因此咱們採用了【繼承特定 ViewGroup,組合各類 View】這種方式來實現控件。VolumeBar 繼承自 LinearLayout,而後在內部組合了音量條控件和提示文案控件,咱們先看看關鍵代碼。
class VolumeBar constructor(context: Context?, attributes: AttributeSet?, defaultAttrStyle: Int) : LinearLayout(context, attributes, defaultAttrStyle) {
init {
// 解析自定義屬性
val typedArray = context?.obtainStyledAttributes(attributes, R.styleable.VolumeBar)
tip = typedArray?.getString(R.styleable.VolumeBar_tip) ?: ""
count = typedArray?.getInt(R.styleable.VolumeBar_count, 20) ?: 20
color = typedArray?.getColor(R.styleable.VolumeBar_col_color, context.resources.getColor(R.color.colorPrimary))
?: context!!.resources.getColor(R.color.colorPrimary)
typedArray?.recycle() //注意回收
gravity = Gravity.CENTER_VERTICAL
// 處理子 View
initVolumeView()
}
private fun initVolumeView() {
val params = LinearLayout.LayoutParams(LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
params.leftMargin = 10
// 添加音量條子 View
(0 until count).forEach { _ ->
val view = VolumeView(context)
addView(view, params)
viewList.add(view)
}
// 添加文案
text = TextView(context)
text.text = "$tip 0"
addView(text, params)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
// 處理觸摸事件
when (event.action) {
MotionEvent.ACTION_DOWN,
MotionEvent.ACTION_MOVE -> {
handleEvent(event)
}
}
return true
}
// 觸摸事件的處理邏輯,主要是在查找當前觸摸事件的位置,肯定是在第幾個子 View 上,而後將此子 View 以前的全部子 View 都設置成實心的
private fun handleEvent(event: MotionEvent) {
val index = getCurIndex(event.x)
// 設置子 View 爲實心
}
private fun getCurIndex(x: Float): Int {
val pos = IntArray(2)
var res = -1
// 遍歷子 View,肯定當前觸摸事件的位置
viewList.forEachIndexed { index, view ->
view.getLocationOnScreen(pos)
if ((pos[0] + view.width) <= x) {
res = index
}
}
return res
}
}
複製代碼
咱們這裏主要關注自定義屬性的解析,和 onTouchEvent 觸摸事件的處理。其中 MotionEvent 攜帶了當前事件的位置,因此咱們遍歷子 View,來肯定當前觸摸的位置是在哪一個子 View 上,而後將其以前的 View 所有繪製成實心的。
而後再看看音量條 VolumeView 的實現。VolumeView 是採用【繼承 View 重寫 onDraw】方式來實現的。
class VolumeView constructor(context: Context?, attributes: AttributeSet?, defaultAttrStyle: Int) : View(context, attributes, defaultAttrStyle) {
val DEFAULT_LENGTH = 50
var color: Int = 0
var full: Boolean = false
var paint: Paint = Paint()
constructor(context: Context?, attributes: AttributeSet?) : this(context, attributes, 0)
constructor(context: Context?) : this(context, null)
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val height = MeasureSpec.getSize(heightMeasureSpec)
setMeasuredDimension(height / 5, height)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
color = if (color > 0) color else context.resources.getColor(R.color.colorPrimary)
paint.isAntiAlias = true
paint.color = color
if (full) {
paint.style = Paint.Style.FILL
} else {
paint.style = Paint.Style.STROKE
}
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
}
}
複製代碼
這裏的實現主要在演示 onMeasure 和 onDraw 的做用,咱們在 onMeasure 中設置了寬高,其中寬度是高度的五分之一,而後在 onDraw 中經過 Canvas.drawReact() 繪製了長方形的音量條。
其實這樣看下來,自定義 View 也沒有那麼難,來本身動手試試吧~