Android自定义控件之圆形时钟

作者: 蛇发女妖 | 来源:发表于2016-12-21 12:40 被阅读3082次

    声明:原创作品,转载请注明出处 http://www.jianshu.com/p/3b789490fc04

    最近,电脑突然罢工了,搞了我好长时间才弄好。。所以写这篇文章耽搁了很长时间。废话不多说今天我给大家带来一个最近自己造的轮子——自定义时钟。对自定义控件有兴趣的朋友可以看看,具体内容我会尽量讲的详细。先看一下效果图:

    大家在做自定义控件时,可以把自己想像成一名艺术家。你在创作自己的艺术品。那么作为一名画家,你肯定得需要至少两样工具:画笔和画布。这两样是作画的基础,缺一不可。那么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分钟时,时针不应该是正对着的,而是有点偏差的,那么这偏差具体是多少呢?还有现在的时钟还是静态的,又如何让它动起来呢?由于篇幅有限,这些内容将写在下篇文章Android自定义控件之圆形时钟(续)中。当然全部代码我已经上传到GitHub上,有兴趣的可以去看一下,记得给个星星哦。。

    GitHub地址:https://github.com/Lloyd0577/CustomClockForAndroid

    相关文章

      网友评论

      • 付凯强:和特效电影一样精彩
      • 忒渺小:厉害了,写的超棒,主要是思路很清晰,比其他博主写的强太多了,本人比较笨,有些位置上还是没看懂,x,y轴,还是模糊,自己差远了啊
      • 张晓凯:学习了
      • zzzz简:有一个问题没明白 canvas的坐标中心点移到控件的中心点后, 是从水平的方向开始1个刻度1个刻度绘制, 旋转360,也就是旋转之后最终写文字的时候 12应该会是在3的那个刻度,为什么会在最上面呢
        蛇发女妖:@zzzz简 😊
        zzzz简:刚刚想明白了 画线的时候是设置坐标的,写文字没有设置坐标 所以是从顶部开始写文字的 这个纠结了好久~
      • 9d13dac0b326:美杜莎你好,哈哈哈
        蛇发女妖:@_壮志凌云_ :wink:
      • 今生挥毫只为你:写得挺好的,问一下,自定义控件不是要写3个构造方法吗,最终指向参数最多的一个,为什么这里只复写了一个
      • 琛哥的琛:学习了😊
        请问您是怎么想到drawNum这种处理方式的?是怎么一步步想到这样处理的?
        琛哥的琛:嗯嗯 多谢
        蛇发女妖: @琛哥的琛 嗯,是这样的,一般我们确定一个点用的都是直角坐标系,但是,对于圆来讲是有一定的局限性,位置不是很好确定,对于圆来讲,还是比较建议采用极坐标的表示方法,只用角度和半径就能表示了。所以问题就变成,如何找到角度和半径与位置的关系,这个关系就是上面的drawNum方法。
      • 麋鹿原:最近正在研究这个, 很详细, 谢谢.
      • 鲁克巴克诗:我照着作者的写了一下,我只想说,您真厉害!
      • 橙一升:写的好仔细,一下子就看懂了~
      • 我有宝马:很细致啊
      • ca6c2b08a0c2:虽然现在不做android了,也觉得很厉害 :smile:
      • 3ca6f6426894:死过意
      • 云淡风轻扬:这是我看过最详细的自定义控件,感觉像大学实验报告,跟着做就能做出来的样子 :+1:
        蛇发女妖:@隽永 谢谢, :blush:
      • 暗尘随码去:写的非常详细,获益匪浅
      • DylanW:第一个圆环图,宽度为0怎么还会有圆出现?android会有默认一个最小宽度吗?
        蛇发女妖:@DylanW 问题是,绿的直接求坐标不好求,求得话得求12次,而且很麻烦。我这样的话,只要知道角度和指针的长度,就能确定他的坐标,类似于极坐标,每次画的时候调用这个函数就可以了
        DylanW:@蛇发女妖 还有纠正数字也跟着旋转的问题时,是黑->蓝->红->绿,画完再返回;能不能直接黑->绿呢?
        蛇发女妖:@DylanW 是的,0的话,宽度就是一个像素
      • 45facb536a94:不错,挺详细的!
      • 静静De欧巴:赞一个!💪💪😀
        蛇发女妖:@静静旳Oppa :grin: 3q

      本文标题:Android自定义控件之圆形时钟

      本文链接:https://www.haomeiwen.com/subject/qhqkpttx.html