1. 程式人生 > >自定義元件開發六 自定義元件

自定義元件開發六 自定義元件

概述

Android SDK 為我們提供了一套完整的元件庫,數量多、功能強,涉及到方方面面,但是,我們依然看到軟體市場上的每個 App 都有自己獨特的東西,絕不是千遍一律的,而且也會和 IOS相互借鑑,這就需要我們對元件進行定製,實現自己獨樹一幟的使用者體驗和介面風格。自定義元件到底難不難呢?如果前面五章的內容掌握好了,其實並不難。不管是普通的元件還是容器,開發時都有章可循的,找到其中的規律,根據實際的使用者需求,一步步慢慢就能實現。學習要從簡單的開始,不要想著一口吃成胖子,眼高手低,而是慢慢加大難度,循序漸進,方可成佛。另外,建議多閱讀優秀原始碼,學習別人的思維模式和程式設計技巧,可能會有豁然開朗的功效。當然,最好的原始碼自然是 Google 提供的官方 Android API Demos 了,裡面包含了開發的方方面面,這是一份最權威的 Demo 原始碼。

通常來說,自定義元件有三種定義方式:
Ø 從 0 開始定義自定義元件,元件類繼承自 View;
Ø 從已有元件擴充套件,比如,從 ImageView 類擴展出功能更強或者更有個性化的元件;
Ø 將多個已有元件合成一個新的元件,比如,側邊帶字母索引的 ListView。
本書將向大家介紹這三種元件的建立方式。技術永遠說不完,最重要的是大家在學習過程中要觸類旁通,舉一反三,將技術學“活”。還是那句話,實踐是通往真理的唯一通道。

自定義元件的基本結構

元件主要由兩部分構成:元件類和屬性定義。我們從第一種定義方式說起。
建立自定義元件類最基本的做法就是繼承自類 View,其中,有三個構造方法和兩個重寫的
方法又是重中之重。下面是自定義元件類的基本結構:

public class FirstView extends View {
    public FirstView(Context context) {
        super(context);
    }

    public FirstView(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }


    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
    }

    @Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); } }

上述程式碼中,我們定義了一個名為 FirstView 的類,該類繼承自 View,同時,為該類定義了三個構造方法並重寫了另外兩個方法:
Ø 構造方法
public FirstView(Context context)
public FirstView(Context context, AttributeSet attrs)
public FirstView(Context context, AttributeSet attrs, int defStyleAttr)
這三個構造方法的呼叫場景其實並不一樣,第一個只有一個引數,在程式碼中建立元件
時會呼叫該構造方法,比如建立一個按鈕:Button btnOK = new Button(this),this 是指
當前的 Activity,Activity 是 Context 的子類。第二個方法在 layout 佈局檔案中使用時調
用,引數 attrs 表示當前配置中的屬性集合,例如在要 layout.xml 中定義一個按鈕:

<Button android:layout_width = "match_parent" android:layout_height = "wrap_co-ntent"android:text = "OK"/>

Android 會呼叫第二個構造方法 Inflate 出 Button 物件。而第三
個構造方法是不會自動呼叫的,當我們在 Theme 中定義了 Style 屬性時通常在第二個
構造方法中手動呼叫。
Ø 繪圖
protected void onDraw(Canvas canvas)
該方法我們再熟悉不過了,前面 5 個章節一直重寫了該方法,用於顯示元件的外觀。
最終的顯示結果需要通過 canvas 繪製出來。在 View 類中,該方法並沒有任何的預設
實現。
Ø 測量尺寸
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)
這是一個 protected 方法,意味著該方法主要用於子類的重寫和擴充套件,如果不重寫該方
法,父類 View 有自己的預設實現。在 Android 中,自定義元件的大小都由自身通過
onMeasure()進行測量,不管介面佈局有多麼複雜,每個元件都負責計算自己的大小。

重寫 onMeasure 方法

View 類對於 onMeasure()方法有自己的預設實現。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(
getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}

在該方法中,呼叫了 protected final void setMeasuredDimension(int measured-Width, int
measuredHeight)方法應用測量後的高度和寬度,這是必須呼叫的,以後我們可以呼叫
getMeasuredWidth()和 getMeasuredHeight()方法獲取這個寬度和高度值。大部分情況下,protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)方法都要重寫,用於計算元件的寬度值和高度值。定義元件時,必須指定 android:layout_width 和android:layout_height 屬性,屬性值有三種情況:match_parent、wrap_content 和具體值。match_parent 表示元件的大小跟隨父容器,所在的容器有多大,元件就有多大;wrap_content 表示元件的大小則內容決定,比如 TextView 元件的大小由文字的多少決定,ImageView 元件的大小由圖片的大小決定;如果是一個具體值,相對就簡單了,直接指定即可,單位為 dp。
總結來說,不管是寬度還是高度,都包含了兩個資訊:模式和大小。模式可能是match_parent、wrap_content 和具體值的任意一種,大小則要根據不同的模式進行計算。其實 match_parent 也是一個確定了的具體值,為什麼這樣說呢?因為 match_parent 的大小跟隨父容器,而容器本身也是一個元件,他會算出自己的大小,所以我們根本不需要去重複計算了,父容器多大,元件就有多大,View 的繪製流程會自動將父容器計算好的大小通過引數傳過來。

模式使用三個不同的常量來區別:
Ø MeasureSpec.EXACTLY
當元件的尺寸指定為 match_parent 或具體值時用該常量代表這種尺寸模式,很顯然,處於該模式的元件尺寸已經是測量過的值,不需要進行計算。
Ø MeasureSpec.AT_MOST
當元件的尺寸指定為wrap_content時用該常量表示,因為尺寸大小和內容有關,所以,我們要根據元件內的內容來測量元件的寬度和高度。比如 TextView 中的 text 屬性字串越長,寬度和高度就可能越大。
Ø MeasureSpec.UNSPECIFIED
未指定尺寸,這種情況不多,一般情況下,父控制元件為 AdapterView 時,通過 measure 方
法傳入。
最後,我們來考慮最關鍵的問題,如何獲得當前元件的尺寸模式和尺寸大小?祕密隱藏在
protected void onMeasure(int widthMeasureSpec, int heightMeasure-Spec)方法的引數中,引數widthMeasureSpec 和 heightMeasureSpec 看起來只是兩個整數,其實每個引數都包含了兩個值:模式和尺寸。我們知道,int 型別佔用 4 個位元組,一共 32 位,引數 widthMeasureSpec 和heightMeasureSpec 的前兩位代表模式,後 30 位則表示大小。
這裡寫圖片描述

真相大白,接下來繼續思考如何獲取 widthMeasureSpec 和 heightMeasureSpec 引數的前 2 位與後 30 位,其實通過位運算即可得到,我們以 widthMeasureSpec 為例:
獲取尺寸模式:widthMeasureSpec & 0x3 << 30
獲取尺寸大小:widthMeasureSpec << 2 >> 2
上面的寫法不一而足,顯然,這樣會給開發人員帶來難度,所以,提供了一個名為MeasureSpec 的類用於計算模式和大小:
int mode = MeasureSpec.getMode(widthMeasureSpec);
int size = MeasureSpec.getSize(widthMeasureSpec);
現在,我們來看看 onMeasure()的基本寫法吧,因為要同時考慮寬度和高度,往往會定義兩個方法分別計算,這樣顯然有更清晰的思路和邏輯。

public class FirstView extends View {
    public FirstView(Context context) {
        super(context);
    }

    public FirstView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    public FirstView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int width = measureWidth(widthMeasureSpec);
        int height = measureHeight(heightMeasureSpec);
        setMeasuredDimension(width, height);
    }

    private int measureWidth(int widthMeasureSpec) {
        int mode = MeasureSpec.getMode(widthMeasureSpec);
        int size = MeasureSpec.getSize(widthMeasureSpec);
        int width = 0;
        if (mode == MeasureSpec.EXACTLY) {
            //寬度為 match_parent 和具體值時,直接將 size 作為元件的寬度
            width = size;
        } else if (mode == MeasureSpec.AT_MOST) {
            //寬度為 wrap_content,寬度需要計算
        }
        return width;
    }

    private int measureHeight(int heightMeasureSpec) {
        int mode = MeasureSpec.getMode(heightMeasureSpec);
        int size = MeasureSpec.getSize(heightMeasureSpec);
        int height = 0;
        if (mode == MeasureSpec.EXACTLY) {
            //寬度為 match_parent 和具體值時,直接將 size 作為元件的高度
            height = size;
        } else if (mode == MeasureSpec.AT_MOST) {
            //高度為 wrap_content,高度需要計算
        }
        return height;
    }
}

上面的程式碼依然什麼事也幹不了,表達的是一種基本思路。我們定義了一個元件類FirstView,從 View 類派生;定義了三個構造方法(雖然什麼都沒幹),重寫了 onDraw()方法用於繪製元件外的外觀(這裡啥都沒幹);重寫的 onMeasure()方法用於計算元件的高度和寬度(嗯,measure 的意思是測量,我們直接理解成計算好了),在該方法中,定義了兩個方法,其中 measureWidth()方法用於計算元件的寬度,如果元件的 layout_width 屬性為 match_parent 或指定了具體值,則直接從引數 widthMeasureSpec 獲取,如果為 wrap_content,則要通過計算才能得到(因為沒有設定具體的功能,所以我們也不知道該幹什麼)。另一個方法 measureHeight()則用於計算元件的高度,程式碼實現和 measureWidth()類似,不再贅述。

那麼,為了充分說明 onMeasure()方法的作用,我們將 FirstView 模擬 TextView 的功能,也就是在元件中繪製文字,為了簡單起見,我們只考慮一行文字(多行文字會讓程式碼變得十分複雜)。
在本案例中,比較麻煩的是繪製文字時,public void drawText(String text, float x, float y, Paint paint)方法中引數 y 的確定,這要從字型的基本結構說起。
這裡寫圖片描述

如圖 所示,從技術層面上來說,字元由下面幾個部分構成,從文字上理解可能比較晦澀,
通過所示的示意圖也許很容易找到答案。簡單來說,常用字元的高度是 ascent 和 descent 的和,但是,一些特殊字元比如拼音的音調等則會延伸到 top 的位置。

Ø baseline:基準點;
Ø ascent:baseline 之上至字元最高處的距離;
Ø descent:baseline 之下至字元最低處的距離;
Ø top:字元可達最高處到 baseline 的值,即 ascent 的最大值;
Ø bottom:字元可達最低處到 baseline 的值,即 descent 的最大值。
在 Android 中,字型的資訊使用 Paint.FontMetrics 類來表示,該類原始碼如下:
public static class FontMetrics {
public float top;
public float ascent;
public float descent;
public float bottom;
public float leading;
}
FontMetrics 類作為 Paint 的內部類,定義了 5 個屬性,除了 leading 在上面沒有說明外,其他都有圖示與說明。leading 是指上一行字元的 descent 到下一行的 ascent 之間的距離,因為案例中只顯示單行字元,所以我們並不打算關注。

要獲取 FontMetrics 物件,呼叫 Paint 類的 getFontMetrics()即可,而在 drawText()方法中,引數 y 就是 baseline 的值,因為 FontMetrics 類並沒有宣告 baseline 屬性,所以,我們需要通過下面的公式計算出來:int baseline = height / 2 + (fontMetrics.descent - fontMetrics.ascent) / 2 - fontMetrics.descent
其中,height 是文字所在區域的高度。

下面是 FirstView 類的完整實現,我們定義了一個方法 private Rect getTextRect()用於獲取文字所佔的區域大小,measureWidth()和 measureHeight()方法也作了修改。

public class FirstView extends View {
    private static final String TEXT = "FirstView  繪製文字";
    private Paint paint;
    public FirstView(Context context) {
        super(context);
    }
    public FirstView(Context context, AttributeSet attrs) {
        super(context, attrs);
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setTextSize(100);
        paint.setColor(Color.RED);
    }
    public FirstView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
    }
    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        //將文字放在正中間
        Rect textRect = this.getTextRect();
        int viewWidth = getMeasuredWidth();
        int viewHeight = getMeasuredHeight();
        Paint.FontMetrics fontMetrics = paint.getFontMetrics();
        int x = (viewWidth - textRect.width()) / 2;
        int y = (int) (viewHeight / 2 +
                (fontMetrics.descent- fontMetrics.ascent) / 2
                - fontMetrics.descent);
        canvas.drawText(TEXT, x, y, paint);
    }
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        Rect rect = getTextRect();
        int textWidth = rect.width();
        int textHeight = rect.height();
        int width = measureWidth(widthMeasureSpec, textWidth);
        int height = measureHeight(heightMeasureSpec, textHeight);
        setMeasuredDimension(width, height);
    }
    /**
     * 獲取文字所佔的尺寸
     * @return
     */
    private Rect getTextRect(){
        //根據 Paint 設定的繪製引數計算文字所佔的寬度
        Rect rect = new Rect();
        //文字所佔的區域大小儲存在 rect 中
        paint.getTextBounds(TEXT, 0, TEXT.length(), rect);
        return rect;
    }
    /**
     * 測量元件寬度
     * @param widthMeasureSpec
     * @param textWidth 文字所佔寬度
     * @return
     */
    private int measureWidth(int widthMeasureSpec, int textWidth){
        int mode = MeasureSpec.getMode(widthMeasureSpec);
        int size = MeasureSpec.getSize(widthMeasureSpec);
        int width = 0;
        if(mode == MeasureSpec.EXACTLY){
            //寬度為 match_parent 和具體值時,直接將 size 作為元件的寬度
            width = size;
        }else if(mode == MeasureSpec.AT_MOST){
            //寬度為 wrap_content,寬度需要計算,此處為文字寬度
            width = textWidth;
        }
        return width;
    }
    /**
     * 測量元件高度
     * @param heightMeasureSpec
     * @param textHeight 文字所佔高度
     * @return
     */
    private int measureHeight(int heightMeasureSpec, int textHeight){
        int mode = MeasureSpec.getMode(heightMeasureSpec);
        int size = MeasureSpec.getSize(heightMeasureSpec);
        int height = 0;
        if(mode == MeasureSpec.EXACTLY){
            //寬度為 match_parent 和具體值時,直接將 size 作為元件的高度
            height = size;
        }else if(mode == MeasureSpec.AT_MOST){
            //高度為 wrap_content,高度需要計算,此處為文字高度
            height = textHeight;
        }
        return height;
    }
}

上述程式碼中,測試元件寬度時,定義了 private int measureWidth(int widthMeasureSpec, int textWidth)方法,如果尺寸模式為 MeasureSpec.EXACTLY,表示寬度可能為 match_parent 或精確值,直接將獲取的尺寸大小返回。如果尺寸模式為 MeasureSpec.AT_MOST,表示寬度為wrap_content,則需要計算元件的寬度,因為元件內容為文字,所以文字佔用的寬度是多少元件的寬度也是多少,此時,元件的寬度就是 textWidth。測量高度也是同樣的道理。

重寫 onDraw()方法繪製元件外觀時,需要將文字在指定的位置上繪製出來,x 方向比較簡單,其值為元件寬度減去文字所佔寬度除以 2;而 y 的大小則是字型的 baseline 值,其大小為viewHeight / 2 + (fontMetrics.descent- fontMetrics.ascent) / 2 - fontMetrics.descent,viewHeight 是元件測量後的高度。
最後,我們比較一下 layout_width 和 layout_height 兩個屬性的值在不同情況下的執行結果。

元件屬性

在 FirstView 元件類中,要顯示的文字定義成了常量——private static final String TEXT = “FirstView 繪製文字”,顯然,這並不可取,我們應該可以隨意定義文字,這需要用到元件的屬性。

從 View 繼承後,View 已經具備了若干預設屬性,比如 layout_width、layout_height,所以,在 FirstView 類中,指定該類的寬度和高度時,我們並沒有特別定義和程式設計。大家找到
sdk/platforms/android-21/data/res/values/attrs.xml 文 件 , 打 開 後 , 定 位 到

<declare-styleablename="View">

這一行,接下來的 500 多行都是與 View 的預設屬性有關的,常用的屬性比如layout_width、layout_height、background、alpha 等屬性都是預設的屬性。您可以開啟上述檔案進行更詳細的瞭解。下面我們將向您介紹自定義屬性的定義。

屬性的基本定義

除了 View 類中定義的預設屬性外,我們也能自定義屬性。自定義屬性主要有以下幾個步驟:
Ø 在 res/values/attrs.xml 檔案中為指定元件定義 declare-styleable 標記,並將所有的屬性
都定義在該標記中;
Ø 在 layout 檔案中使用自定義屬性;
Ø 在元件類的構造方法中讀取屬性值。
在 res/values 目錄下,建立 attrs.xml 檔案,內容大概如下:

<declare-styleable name="FirstView">
<attr name="attr" format="string"/>
</declare-styleable>

元件的屬性都應該定義在 declare-styleable 標記中,該標記的 name 屬性值一般來說都是元件類的名稱(此處為 FirstView),雖然也可以取別的名稱,但和元件名相同可以提高程式碼的可讀性。元件的屬性都定義在 declare-styleable 標記內,成為 declare-styleable 標記的子標記,每個屬性由兩部分組成——屬性名和屬性型別。屬性通過 attr 來標識,屬性名為 name,屬性型別為format,可選的屬性型別如圖 所示。
這裡寫圖片描述

Ø string:字串
Ø boolean:布林
Ø color:顏色
Ø dimension:尺寸,可以帶單位,比如長度通常為 dp,字型大小通常為 sp
Ø enum:列舉,需要在 attr 標記中使用標記定義列舉值,例如 sex 作為性別,有
兩個列舉值:MALE 和 FEMALE。

<attr name="sex" format="enum">
<enum name="MALE" value="0"/>
<enum name="FEMALE" value="1"/>
</attr>

Ø flag:標識位,常見的 gravity 屬性就是屬性該型別,如圖 所示。
這裡寫圖片描述

flag 型別的屬性也有一個子標記,語法形如:

<attr name="x" format="flag">
<flag name="f1" value="0"/>
<flag name="f2" value="1"/>
</attr>

Ø float:浮點數
Ø fraction:百分數,在動畫資源<scale>、<rotate>等標記中,fromX、fromY 等屬性就是
fraction 型別的屬性
Ø integer:整數
Ø reference : 引 用 , 引 用 另 一 個 資 源 , 比 如 android:paddingRight=-
“@dimen/activity_horizontal_margin”就是引用了一個尺寸資源。
在 FirstView 元件中,text 應該作為屬性來定義,並且為 string 型別,我們在 attrs.xml 中定義如下的 xml 內容:

<?xml version="1.0" encoding="utf-8"?>

<resources>
    <declare-styleable name="FirstView">
        <attr name="text" format="string" />
    </declare-styleable>
</resources>

上述的屬性配置好之後,會在工程的 R.java 檔案中自動生成形如下面的索引,讀取屬性時將會使用這些索引名稱來進行訪問。
public static final int[] FirstView = {
0x7f01002d
};
public static final int FirstView_text = 0;
定義好屬性的名稱和型別後,屬性就可以使用了,在佈局檔案 layout.xml 中,首先要定義好屬性的名稱空間(namespace),預設情況下,xml 檔案中的根元素按如下定義:

<?xml version="1.0" encoding="utf-8"?>

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    android:paddingBottom="@dimen/activity_vertical_margin" tools:context=".MainActivity">
</RelativeLayout>

默 認 的 命 名 空 間 為 “android” , 是 由 語 句 xmlns:android=
http://schemas.android.com/apk/res/android 決定的,對於自定義屬性來說,必須定義其他的名稱空間,且必須按下面的要求定義:xmlns:trkj=”http://schemas.android.com/apk/res-auto”其中 , trkj 是自定義的名稱空間 , 也可以使用其他代替 , 後面的http://schemas.android.com/apk/res-auto 則是固定的,有了這個名稱空間後,訪問前面的 text 屬性則應該這樣賦值:trkj:text=”Android 自定義元件開發詳解”。事實上,IDE 也有相應的提示(Android Studio 的智慧提示功能比 eclipse ADT 要強大得多,在 attrs.xml 檔案中後者沒有提示),如圖所示。
這裡寫圖片描述
完整的 xml 配置如下(請注意下劃線部分):

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:trkj="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:paddingLeft="@dimen/activity_horizontal_margin"
android:paddingRight="@dimen/activity_horizontal_margin"
android:paddingTop="@dimen/activity_vertical_margin"
android:paddingBottom="@dimen/activity_vertical_margin" tools:context=".MainActivity">
<bczm.graphics.view.FirstView
android:layout_width="match_parent"
android:layout_height="wrap_content"
trkj:text="Android 自定義元件開發詳解"
android:background="@android:color/holo_blue_bright"/>
</RelativeLayout>

接下來我們需要在 FirstView 類中讀取 trkj:text 屬性,元件執行後,所有屬性都將儲存在
AttributeSet 集合中並通過構造方法傳入,我們通過 TypedArray 可以讀取出指定的屬性值。

public FirstView(Context context, AttributeSet attrs) {
super(context, attrs);
……
//讀取屬性值
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FirstView);
text = a.getString(R.styleable.FirstView_text);
a.recycle();
}

語 句 TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FirstView) 中 參 數
R.styleable.FirstView 是<declare-styleable name="FirstView">配置中的 name 值,TypedArray 物件的getString()方法用於讀取特定屬性的值(R.styleable.FirstView_text 是指 text 屬性),TypedArray 類中定義了很多 getXXX()方法,“XXX”代表對應屬性的型別,有些 get 方法有兩個引數,第二個引數通常是指預設值。最後,需要呼叫 TypedArray 的 recycle()方法釋放資源。
至此,FirstView 已經修改完成了,因為改動並不大,限於篇幅,這裡我們把改動的程式碼列出來,並用下劃線標識。

改動 1:

public class FirstView extends View {
private static final String TEXT = "FirstView  繪製文字";
private String text;

改動 2:

public FirstView(Context context, AttributeSet attrs) {
……
//讀取屬性值
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FirstView);
text = a.getString(R.styleable.FirstView_text);
a.recycle();
}

改動 3:

private Rect getTextRect(){
//根據 Paint 設定的繪製引數計算文字所點的寬度
Rect rect = new Rect();
//文字所佔的區域大小儲存在 rect 中
paint.getTextBounds(this.text, 0, this.text.length(), rect);
return rect;
}

改動 4:

protected void onDraw(Canvas canvas) {
……
canvas.drawText(this.text, x, y, paint);
} 

執行效果圖如圖所示。
這裡寫圖片描述

讀取來自 style 和 theme 中的屬性

元件的屬性可以在下面 4 個地方定義:
Ø 元件
Ø 元件的 style 屬性
Ø theme
Ø theme 的 style 屬性
這個問題說起來可能有點兒繞,所以我們索性通過一個案例來進行學習和講解。假如我們有一個元件類 AttrView,從 View 類派生,AttrView 類有 4 個屬性:attr1、attr2、attr3、attr4。另外,定義了一個屬性 myStyle,該屬性定義在 declare-styleable 標記之外,型別為 reference,用於theme 的 style 屬性。這些屬性在 res/values/attrs.xml 檔案中定義如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="AttrView">
<attr name="attr1" format="string"></attr>
<attr name="attr2" format="string"></attr>
<attr name="attr3" format="string"></attr>
<attr name="attr4" format="string"></attr>
</declare-styleable>
<attr name="myStyle" format="reference"></attr>
</resources>

我們將這 4 個屬性應用在不同的場合,分別為元件、元件的 style 屬性、theme 和 theme 的style 屬性。
attr_layout.xml:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:trkj="http://schemas.android.com/apk/res-auto"
android:orientation="vertical" android:layout_width="match_parent"
android:layout_height="match_parent">
<com.trkj.lizanhong.chapter6.AttrView
android:layout_width="match_parent"
android:layout_height="match_parent"
trkj:attr1="attr1"
style="@style/viewStyle"/>
</LinearLayout>

trkj:attr1=”attr1” 應 用 了 屬 性 attr1 , style=”@style/viewStyle” 應 用 了 屬 性 attr2 , 其 中@style/viewStyle 定義在 res/values/style.xml 檔案中,當然,該檔案還定義了整個 App 工程的主題(theme),配置如下:
style.xml:


<resources>
<!-- Base application theme. -->
<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
<item name="attr3">attr3</item>
<item name="myStyle">@style/ myDefaultStyle</item>
</style>
<style name=" myDefaultStyle">
<item name="attr4">attr4</item>
</style>
<style name="viewStyle">
<item name="attr2">attr2</item>
</style>
</resources>

在工程的主題(theme) AppTheme 中,應用了屬性 attr3,同時應用了 style 屬性 myStyle,該 style 屬性又引用了@style/ myDefaultStyle,@style/ myDefaultStyle 中應用了屬性 attr4。總結起來,attr1 是元件的直接屬性,attr2 是元件的 style 屬性引用的屬性,attr3 是工程主題(theme)屬性,attr4 是工程主題(theme)的 style 屬性。現在,我們在 AttrView 構造方法中讀取這 4 個屬性值。

public class AttrView extends View {
private static final String TAG = "AttrView";
public AttrView(Context context) {
super(context);
}
public AttrView(Context context, AttributeSet attrs) {
this(context, attrs, R.attr.myStyle);
}
public AttrView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.AttrView,
defStyleAttr, R.style. myDefaultStyle);
String attr1 = a.getString(R.styleable.AttrView_attr1);
String attr2 = a.getString(R.styleable.AttrView_attr2);
String attr3 = a.getString(R.styleable.AttrView_attr3);
String attr4 = a.getString(R.styleable.AttrView_attr4);
Log.i(TAG, attr1 + "");
Log.i(TAG, attr2 + "");
Log.i(TAG, attr3 + "");
Log.i(TAG, attr4 + "");
}
}

我們在 AttrView(Context context, AttributeSet attrs)構造方法中,呼叫了 AttrView(Context
context, AttributeSet attrs, int defStyleAttr)構造方法,與上一個案例相比,我們呼叫了另一個過載的 obtainStyledAttributes()方法,該方法的原型為:
public TypedArray obtainStyledAttributes (AttributeSet set, int[] attrs, int defStyleAttr, int
defStyleRes),我們來了解一下該方法引數作用:
set:屬性值的集合。
attrs:我們要獲取的屬性的資源 ID 的一個數組,我們定義了 attr1、attr2、attr3 和 attr4,這
4 個屬性自動生成的索引會儲存到 R.styleable.AttrView 陣列中,該陣列就是 attrs 引數。
public static final int[] AttrView = {
0x7f010020, 0x7f010021, 0x7f010022, 0x7f010023
};
defStyleAttr:當前 Theme 中 style 屬性,如果元件和元件的 style 屬性都沒有為 View 指定屬性時,將從 Theme 的 Style 中查詢相應的屬性值。
defStyleRes:指向一個 Style 的資源 ID,但是僅在 defStyleAttr 為 0 或 defStyleAttr 不為 0 但Theme 中沒有為 defStyleAttr 屬性賦值時起作用。
我們通過如圖所示的流程圖來了解 View 是如何讀取屬性的。圖中我們試圖讀取 attr 屬
性,從流程圖中也可以看出各個環節的優先順序順序。
這裡寫圖片描述
如圖是最後的執行結果,在控制檯輸出了每一個屬性的值。大家也可以思考一下如果
同一個屬性在不同的地方都出現,根據優先順序關係判斷最後輸出的屬性值是多少。
這裡寫圖片描述

案例 1 : 圓形 ImageView 元件

ImageView 是我們常用的元件之一,但該元件存在一定的侷限性,比如只能顯示矩形的圖片,現在很多 App 在顯示頭像時都支援圓形或其他形狀,所以,我們將向大家介紹如何定製支援圓形圖片的 ImageView 元件。

因為是顯示圖片,我們自然想到元件類應該繼承自 ImageView,ImageView 已經幫我們做了大部分工作,比如已經重寫了 onMeasure()方法,不再需要重新計算尺寸,設定圖片也已經實現了。我們還要新增一些功能,比如顯示出來的圖片是圓的,支援新增圓形框線,為圓形框線指定顏色和大小等等。另外,還要刪除 ImageView 與本需求衝突的功能,ImageView 支援 scaleType,用於定指圖片的縮放型別,但我們打算把這個功能刪除(別問為什麼,任性!^_^)。要提醒的是,其實我們最終顯示的圖片是一個橢圓,如果要顯示成圓形,請將元件的寬度和高度設成一致。

首先,我們事先定義兩個屬性:圓形框線的粗細與顏色,定義粗細時使用 dimension 型別,而顏色則使用 color 型別。

attrs.xml:

<declare-styleable name="CircleImageView">
<attr name="circle_border" format="dimension"></attr>
<attr name="circle_border_color" format="color"></attr>
</declare-styleable>

其次,定義 CircleImageView 元件類,該類繼承自 ImageView 類。

public class CircleImageView extends ImageView {
    private static final String TAG = "CircleImageView";
    private Paint paint;
    private Xfermode xfermode ;
    private Path path = new Path();
    private int border;
    private int borderColor;
    public CircleImageView(Context context) {
        super(context);
    }
    public CircleImageView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }
    public CircleImageView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        paint = new Paint(Paint.ANTI_ALIAS_FLAG);
        paint.setColor(Color.BLACK);
        xfermode = new PorterDuffXfermode(PorterDuff.Mode.DST_IN);
        path = new Path();
        TypedArray a = context.obtainStyledAttributes(attrs,
                R.styleable.CircleImageView);
        border = a.getDimensionPixelSize(
                R.styleable.CircleImageView_circle_border, 0);
        borderColor = a.getColor(R.styleable.CircleImageView_circle_border_color,
                Color.GRAY);
        a.recycle();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        Drawable mDrawable = getDrawable();
        if (mDrawable == null) {
            super.onDraw(canvas);
        }
        int width = getMeasuredWidth();
        int height = getMeasuredHeight();
        RectF ovalRect = new RectF(0, 0, width, height);
        int layerId = canvas.saveLayer(getPaddingLeft(), getPaddingTop(), width,
                height, null, Canvas.ALL_SAVE_FLAG);
        Bitmap bitmap = ((BitmapDrawable) mDrawable).getBitmap();
        canvas.drawBitmap(bitmap, new Rect(0, 0, mDrawable.getIntrinsicWidth(),
                mDrawable.getIntrinsicHeight()), ovalRect, null);
        paint.setXfermode(xfermode);
        paint.setStyle(Paint.Style.FILL);
        paint.setColor(Color.BLACK);
        path.reset();
        path.addOval(ovalRect, Path.Direction.CCW);
        canvas.drawPath(path, paint);
        paint.setXfermode(null);
        canvas.restoreToCount(layerId);
//畫空心圓
        if(border != 0) {
            paint.setStyle(Paint.Style.STROKE);
            paint.setColor(borderColor);
            paint.setStrokeWidth(border);
            ovalRect.inset(border / 2, border / 2);
            canvas.drawOval(ovalRect, paint);
        }
    }
}

上述程式碼中,主要重寫了 onDraw()方法,ImageView 作為父類,可以通過 src 屬性或
setImageResource()、setImageBitmap()等方法設定圖片,getDrawable()方法用於獲取設定的圖片,得到圖片後,需要在圖片上畫一個實心橢圓作為遮罩層,該橢圓是元件的內切橢圓,通過語句RectF ovalRect = new RectF(0, 0, width, height)指定。畫橢圓圖片時,先建立一個 Layer,呼叫canvas.drawBitmap(bitmap, new Rect(0, 0, mDrawable.getIntrinsicWidth(),mDrawable.getIntrinsicHeight()), ovalRect, null)語句將圖片繪製到 canvas 畫布上並進行縮放,然後為 Paint 指定 PorterDuff.Mode.DST_IN 點陣圖模式,在 Path 物件中新增一個橢圓,並與圖片進行DST_IN 點陣圖運算(只有 Path 物件才能進行點陣圖運算,不能直接呼叫 drawOval()方法),於是就得到圓形圖片了。

為圖片繪製邊框線就是一件相對簡單的工作了,但也有幾個地方需要交待。呼叫 border =
a.getDimensionPixelSize(R.styleable.CircleImageView_circle_border, 0)語句獲取邊框線的大小後,得到的資料單位始終為畫素(px),這樣不管使用 dp 還是 sp 都可以得到一致的數值。畫邊框線時,僅僅只有 border 還是不夠的,因為 border 本身佔用了一定的寬度,必須呼叫 ovalRect.inset(border/ 2, border / 2)語句將圓形邊框縮小(注意要除以 2)。定義佈局檔案 circle_imageview.xml,其中 border 為 10dp,顏色為紅色。最終的執行效果如圖 所示。
這裡寫圖片描述

案例 2: 驗證碼元件

驗證碼在 Web 開發中非常常見,用於防止非法暴力破解,隨著圖形識別技術的發展,驗證碼也越來越複雜化和多樣化,以適應當前破解技術的不斷提高。本小節將定義一個驗證碼元件,併為使用者提供定製功能,在執行過程中與元件互動。

我們將驗證碼元件命名為 CodeView,預設情況下,隨機生成 4 個數字和 50 條幹擾線,如果使用者測試次數過多,可以動態加大驗證碼的難度,比如增加驗證碼的個數、增加干擾線條數、改變驗證碼顏色等等。提供的主要功能有:

Ø 重新整理驗證碼
Ø 改變驗證碼個數
Ø 改變干擾線條數
Ø 改變驗證碼字型大小
Ø 改變驗證碼字型顏色
Ø 獲取當前驗證碼

先來看看效果圖,如圖所示:
這裡寫圖片描述

本元件的屬性主要包括驗證碼個數、干擾線條數、字型大小和字型顏色,在 attrs.xml 檔案中定義如下屬性,其中 font_size 表示字型大小,型別為 dimension,到時將使用 sp 作為字型單位。

元件類 CodeView 從 View 中派生,這是一個從 0 開始開發的自定義元件,其實從TextView繼承也是一個不錯的主意。在 CodeView 類中,定義瞭如下的成員變數和常量,常量主要是用於定義各屬性的預設值。

  private static final String TAG = "CodeView";
    private int count;//驗證碼的數字個數
    private int lineCount; //干擾線的條數
    private int fontSize; //字型大小
    private int color;//字型顏色

    private String code;//驗證碼
    private Random rnd;
    private Paint paint;

    private static final int DEFAULT_COUNT = 4;
    private static final int DEFAULT_LINE_COUNT = 50;
    private static final int DEFAULT_FONT_SIZE = 12;//sp
    private static final int DEFAULT_COLOR = Color.BLACK;

在構造方法 public CodeView(Context context, AttributeSet attrs, int defStyleAttr)中讀取出各屬性的值,重點強調一下字型大小的讀取方法。字型大小涉及單位的問題,一般使用 sp 作為字型單位,而我們使用 TypedValue 類的 getDimensionPixelSize()方法讀取的值是畫素,所以需要進行單位轉換,該工作交給 TypedValue 類的靜態方法 applyDimension()完成,applyDimension()的作用是 進 行 單 位 換 算 , 其 方 法 原 型 為 : public static float applyDimension(int unit, float value,DisplayMetrics metrics),其中 unit 是目標單位,可選值如圖所示,value 是要換算的值,metrics 通過 getResources().getDisplayMetrics()即可得到。

這裡寫圖片描述

    public CodeView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray typedArray = context.obtainStyledAttributes(attrs,R.styleable.CodeView);
        count = typedArray.getInt(R.styleable.CodeView_count,DEFAULT_COUNT);
        lineCount = typedArray.getInt(R.styleable.CodeView_line_count,DEFAULT_LINE_COUNT);
        fontSize = typedArray.getDimensionPixelSize(R.styleable.CodeView_font_size,DEFAULT_FONT_SIZE);
        Ty