Android 入門(三)簡單自定義 View

wendraw發表於2019-02-27

知識點摘要:只需要會簡單的自定義 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 和固定尺寸,三種測量模式與 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>
複製程式碼

自定義 view
Custom View

自定義佈局屬性

不知道你在寫佈局檔案的時候,有沒有想過這樣的問題,在佈局檔案中設定控制元件的 layout_width 屬性的值之後,相應的 View 物件就會改變,這是怎麼實現的呢?我們的 CustomView 可不可以自己定義一個這樣的佈局檔案上可以用的屬性呢?

我們在使用 view 時會發現,defaultSize 值被我們寫死了,如果有別的開發者想使用我們的 CustomView,但是預設大小想設定為 200,就需要去修改原始碼,這就破壞了程式碼的封裝特性,有的人會說我們可以增加 getDefaultSize、setDefaultSize 方法,這個方法沒有問題,但是還不夠優雅,其實 Google 已經幫我們優雅的實現了,就本節要講到的 AttributeSet。

我們在重寫建構函式時,其實埋下了一個伏筆,為什麼我們要實現 public CustomView(Context context, AttributeSet attrs) 方法呢?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 介面的左上角看到我們剛剛建立的控制元件:

Project Custom View
自定義控制元件

自定義 ViewGroup

我們寫一個佈局檔案用到的就是兩個元素,控制元件、佈局。控制元件在上一節已經講了,這一節我們一起來學習佈局 ViewGroup。佈局就是一個 View 容器,其作用就是決定控制元件的擺放位置。

其實官方給我們提供的六個佈局已經夠用了,我們學習自定義 view 主要是為了在使用佈局的時候更好的理解其原理。既然是佈局就要滿足幾個條件:

  1. 首先要知道子 view 的大小,才能根據子 View 才能設定 ViewGroup 的大小。
  2. 然後要知道佈局功能,也就是子 View 需要怎麼擺放,知道子 View 的尺寸和擺放方式才能確定 ViewGroup 的大小。
  3. 最後就是將子 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 跟平常使用控制元件、佈局的方式一樣,我們組合起來其效果如下:

demo
自定義 View 和 ViewGroup

深入學習自定義 View

通過上面的學習你應該對自定義 View 和 ViewGroup 有一定的認識,甚至覺得還有一點點簡單,接下來你就可以學習一下更復雜的 View。比如小米時鐘,你可以先嚐試自己實現,不會的再參考我的程式碼。

MiClock Demo
MiClock Demo

結束

在入門階段我們不需要去詳細 onMeasure、onLayout 和 onDraw 的過程,只需要會簡單的自定義 View、ViewGroup 即可,切記只見樹木不見森林。最後還提供了一個比較複雜小米時針 View, 非常值得自己動手學習。

所有的程式碼都上傳到了 GayHub CustomViewExample,歡迎拍磚。

參考

自定義View,有這一篇就夠了

Github-MiClockView

相關文章