【Android 修煉手冊】經常使用技術篇 -- Android 自定義 View

這是【Android 修煉手冊】系列第 9 篇文章,若是尚未看過前面系列文章,歡迎點擊 這裏 查看~php

預備知識

  1. 瞭解 android 基本開發

看完本文能夠達到什麼程度

  1. 學會自定義 View 以及其中的關鍵點

閱讀前準備工做

  1. clone CommonTec 項目,其中 myview 是自定義 View 的代碼

文章概覽

summary

自定義 View 內容整體來講仍是比較簡單,更多的是要知足具體的需求,因此本文內容並不太難,看起來比較愉悅。html

在學習如何自定義 View 以前,須要先了解一下 Android 系統裏,View 的繪製流程,熟悉了各個流程,咱們在自定義過程當中也就駕輕就熟了。java

1、Android View 繪製流程

Android View 的繪製流程是從 ViewRootImpl 的 performTraversals 開始的,會經歷下面的過程。 android

view

因此一個 view 的繪製主要有三個流程,measure 肯定寬度和高度,layout 肯定擺放的位置,draw 繪製 view 內容。 下面就依次看看這三個步驟。git

1.1 onMeasure

onMeasure 是用來測量 View 寬度和高度的,通常狀況下能夠理解爲在 onMeasure 之後 View 的寬度和高度就肯定了,而後咱們就可使用 getMeasuredWidth 和 getMeasuredHeight 來獲取 View 的寬高了。 咱們先看看 View 默認的 onMeasure 裏作了什麼事情。github

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 就是獲取的這兩個值。canvas

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 看看。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

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。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 的效果實際上是同樣的。

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… 在這裏查看。

2、觸摸事件以及滑動衝突

關於觸摸事件以及滑動衝突,也是自定義 View 常常遇到的問題。在此以前,先要了解一下 View 的事件分發機制。

2.1 事件和事件流

在 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
複製代碼

這裏須要注意的就是事件事件流的區別。

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()) { // 處理 OnTouchListener
           return true;
       }
       // 沒有設置 OnTouchListener 或者其 onTouch 返回 false,就調用 onTouchEvent
       res = onTouchEvent(); // -> clicklistener.onClick()
   } else {
       // 本次事件不須要攔截,就分發給子 View 去處理
       for (child in childrenView) {
           res = child.dispatchTouchEvent();
       }
   }
   return res;
}
複製代碼

3、自定義 view 的幾種方式

瞭解了上面自定義 View 的一些基礎知識,咱們看看自定義 View 經常使用的幾種方法。

1. 繼承特定的 View 實現加強功能

這種方式通常是已有的控件功能沒法知足需求,須要在已有控件上進行擴展。一般只要實現咱們須要擴展的功能便可,比較簡單。

2. 繼承特定的 ViewGroup,組合各類 View

這種方式通常是對已有的一些控件的封裝,使用起來比較方便。

3. 繼承 View 實現 onDraw 方法

這種方式通常是已有控件沒法知足需求,因此須要咱們本身來繪製 View

4、設置自定義 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.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 會自動加上。

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 {
        // 獲取 TypeArray
        val typedArray = context?.obtainStyledAttributes(attributes, R.styleable.MyView)
        // 獲取 message 屬性
        val message = typedArray?.getString(R.styleable.MyView_message)
        typedArray?.recycle()  //注意回收
    }
}
複製代碼

經過上面三個步驟,咱們就把自定義屬性用起來了。

5、實例分析

經過上面的分析咱們知道了自定義 View 的關鍵點以及如何去自定義 View,下面就寫個例子實戰一下。
我這裏簡單寫了一個相似音量條的控件,能夠跟隨手指滑動提升下降音量,僅作自定義控件的演示,因此裏面的邏輯和 ui 可能比較醜,重點關注上面關鍵點的處理~
完整代碼在這裏查看

vol

咱們這裏先看一下如何使用的這個控件

<?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 也沒有那麼難,來本身動手試試吧~

總結

summary

關於我

about
相關文章
相關標籤/搜索