Android自定義控件之圓形時鐘

ecin6478 7年前發布 | 16K 次閱讀 安卓開發 Android開發 移動開發

最近,電腦突然罷工了,搞了我好長時間才弄好。。所以寫這篇文章耽擱了很長時間。廢話不多說今天我給大家帶來一個最近自己造的輪子——自定義時鐘。對自定義控件有興趣的朋友可以看看,具體內容我會盡量講的詳細。先看一下效果圖:

大家在做自定義控件時,可以把自己想像成一名藝術家。你在創作自己的藝術品。那么作為一名畫家,你肯定得需要至少兩樣工具:畫筆和畫布。這兩樣是作畫的基礎,缺一不可。那么Android有這兩樣東西嗎,答案是肯定的。在Android中 Paint 就是我們的畫筆, Canvas 就是我們的畫布。那么這兩樣東西該如何去用呢?其實也很簡單, Paint 提供了很多方法,我們通過這些方法可以對這只筆進行設置,比如筆的顏色,畫出來線條的粗細等等。而 Canvas 則負責具體要畫的東西,比如點,線,矩形,圓形等等,有關具體的使用細節我一會兒會詳細講解。這里你只需要大體知道有這么個東西就可以了。

好了,回到我們的主題上來,畫筆和畫布都有了,那么問題來了,,,挖掘機技術哪家強。。。。。日。。再來一遍,,那么問題來了,如果是你想要在現實生活中畫一個時鐘,你覺得都得要畫什么呢?我想小時候大家一定都有在自己手上畫手表的經歷吧。首先,當然得有一個邊框吧,然后是圓心、刻度以及數字,當然還有最重要的指針,這也是構成時鐘最基本的要素,相信你當時一定畫的很漂亮。那么在Android中到底該如何去畫呢。接下來,我就帶大家一起看看,這些東西是如何一步步畫在手機上的。

1.準備工作

首先,我們得自己定義一個類取名叫TimeView,讓其繼承View,然后創建構造方法,最后我們要覆寫 onDraw(Canvas canvas) 方法,我們具體的畫圖邏輯就在這個方法中。具體代碼如下:

public class TimeView extends View{
    private Context mContext;
    private Paint mPaint;
    public TimeView(Context context) {
        super(context);
        this.mContext = context;
        initPaint();
    }
    public TimeView(Context context, AttributeSet attrs) {
        super(context, attrs);
        this.mContext = context;
        initPaint();
    }
    /**
     * 初始化畫筆
     */
    private void initPaint(){
        mPaint = new Paint();
        //抗鋸齒
        mPaint.setAntiAlias(true);
        mPaint.setColor(Color.BLACK);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(0);
    }
    @Override
    protected void onDraw(Canvas canvas) {
    //畫具體內容
    }
}

在這里我們定義了兩個構造方法,第一個大家應該都很好理解,關鍵是第二個,入參多了個 AttributeSet ,可能大家對這東西比較陌生。我們知道,想要使用一個控件時,有兩種方法,第一,我們可以在Java代碼中直接new一個,第二種就是在XML布局文件中聲明。這兩種方法也正好對應以上兩種構造方法。如果你不寫第二種構造方法,那么你在XML布局文件中直接使用時會報錯的。在構造方法中,我們創建了一只畫筆。然后給它設置一些屬性,其中setAntiAlias(true)的作用是抗鋸齒,顧名思義如果不設置的話,在圖形邊緣會有一些鋸齒狀的痕跡。然后給這只筆設置顏色,以及風格。風格一共有三種: Paint.Style.STROKE ,描邊效果,比如你畫一個圓,顯示的就是一個圓環; Paint.Style.FILL 、填充效果,顯示的整個圓; Paint.Style.FILL_AND_STROKE ,這個既有描邊,又有填充其實效果和FILL差不多。如果設置成 STROKE ,那么你可以用 setStrokeWidth() 給這條邊設置寬度。這樣我們的畫筆就準備好了,畫布就是我們 onDraw(Canvas canvas) 中的 canvas 已經給我們提供好了。好了,這樣我們就已經寫好了一個自定義控件。然后我們就可以在XML布局文件中引用了,注意:控件名前一定要加具體的包名。好了這樣我們運行一下發現什么都沒有,因為我們在onDraw方法中還沒干任何事情,不過別著急,接下來我們一步步來實現。

<com.example.administrator.timeviewdemo.TimeView
    android:id="@+id/time_view"
    android:layout_width="300dp"
    android:layout_height="300dp"/>

2.畫邊框

我們的邊框就是一個簡單的圓:

@Override
protected void onDraw(Canvas canvas){
    //圓形邊框
    canvas.drawCircle(getWidth() / 2, getHeight() / 2, getWidth() / 3, mPaint);
}

我們可以看到,要想畫一個圓,只用調用canvas的drawCircle(float x,float y,float radius,Paint mPaint)方法,它接受四個參數,其中想x、y為圓的圓心。這里我要說一下Android的坐標系,它的坐標原點默認在屏幕的左上角,向右為為X軸正方向,向下為Y軸正方向

這里我們圓心選在控件的中心,即寬高的一半。第三個參數是圓的半徑,這里我們就取控件寬的三分之一,第四個為之前我們創建的畫筆。我們來運行看一下效果:

怎么樣還不錯吧,總算有點東西了,這里我們的Style設的是Paint.Style.STROKE,我們換成Paint.Style.FILL試下:

可以看到圓內被填充了,這下你應該知道FILL和STROKE的區別了吧。好了我們來看下一個

3.畫中心點

有了外面的邊框我們還可以再給它一個中心點,當然你覺得沒必要,不加也可以。不過我們還是來看一下在Android中是如何畫一個點的。其實也很簡單,你只需調用 canvas.drawPoint(float x,float y,Paint mPaint) 方法,我想這方法也不用在過多的解釋了,x,y為中心點的坐標,mPaint為之前的畫筆。

4.畫刻度線

時鐘自然是少不了刻度線啦,所以我們來看看刻度線是如何畫的。刻度線說白了就是一條條的直線。那么在 Canvas 中有畫直線的方法嗎?答案是必須的。畫布給我們提供一個叫 canvas.drawLine(float fromX,float fromY,float stopX,float stopY,Paint mPaint) 的方法;我想大家應該在初中就知道兩點決定一條直線,所以這個方法中一二兩個參數分別為起始點的x、y坐標,三四兩個參數為終點坐標,第五個自然為我們的畫筆啦。好了有了這個方法,只要求出起點坐標和終點坐標,理論上我們能畫出任意的直線。不過這里可能有人要坐不住了:你扯獨自呢,這么多刻度線,怎么求啊?確實,這么多刻度線,要想一條一條求出起點坐標和終點坐標,確實不太現實。那么有沒有簡單點的方法呢?先別急,在回答這個問題之前我們先來看一下 Canvas 的操作坐標系的幾個方法:

  1. canvas.translate(float x,float y);
  2. canvas.rotate(float degree);
  3. canvas.rotate(float degree,float x,float y);

這里我簡單說一下這幾個方法,第一個是坐標系的平移,傳入的兩個參數,分別為平移后坐標原點的X、Y坐標,說白了就是你想把坐標原點移到哪個點就傳入哪個點;第二個方法是把坐標系旋轉一定角度,傳入正數則順時針轉,負數則相反。第三個方法是繞著傳入的(X,Y)點旋轉一定度數。好了,知道了這幾個方法現在再畫刻度線是不是有點思路了呢。我們知道,要想求出所有刻度的起始與終點坐標很復雜,也不太現實。但求一條刻度的坐標還是好求的。為了坐標表示方便我們移動一下坐標系,即調用 canvas.translate(getWidth()/2,getHeight()/2) 將坐標原點移到圓心處。

如上圖所示,我們把坐標原點移到圓心,這樣如果我們要畫圖中綠色刻度線,其實就很簡單了。起始坐標和終點坐標的Y軸坐標均為0,起始坐標的X軸坐標為半徑減去刻度線長度,而終點坐標的X軸坐標就是半徑。怎么樣,這樣畫一條刻度線是不是挺簡單的,相信你一定能畫好。好,接下來我們再畫一條,不過在畫之前,我們得做一個小小的動作,就是把坐標系旋轉一下。如下圖:

我們把原來的紅色坐標系順時針旋轉了a角度得到了黃色坐標系,也就是調用了 canvas.rotate(a) ,我們之前說過順時針轉,要傳入正值,所以這里的 a 是一個正數。好了,這樣我們再來求一下黃色X’軸上的刻度線,會發現它的坐標和第一條刻度線的坐標是一樣的。是不是問題變得很簡單了。這樣不管你要畫幾條刻度線,不管你想畫在哪,只要旋轉你的坐標系,而不用反復的計算刻度線的坐標。比如,我們都知道圓是360度,你想每隔一度,就畫一條刻度線,那么你就每次旋轉一度,然后畫一條線。這樣不斷循環后,就畫出了360條刻度線。當然你可以根據自己的需求畫任意條。代碼如下:

@Override
protected void onDraw(Canvas canvas) {
    //圓形邊框
    mPaint.setStrokeWidth(2);
    canvas.drawCircle(getWidth() / 2, getHeight() / 2, getWidth() / 3, mPaint);
    //圓心
    mPaint.setStrokeWidth(5);
    canvas.drawPoint(getWidth() / 2, getHeight() / 2, mPaint); 
    //設置刻度線線寬
    mPaint.setStrokeWidth(1); 
    //將坐標原點移到圓心處
    canvas.translate(getWidth()/2,getHeight()/2);
    for (int i = 0; i < 360; i++) {      
       //這里刻度線長度我設置為25
       canvas.drawLine(getWidth() / 3-25, 0,getWidth() /3, 0, mPaint);
       canvas.rotate(1); 
    }
}

效果如下:我是每隔1度畫了一條刻度線。為便于觀看,我放大了整個圖片,可以看到我們的刻度線分布的還是很均勻、整齊的。

當然如果你覺得刻度線的長度都一樣長,太單調了你也可以進行適當的改變。比如你可以每秒鐘設置一個中等長度,每五秒鐘設置一個最長的長度,然后其他的刻度線都設置一個最小的長度。我們知道圓是360度,并且秒針轉一圈為60秒,所以一秒就對應360度/60秒=6度,那么五秒也就是5*6 = 30度。得到這兩個關鍵的角度我們就可以寫代碼了:

@Override
protected void onDraw(Canvas canvas) {
    mPaint.setStrokeWidth(2); 
    canvas.drawCircle(getWidth() / 2, getHeight() / 2, getWidth() / 3,mPaint);
    mPaint.setStrokeWidth(5);
    canvas.drawPoint(getWidth() / 2, getHeight() / 2, mPaint);
    mPaint.setStrokeWidth(1); 
    canvas.translate(getWidth() / 2, getHeight() / 2);
    for (int i = 0; i < 360; i++) {
        if (i % 30 == 0) {//長刻度
            canvas.drawLine(getWidth() / 3 - 25, 0,getWidth() / 3, 0, mPaint);
        } else if (i % 6 == 0) {//中刻度*/ 
            canvas.drawLine(getWidth() / 3 - 14, 0,getWidth() / 3, 0, mPaint);
        } else {//短刻度
            canvas.drawLine(getWidth() / 3 - 9, 0,getWidth() / 3, 0, mPaint); 
        } 
        canvas.rotate(1); 
   }

效果如下:

4.畫數字

接下來我們在時鐘上畫上1-12的數字,有關寫字Canvas給我提供了這樣一個方法: drawText(String text,float x,float y,Paint mPaint) ;其中text指我們要寫的字,mPaint是我們的畫筆,那么x,y是什么呢?很顯然x和y是用來給文字定位用的,x指的文字最左邊的X坐標,那么y呢,難道是文字最下邊的Y坐標嗎。其實不是的。我們來看下圖:

上圖給出個文字的一些尺寸參數,我們可以看到其中那條黑線,即Baseline,上文的y其實就是這條線的Y坐標。Baseline到文字頂部距離叫做ascent,Baseline到文字底部叫做descent,我們知道一般文字上部和下部會有一點padding,所以top和bottom的距離會略大于ascent,descent。如果有兩行文字,那么上一行的descent到下一行的ascent的距離就叫做leading,即行間距。那么我們如何能得到這些參數呢。其實很簡單,在調用drawText方法之前,我們一般會通過mPaint.setTextSize(float size);來設置字體大小,設完以后,我們就可以通過mPaint.getFontMetrics()方法來得到一個Paint.FontMetrics對象,這個對象封裝了上述我們要的文字尺寸信息。代碼如下:

Paint mPaint = new Paint();
mPaint.setTextSize(50);
Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
float ascent = fontMetrics.ascent;
float bottom = fontMetrics.bottom;
float descent = fontMetrics.descent;
float leading = fontMetrics.leading;
float top = fontMetrics.top;

注意:上述這些參數大小與具體是什么文字無關,只與字體大小和字體格式有關。并且,在Baseline上方的尺寸為負,下方為正。也就是top、ascent都是負數,bottom和descent為正數。

好了,知道了如何寫文字后,我們就可以在時鐘上寫上我們要的十二個數字了,一共12個數字,一個圓360度,所以每個30度寫一個字。這樣我們就可以用之前的方法,沒寫完一個數,就將坐標系旋轉30度。代碼如下:

mPaint.setTextSize(25);
mPaint.setStyle(Paint.Style.FILL);
Rect textBound = new Rect();//創建一個矩形
for (int i = 0; i <12; i++) {
    if (i == 0){
        //將文字裝在上面創建的矩形中,即這個矩形就是文字的邊框
        mPaint.getTextBounds(12+"",0,(12+"").length(),textBound);
        canvas.drawText(12+"",-textBound.width()/2,-(getWidth()/3-50),mPaint);
        canvas.rotate(30);
    }else{
        mPaint.getTextBounds(i+"",0,(i+"").length(),textBound);
        canvas.drawText(i+"",-textBound.width()/2,-(getWidth()/3-50),mPaint);
        canvas.rotate(30); 
   }
}

上面的代碼還是好理解的,我們創建一個循環,每循環一次就寫個文字,并且將坐標系順時針旋轉30度,其中我們可以看到,我們創建了一個矩形,然后我們調用mPaint.getTextBounds(String text,int start,int end,Rect textBound)將文字的邊框存入其中,這個方法傳入四個參數,第一個為我們要畫的字符串,第二三個參數分別為這個字符串的開始角標和結束叫角標,最后一個為矩形。這樣我們就可以把這個矩形理解為這個字符串的邊框,有了邊框我們就可以知道這個字符串的很多參數,比如上下左右的坐標,以及字符串的寬高等。這樣當我們畫數字時,它的X坐標就是文字寬度的一半,注意別忘了負號。好了我們來看下效果如何:

沒錯正如你所料,雖然數字是有了,而且還挺整齊的,不過文字也跟著旋轉了。看來簡單的旋轉坐標系是不行了。那還有其他辦法嗎,有的人可能會說了,直接算出每個數字的具體坐標然后在畫。這樣當然可以,只要你夠耐心,而三角函數還不錯的話,可以嘗試下。不過我還是勸你不要這么干,因為這樣計算既麻煩而且算的準確度也不高。那么還有什么更好的辦法呢。這里我想到了一個好辦法,可以給大家參考一下。其實我們每一次畫數字的時候可以提取出一個動作,舉個例子,比如我們要畫數字“1”,如下圖所示,我們知道“12”和“1”之間為30度,那么我們可以先將圖中黑色坐標系順時針旋轉30度,得到藍色坐標系,然后我們將藍色坐標系沿著Y軸反方向移動合適的距離,得到紅色坐標系,然后再將坐標系逆時針轉30度得到綠色坐標系,我們的目標就是在綠色坐標系的中心畫上數字,具體怎么畫,我想也不用多說了。畫完后,再將坐標系原路返回。也就是,將綠色坐標系順時針旋轉30度,回到紅色坐標系,然后將紅色坐標系沿著Y軸正方向移動和之前平移時同樣的距離,得到藍色坐標系,最后將藍色坐標系逆時針旋轉30度回到原來的黑色坐標,即剛開始的坐標系。這樣經過一系列的動作,畫完一個數字,我們的坐標系還是和原來沒畫數字時的一樣。這樣我們就可把這一系列動作寫成一個方法,在每次畫數字之前調用它就行。

這系列動作我們可以寫成如下方法:

private void drawNum(Canvas canvas, int degree, String text, Paint paint) { 
        Rect textBound = new Rect();
        paint.getTextBounds(text, 0, text.length(), textBound);
        canvas.rotate(degree);
        canvas.translate(0, 50 - getWidth() / 3);//這里的50是坐標中心距離時鐘最外邊框的距離,當然你可以根據需要適當調節
        canvas.rotate(-degree);
        canvas.drawText(text, -textBound.width() / 2, 
               textBound.height() / 2, paint); 
        canvas.rotate(degree);
        canvas.translate(0, getWidth() / 3 - 50);  
        canvas.rotate(-degree);
    }

這個方法,我們傳入四個參數,分別為畫布,要畫數字與12點之間的夾角,要畫的數字以及畫筆。接下來,在我們每次畫數字是調用這個方法就行了:

mPaint.setTextSize(25);
mPaint.setStyle(Paint.Style.FILL);
for (int i = 0; i < 12; i++) { 
   if (i == 0) { 
       drawNum(canvas, i * 30, 12 + "", mPaint);
    } else {
       drawNum(canvas, i * 30, i + "", mPaint); 
   }
}

代碼還是挺直觀的,我就不過多解釋了。我們來看一下效果:

只能用兩字形容“完美”。

3.畫指針

好了,數字也總算畫好了,接下來就只剩下指針了,指針分秒針、分針和時針,知道一種怎么畫就可以。其實很簡單,這里我直接調用drawLine()方法,代碼如下:

//秒針
canvas.save()
mPaint.setColor(Color.RED);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(2);
//其實坐標點(0,0)終點坐標(0,-190),這里的190為秒針長度
canvas.drawLine(0, 0, 0, 
       -190, mPaint);
canvas.restore();
//分針
canvas.save();
mPaint.setColor(Color.BLACK);
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(4);
canvas.rotate(30);
canvas.drawLine(0, 0, 0,
        -130, mPaint);
canvas.restore();
//時針
canvas.save();
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(7);
canvas.rotate(90);
canvas.drawLine(0, 0, 0,
        -90, mPaint);
canvas.restore();

因為我們每個指針的旋轉角度都不同,所以為了避免相互影響,我們把每個指針畫在canvas.save()和canvas.restore()之間,相當每個指針都畫在不同的圖層上,最后合并為一張圖。

好了這樣我們的時鐘算是畫完了,不過細心的朋友可能會發現,這里還有個bug,分針在5分鐘時,時針不應該是正對著的,而是有點偏差的,那么這偏差具體是多少呢?還有現在的時鐘還是靜態的,又如何讓它動起來呢?由于篇幅有限,這些內容將寫在下篇文章中。當然全部代碼我已經上傳到GitHub上,有興趣的可以去看一下,記得給個星星哦。。

 

 

 本文由用戶 ecin6478 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!