Tuesday, April 5, 2011

客製化你專屬的View - View與OnDraw()應用

文章更新時間︰2013/06/04
文章更新次數︰3

一、前提

話說人是貪婪的,
這句話用在沉浸於3C的使用者來說,
真是滿恰當的。

一開始就以這段文字做開頭,
跟今天的主題很有關係,
一般初學者剛開始學的,
大部份都是如何建立一個TextView、EditText、Button等等的元件。

想要在我們的畫面上叫出一個TextView文字框,我們有以下2種方式︰
1.在程式碼裡
TextView text = new TextView(this);
text.setText("test");
我們的Layout.addView(text);  //將TextView新增至我們的Layout
2.在xml裡宣告
<TextView
    android:layout_width="fill_parent"
    android:layout_height="warp_content"
    android:text="test"/>
於是,我們可以很順利的得到這個畫面

但是這些基礎元件用久了,我們或使用者,
開始想要搞東搞西,想要客製化

以下是這篇分享文最後會呈現的樣子︰

ㄜ...這不就是再加一行字,並且把字的大小和顏色改一改而已嗎?
有什麼好說的呢?

二、文章開始

如果你這麼想,可就大錯特錯囉!
快來看看我在layout資料夾裡面的main.xml放了些什麼︰
<?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/com.test.testcustomize"
    android:orientation="vertical"
    android:layout_width="fill_parent"
    android:layout_height="fill_parent"
    >
<TextView  
    android:layout_width="fill_parent" 
    android:layout_height="wrap_content" 
    android:text="@string/hello"
    />
<com.test.testcustomize.LabelView
    android:layout_width="fill_parent"
    android:layout_height="wrap_content"
    app:text="Reddd" />
</LinearLayout>
有注意到嗎?多了
xmlns:app="http://schemas.android.com/apk/res/com.test.testcustomize"

app:text="Reddd" 
這時候你會問︰
app:text="Reddd"
這個標籤怎麼來的?

從這裡︰

只要在res/values/裡新增attrs.xml這份文件,宣告我們自訂的屬性,
然後在main.xml裡導入這份文件,
再加上之後會提到LabelView.class的一些設定,
就可以達到藍色字的效果了。

也許你會說︰
「天啊!
為什麼我們要把一個原本可以很簡單設定大小和顏色就能做到的事,
搞得那麼複雜?」
這就是我今天要說的「客製化」。

不知道你有沒有注意到,畫面中藍色的那行字,
只在main.xml裡很簡單的宣告文字內容,
app:text="Reddd"
卻呈現了藍色、而且更大的字體。

Android允許我們自訂View,
呈現我們自己想呈現的基本樣式。

技術文件說︰
如果我們想要有自己的View,
那我們就自己寫一個類並繼承View。
所以在這份文件裡,我多寫了一個LabelView來繼承View。

該類別Code如下(可快速跳過這段)︰
package com.test.testcustomize;

import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.util.Log;
import android.view.View;

public class LabelView extends View {
    private Paint mTextPaint;
    private String mText;
    private int mAscent;
    
    public LabelView(Context context) {
        super(context);
        initLabelView();
    }
    
    public LabelView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initLabelView();
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LabelView);
        CharSequence s = a.getString(R.styleable.LabelView_text);
        if(s!=null){
            setText(s.toString());
        }
        setTextColor(a.getColor(R.styleable.LabelView_textColor, 0xFF0000FF));

            int textSize = a.getDimensionPixelOffset(R.styleable.LabelView_textSize, 0);
            if (textSize > 0) {
                setTextSize(textSize);
            }
        a.recycle();
    }

    private void initLabelView(){
        mTextPaint = new Paint();
        mTextPaint.setAntiAlias(true);
        mTextPaint.setTextSize(24);
        mTextPaint.setColor(0xFF000000);
        setPadding(3,3,3,3);
    }

    private void setText(String str){
        mText = str;
        requestLayout();
        invalidate();  //每呼叫一次就會重新繪圖onDraw()
    }

      /**
     * Sets the text size for this label
     * @param size Font size
     */
    public void setTextSize(int size) {
        mTextPaint.setTextSize(size);
        requestLayout();
        invalidate();  //每呼叫一次就會重新繪圖onDraw()
    }

    /**
     * Sets the text color for this label.
     * @param color ARGB value for the text
     */
    public void setTextColor(int color) {
        mTextPaint.setColor(color);
        invalidate(); //每呼叫一次就會重新繪圖onDraw()
    }

    /**
     * @see android.view.View#measure(int, int)
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(measureWidth(widthMeasureSpec),
                measureHeight(heightMeasureSpec));
    }

    /**
     * Determines the width of this view
     * @param measureSpec A measureSpec packed into an int
     * @return The width of the view, honoring constraints from measureSpec
     */
    private int measureWidth(int measureSpec) {

        int result = 0;
        int specMode = MeasureSpec.getMode(measureSpec);
        
        int specSize = MeasureSpec.getSize(measureSpec);
        
        if (specMode == MeasureSpec.EXACTLY) {
            // We were told how big to be
            result = specSize;
        } else {
            // Measure the text
            result = (int) mTextPaint.measureText(mText) + getPaddingLeft()
                    + getPaddingRight();
            if (specMode == MeasureSpec.AT_MOST) {
                // Respect AT_MOST value if that was what is called for by measureSpec
                result = Math.min(result, specSize);
            }
        }

        return result;
    }

    /**
     * Determines the height of this view
     * @param measureSpec A measureSpec packed into an int
     * @return The height of the view, honoring constraints from measureSpec
     */
    private int measureHeight(int measureSpec) {

        int result = 0;
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);

        mAscent = (int) mTextPaint.ascent();
        Log.i("tag", "Height Ascent: "+mAscent);
        if (specMode == MeasureSpec.EXACTLY) {
            // We were told how big to be
            result = specSize;
        } else {
            // Measure the text (beware: ascent is a negative number)
            result = (int) (-mAscent + mTextPaint.descent()) + getPaddingTop()
                    + getPaddingBottom();
            Log.i("tag", "Height mTextPaint.descent(): "+mTextPaint.descent());
            if (specMode == MeasureSpec.AT_MOST) {
                // Respect AT_MOST value if that was what is called for by measureSpec
                result = Math.min(result, specSize);
            }
        }
        return result;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);        
        canvas.drawText(mText, getPaddingLeft(), getPaddingTop() - mAscent, mTextPaint);
    }
    
}

Code很長,請聽我一一道來。

這段Code的基本骨架是這樣的︰
1.一開始,宣告2個建構式︰
//建構式1
    public LabelView(Context context) {
        super(context);
        initLabelView();  //設定文字的最初樣式
    }

    //建構式2    
    public LabelView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initLabelView();  //設定文字的最初樣式

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LabelView);
        CharSequence s = a.getString(R.styleable.LabelView_text);
        if(s!=null){
            setText(s.toString());
        }
        setTextColor(a.getColor(R.styleable.LabelView_textColor, 0xFF0000FF));

            int textSize = a.getDimensionPixelOffset(R.styleable.LabelView_textSize, 0);
            if (textSize > 0) {
                setTextSize(textSize);
            }

        a.recycle();
    }
技術文件裡提到,
如果我們想對我們想呈現的View初始化,
那麼我們就應該在建構式裡做基本的設定

先不看第2段建構式的龐大內容,
這2個建構式都有一個共通點︰
都呼叫了
initLabelView();
該函式的內容如下︰
private void initLabelView(){
        mTextPaint = new Paint();
        mTextPaint.setAntiAlias(true); //設定反鉅齒
        mTextPaint.setTextSize(24);    //設定預設大小為24
        mTextPaint.setColor(0xFF000000); //設定預設的顏色為黑字體
        setPadding(3,3,3,3);   //設定在版面上下左右各距離3
    }
我們在輸入文字的一開始,
馬上呼叫了Paint,
並對這個我們自訂的View做了初始化.

回到第2個建構式︰
//建構式2    
    public LabelView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initLabelView();

        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.LabelView);
        CharSequence s = a.getString(R.styleable.LabelView_text);
        if(s!=null){
            setText(s.toString());
        }
        setTextColor(a.getColor(R.styleable.LabelView_textColor, 0xFF0000FF));

        int textSize = a.getDimensionPixelOffset(R.styleable.LabelView_textSize, 0);
        if (textSize > 0) {
             setTextSize(textSize);
        }
      
        a.recycle();
    }
第6行︰將我們自己做的attrs.xml呼叫進來
第7行︰取出我們在main.xml裡設定的字
第8行︰如果有取到字,則呼叫setText()函式
第11行︰設定文字最後會呈現的顏色,如果在main.xml裡面沒有設定的話,
就用這裡的預設值︰0xFF0000FF,設為藍色
第13行︰設定文字大小
第18行︰讓我們自訂的attrs可以重覆被使用(因為以後也可能被用到啊!)

Android在畫出一個View時,
原理是這樣子的︰
1.不斷的呼叫我們程式裡的onMeasure()函式去量測現在要畫的View的尺寸會有多大︰
/**
     * @see android.view.View#measure(int, int)
     */
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(measureWidth(widthMeasureSpec),
                measureHeight(heightMeasureSpec));
    }
2.最後呼叫onDraw()繪圖

當我在試驗時,程式為了畫出藍色字體,
至少呼叫了3次的onMeasure()才呼叫onDraw()。

所以我們得知︰
系統在開始onDraw()前,
會不斷的呼叫onMeasure()去跟Layout問現在要畫的藍色字體,
範圍是多大。

所以,我們就要將尺寸的算法在這裡交待一下了︰
/**
     * 量測這個View的寬
     * @param measureSpec A measureSpec packed into an int
     * @return The width of the view, honoring constraints from measureSpec
     */
    private int measureWidth(int measureSpec) {

        int result = 0;
        int specMode = MeasureSpec.getMode(measureSpec);       
        int specSize = MeasureSpec.getSize(measureSpec);
        
        if (specMode == MeasureSpec.EXACTLY) {
            // We were told how big to be
            result = specSize;
        } else {
            // Measure the text
            result = (int) mTextPaint.measureText(mText) + getPaddingLeft()
                    + getPaddingRight();
            if (specMode == MeasureSpec.AT_MOST) {
                // Respect AT_MOST value if that was what is called for by measureSpec
                result = Math.min(result, specSize);
            }
        }

        return result;
    }
/**
     * 量測這個View的高
     * @param measureSpec A measureSpec packed into an int
     * @return The height of the view, honoring constraints from measureSpec
     */
    private int measureHeight(int measureSpec) {

        int result = 0;
        int specMode = MeasureSpec.getMode(measureSpec);
        int specSize = MeasureSpec.getSize(measureSpec);

        mAscent = (int) mTextPaint.ascent();
        Log.i("tag", "Height Ascent: "+mAscent);
        if (specMode == MeasureSpec.EXACTLY) {
            // We were told how big to be
            result = specSize;
        } else {
            // Measure the text (beware: ascent is a negative number)
            result = (int) (-mAscent + mTextPaint.descent()) + getPaddingTop()
                    + getPaddingBottom();
            Log.i("tag", "Height mTextPaint.descent(): "+mTextPaint.descent());
            if (specMode == MeasureSpec.AT_MOST) {
                // Respect AT_MOST value if that was what is called for by measureSpec
                result = Math.min(result, specSize);
            }
        }
        return result;
    }
第6行︰一開始系統傳進來的引數measureSpec是從main.xml裡抓過來的
第9行︰在MeasureSpec.getMode()裡會抓到的尺寸模式,有以下3種
 Mode
 Value
 備註
main.xml裡相對應的設定
AT_MOST
 -2147483648
 子視圖可自行調整尺寸大小
android:layout_width或height 設為"wrap_content"時
EXACTLY     
 1073741824
 父Layout已經很明確的定義該子視圖的大小
android:layout_width或height有自訂大小或為"fill_parent"時
UNSPECIFIED
 0
 父Layout未指定大小,子視圖可自行調整。

第10行︰你在main.xml裡真實設定的尺寸。

幾次的onMeasure()呼叫完後,就跑去呼叫onDraw()開始繪圖了。

@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);        
        canvas.drawText(mText, getPaddingLeft(), getPaddingTop() - mAscent, mTextPaint);
    }
挪,結束了。

三、結論

1.在res/values/新增一個自訂的attrs.xml
2.在main.xml裡使用他們
3.繼承View,並在
(1)建構式 裡宣告初始值
(2)在onMeasure()裡交待清楚這個自創的View應該有多大
(3)在onDraw()裡開始繪圖

這樣子,你就能為所欲為,畫出任何你預設想呈現出來的View了。

有點複雜,但搞懂了就簡單了。

備註︰
來源︰百度知道





1.基準點是baseline
2.ascent:是baseline之上至字符最高處的距離
3.descent:是baseline之下至字符最低處的距離
4.leading:是上一行字符的descent到下一行的ascent之間的距離,也就是相鄰行間的空白距離
5.top:指的是最高字符到baseline的值,即ascent的最大值
6.bottom:指最低字符到baseline的值,即descent的最大值

5 comments:

Anonymous said...

感謝分享

Charliejack Wei said...

感謝分享

朱昌森 said...

不錯啊!
點個廣告贊助你。

Anonymous said...

試著把你所寫的程式碼
複製、貼上到 Android 去
會出現錯誤耶!

Exception raised during rendering: text cannot be null
Exception details are logged in Window > Show View > Error Log

java.lang.IllegalArgumentException: text cannot be null
at android.graphics.Paint.measureText(Paint.java:1350)
at com.custom.label.LabelView.measureWidth(LabelView.java:100)
at com.custom.label.LabelView.onMeasure(LabelView.java:79)
at android.view.View.measure(View.java:15848)
at android.widget.RelativeLayout.measureChildHorizontal(RelativeLayout.java:728)
at android.widget.RelativeLayout.onMeasure(RelativeLayout.java:477)
at android.view.View.measure(View.java:15848)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:5012)
at android.widget.FrameLayout.onMeasure(FrameLayout.java:310)
at android.view.View.measure(View.java:15848)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:5012)
at android.widget.LinearLayout.measureChildBeforeLayout(LinearLayout.java:1404)
at android.widget.LinearLayout.measureVertical(LinearLayout.java:695)
at android.widget.LinearLayout.onMeasure(LinearLayout.java:588)
at android.view.View.measure(View.java:15848)
at android.view.ViewGroup.measureChildWithMargins(ViewGroup.java:5012)
at android.widget.LinearLayout.measureChildBeforeLayout(LinearLayout.java:1404)
at android.widget.LinearLayout.measureVertical(LinearLayout.java:695)
at android.widget.LinearLayout.onMeasure(LinearLayout.java:588)
at android.view.View.measure(View.java:15848)

Anonymous said...

找到你程式出錯的地方了

在 26 行的地方
請加上
else{
setText("TextView");
}