【Android 修炼手册】常用技术篇 -- Android 自定义 View
這是【Android 修煉手冊】系列第 9 篇文章,如果還沒有看過前面系列文章,歡迎點擊 這里 查看~
預備知識
看完本文可以達到什么程度
閱讀前準備工作
文章概覽
自定義 View 內容總體來說還是比較簡單,更多的是要滿足具體的需求,所以本文內容并不太難,看起來比較愉悅。
在學習如何自定義 View 之前,需要先了解一下 Android 系統里,View 的繪制流程,熟悉了各個流程,我們在自定義過程中也就得心應手了。
一、Android View 繪制流程
Android View 的繪制流程是從 ViewRootImpl 的 performTraversals 開始的,會經歷下面的過程。
所以一個 view 的繪制主要有三個流程,measure 確定寬度和高度,layout 確定擺放的位置,draw 繪制 view 內容。 下面就依次看看這三個步驟。
1.1 onMeasure
onMeasure 是用來測量 View 寬度和高度的,一般情況下可以理解為在 onMeasure 以后 View 的寬度和高度就確定了,然后我們就可以使用 getMeasuredWidth 和 getMeasuredHeight 來獲取 View 的寬高了。 我們先看看 View 默認的 onMeasure 里做了什么事情。
setMeasuredDimension
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 就是獲取的這兩個值。
getMeasuedWidth/getMeasuredHeight 和 getWidth/getHeight
這里說一下 getMeasuredWidth/getMeasuredHeight 和 getWidth/getHeight 的區別,getMeasuredWidth/getMeasuredHeight 是獲取測量寬度和高度,也就是 onMeasure 以后確定的值,相當于是通知了系統我的 View 應該是這么大,但是 View 最終的寬度和高度是在 layout 以后才確定的,也就是 getWidth 和 getHeight 的值。而 getWidth 的值是 right - left,getHeight 也類似。
一般情況下 getMeasuredWidth/getMeasuredHeight 和 getWidth/getHeight 的值是相同的,但是要記住,這兩個值是可以不同的。我們可以寫個小 demo 看看。
在上面的 MyView 中,onMeasure 里通過 setMeasuredDimension 設置了寬高是 100 * 100,但是在 onLayout 中我們設置了 setFrame 為 (0, 0, 100, 20),通過計算,高度是 bottom - top。所以最后展示的高度就是 20。
MeasureSpec
在 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。
所以在 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 的效果其實是一樣的。
1.2 onLayout
onLayout 是對 View 的位置進行擺放。在 layout 中通過 setFrame(left, top, right, bottom) 設置 View 的上下左右位置。這一步只要處理好 View 的位置即可。如果是 ViewGroup 及其子類,還要處理子 View 的位置。
1.3 onDraw
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 的事件分發機制。
2.1 事件和事件流
在 Android 系統中,觸摸事件是以 MotionEvent 傳遞給 View 的。在其中定義了一些常用的操作,ACTION_DOWN,ACTION_UP,ACTION_MOVE,ACTION_CANCEL 等等。分別代表了按下,抬起,以及中間的移動,事件取消等操作。
而我們處理事件的本質,就是對這些操作進行判斷,在正確的時機去做正確的事情。而一些列事件操作就構成一次事件操作流,也就是一次用戶完整的操作。
觸摸事件的操作流都是以 ACTION_DOWN 為起始。以 ACTION_UP 或者 ACTION_CANCEL 結束。
這里需要注意的就是事件和事件流的區別。
2.2 onTouchEvent
在 View 中,處理觸摸事件的方法是 onTouchEvent(MotionEvent event),傳入的參數就是操作,我們要處理觸摸事件的時候,就要重寫 onTouchEvent 方法,在其中做自定義的處理。
這里值得注意的是,onTouchEvent 是有一個 boolean 類型的返回值的,這個返回值也很重要。返回值代表了本次【次事件流】是否要執行處理,如果返回 true,那么就表示本次事件流都由自己全權負責,后續的【事件】就不出再傳遞給其他 View 了。
因為代表的是整個事件流的處理,所以這個返回值只在 ACTION_DOWN 的時候有效,如果 ACTINO_DOWN 的時候返回 false,那么后面就不會收到其他的事件了。
如果 View 設置了 OnClickListener,那么在默認的 View 里的 onTouchEvent 中會在 ACTION_UP 的時候調用其 onClick。
2.3 onInterceptTouchEvent
上面說了 View 中觸摸事件的處理,如果是在 ViewGroup 中,在 onTouchEvent 之前還會有一個校驗 onInterceptTouchEvent,意思是是否攔截觸摸事件。
如果 onInterceptTouchEvent 返回 true,那么說明需要攔截此次事件,就不會再分發事件給子 View 了。增加這個攔截以后,父 View 可以把一些事件下方給子 View,在合適的還能進行攔截,把事件收回來做自己的處理。典型的應用就是列表中 item 的點擊和列表的滑動。
這里強調一點,onInterceptTouchEvent 是事件流中的每個【事件】到來時都會調用,而 onTouchEvent 如果在 ACTION_DOWN 以后返回 false,那么【事件流】后續的事件就不會再收到了。
2.4 requestDisallowInterceptTouchEvent
從上面的分析我們知道了,ViewGroup 中如果遇到自己需要處理的事件,就會通過 onIntercepTouchEvent 攔截這個事件,這樣這個事件就不會傳遞到子 View 里了。但是事情總有例外,如果某些事件子 View 想要自己來處理,不需要父 View 來插手,那么就可以調用 requestDisallowInterceptTouchEvent 告訴父 View 后面的事件不需要攔截。
這個只在一次【事件流】中有效,因為在父 View 收到 ACTION_DOWN 以后,會重置此標識位。
2.5 OnTouchListener
還有一個點是 onTouchListener,對于一個 View,可以設置 OnTouchListener,在其 onTouch 方法中也可以處理觸摸事件。如果 onTouch 中返回了 true,就代表消耗了這次事件,就不會再去調用 onTouchEvent 了。
2.6 dispatchTouchEvent
上面說的幾個 View 以及 ViewGroup 的事件處理方法,都是在 dispatchTouchEvent 中進行分發的。整個事件分發機制可以用下面的偽代碼來表示。
public boolean dispatchTouchEvent() {boolean res = false;if (onInterceptTouchEvent()) { // View 不會調用這個,直接執行下面的 touchlistener 判斷if (mOnTouchListener && mOnTouchListener.onTouch()) { // 處理 OnTouchListenerreturn true;}// 沒有設置 OnTouchListener 或者其 onTouch 返回 false,就調用 onTouchEventres = onTouchEvent(); // -> clicklistener.onClick()} else {// 本次事件不需要攔截,就分發給子 View 去處理for (child in childrenView) {res = child.dispatchTouchEvent();}}return res; } 復制代碼三、自定義 view 的幾種方式
了解了上面自定義 View 的一些基礎知識,我們看看自定義 View 常用的幾種方法。
1. 繼承特定的 View 實現增強功能
這種方式一般是已有的控件功能無法滿足需求,需要在已有控件上進行擴展。通常只要實現我們需要擴展的功能即可,比較簡單。
2. 繼承特定的 ViewGroup,組合各種 View
這種方式一般是對已有的一些控件的封裝,使用起來比較方便。
3. 繼承 View 實現 onDraw 方法
這種方式一般是已有控件無法滿足需求,所以需要我們自己來繪制 View
四、設置自定義 View 的屬性
在自定義 View 的時候,我們經常需要加一些自定義的屬性,方便在 xml 中進行配置,類似 TextView 的 text。下面就看看自定義 View 屬性的方法。我們以創建一個 MyView 的 message 屬性為例。
1. 在 xml 定義需要的屬性
先在 res/valuse 目錄下創建 attrs.xml,在其中添加自定義的屬性
xml version="1.0" encoding="utf-8" <resources><declare-styleable name="MyView"><attr name="message" format="string" /></declare-styleable> </resources> 復制代碼2. 在 xml 中使用屬性
<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.MyViewandroid: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 會自動加上。
3. 在 java 類中獲取屬性
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 {// 獲取 TypeArrayval 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.VolumeBarandroid: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) ?: 20color = 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// 處理子 ViewinitVolumeView()}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 = 50var color: Int = 0var full: Boolean = falsevar 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 = truepaint.color = colorif (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 也沒有那么難,來自己動手試試吧~
總結
歡迎關注下面賬號,獲取最新技術文章:
Github
掘金
知乎
總結
以上是生活随笔為你收集整理的【Android 修炼手册】常用技术篇 -- Android 自定义 View的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 《魔兽世界》6.0奥法天赋选择 亲儿子怎
- 下一篇: 一文搞懂MySQL的Join