Android自定义控件之圆形时钟

ecin6478 7年前
   <p>最近,电脑突然罢工了,搞了我好长时间才弄好。。所以写这篇文章耽搁了很长时间。废话不多说今天我给大家带来一个最近自己造的轮子——自定义时钟。对自定义控件有兴趣的朋友可以看看,具体内容我会尽量讲的详细。先看一下效果图:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/ea86ced8b78e4d9a3ba1668d4550ab0b.gif"></p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/6cde6c2fde454638fb5c3522be1de2dc.jpg"></p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/36560d42ec62f375d405e904bb88831c.jpg"></p>    <p>大家在做自定义控件时,可以把自己想像成一名艺术家。你在创作自己的艺术品。那么作为一名画家,你肯定得需要至少两样工具:画笔和画布。这两样是作画的基础,缺一不可。那么Android有这两样东西吗,答案是肯定的。在Android中 Paint 就是我们的画笔, Canvas 就是我们的画布。那么这两样东西该如何去用呢?其实也很简单, Paint 提供了很多方法,我们通过这些方法可以对这只笔进行设置,比如笔的颜色,画出来线条的粗细等等。而 Canvas 则负责具体要画的东西,比如点,线,矩形,圆形等等,有关具体的使用细节我一会儿会详细讲解。这里你只需要大体知道有这么个东西就可以了。</p>    <p>好了,回到我们的主题上来,画笔和画布都有了,那么问题来了,,,挖掘机技术哪家强。。。。。日。。再来一遍,,那么问题来了,如果是你想要在现实生活中画一个时钟,你觉得都得要画什么呢?我想小时候大家一定都有在自己手上画手表的经历吧。首先,当然得有一个边框吧,然后是圆心、刻度以及数字,当然还有最重要的指针,这也是构成时钟最基本的要素,相信你当时一定画的很漂亮。那么在Android中到底该如何去画呢。接下来,我就带大家一起看看,这些东西是如何一步步画在手机上的。</p>    <h2>1.准备工作</h2>    <p>首先,我们得自己定义一个类取名叫TimeView,让其继承View,然后创建构造方法,最后我们要覆写 onDraw(Canvas canvas) 方法,我们具体的画图逻辑就在这个方法中。具体代码如下:</p>    <pre>  <code class="language-java">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) {      //画具体内容      }  }</code></pre>    <p>在这里我们定义了两个构造方法,第一个大家应该都很好理解,关键是第二个,入参多了个 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方法中还没干任何事情,不过别着急,接下来我们一步步来实现。</p>    <pre>  <code class="language-java"><com.example.administrator.timeviewdemo.TimeView      android:id="@+id/time_view"      android:layout_width="300dp"      android:layout_height="300dp"/></code></pre>    <h2>2.画边框</h2>    <p>我们的边框就是一个简单的圆:</p>    <pre>  <code class="language-java">@Override  protected void onDraw(Canvas canvas){      //圆形边框      canvas.drawCircle(getWidth() / 2, getHeight() / 2, getWidth() / 3, mPaint);  }</code></pre>    <p>我们可以看到,要想画一个圆,只用调用canvas的drawCircle(float x,float y,float radius,Paint mPaint)方法,它接受四个参数,其中想x、y为圆的圆心。这里我要说一下Android的坐标系,它的坐标原点默认在屏幕的左上角,向右为为X轴正方向,向下为Y轴正方向</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/6d4eb503c48834c447202aeda5550c4d.png"></p>    <p>这里我们圆心选在控件的中心,即宽高的一半。第三个参数是圆的半径,这里我们就取控件宽的三分之一,第四个为之前我们创建的画笔。我们来运行看一下效果:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/861ec147bce9701c0104032a0cf48593.png"></p>    <p>怎么样还不错吧,总算有点东西了,这里我们的Style设的是Paint.Style.STROKE,我们换成Paint.Style.FILL试下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/959a802447700da1259974530c179a2d.png"></p>    <p>可以看到圆内被填充了,这下你应该知道FILL和STROKE的区别了吧。好了我们来看下一个</p>    <h2>3.画中心点</h2>    <p>有了外面的边框我们还可以再给它一个中心点,当然你觉得没必要,不加也可以。不过我们还是来看一下在Android中是如何画一个点的。其实也很简单,你只需调用 canvas.drawPoint(float x,float y,Paint mPaint) 方法,我想这方法也不用在过多的解释了,x,y为中心点的坐标,mPaint为之前的画笔。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/7028227459ff132cf1e7d2073e47ce95.png"></p>    <h2>4.画刻度线</h2>    <p>时钟自然是少不了刻度线啦,所以我们来看看刻度线是如何画的。刻度线说白了就是一条条的直线。那么在 Canvas 中有画直线的方法吗?答案是必须的。画布给我们提供一个叫 canvas.drawLine(float fromX,float fromY,float stopX,float stopY,Paint mPaint) 的方法;我想大家应该在初中就知道两点决定一条直线,所以这个方法中一二两个参数分别为起始点的x、y坐标,三四两个参数为终点坐标,第五个自然为我们的画笔啦。好了有了这个方法,只要求出起点坐标和终点坐标,理论上我们能画出任意的直线。不过这里可能有人要坐不住了:你扯独自呢,这么多刻度线,怎么求啊?确实,这么多刻度线,要想一条一条求出起点坐标和终点坐标,确实不太现实。那么有没有简单点的方法呢?先别急,在回答这个问题之前我们先来看一下 Canvas 的操作坐标系的几个方法:</p>    <ol>     <li>canvas.translate(float x,float y);</li>     <li>canvas.rotate(float degree);</li>     <li>canvas.rotate(float degree,float x,float y);</li>    </ol>    <p>这里我简单说一下这几个方法,第一个是坐标系的平移,传入的两个参数,分别为平移后坐标原点的X、Y坐标,说白了就是你想把坐标原点移到哪个点就传入哪个点;第二个方法是把坐标系旋转一定角度,传入正数则顺时针转,负数则相反。第三个方法是绕着传入的(X,Y)点旋转一定度数。好了,知道了这几个方法现在再画刻度线是不是有点思路了呢。我们知道,要想求出所有刻度的起始与终点坐标很复杂,也不太现实。但求一条刻度的坐标还是好求的。为了坐标表示方便我们移动一下坐标系,即调用 canvas.translate(getWidth()/2,getHeight()/2) 将坐标原点移到圆心处。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/a059faaed5ab3e90a59e483c98b0540b.png"></p>    <p>如上图所示,我们把坐标原点移到圆心,这样如果我们要画图中绿色刻度线,其实就很简单了。起始坐标和终点坐标的Y轴坐标均为0,起始坐标的X轴坐标为半径减去刻度线长度,而终点坐标的X轴坐标就是半径。怎么样,这样画一条刻度线是不是挺简单的,相信你一定能画好。好,接下来我们再画一条,不过在画之前,我们得做一个小小的动作,就是把坐标系旋转一下。如下图:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/fd8f4af64c32fa8b0852124f631b6dd2.png"></p>    <p>我们把原来的红色坐标系顺时针旋转了a角度得到了黄色坐标系,也就是调用了 canvas.rotate(a) ,我们之前说过顺时针转,要传入正值,所以这里的 a 是一个正数。好了,这样我们再来求一下黄色X’轴上的刻度线,会发现它的坐标和第一条刻度线的坐标是一样的。是不是问题变得很简单了。这样不管你要画几条刻度线,不管你想画在哪,只要旋转你的坐标系,而不用反复的计算刻度线的坐标。比如,我们都知道圆是360度,你想每隔一度,就画一条刻度线,那么你就每次旋转一度,然后画一条线。这样不断循环后,就画出了360条刻度线。当然你可以根据自己的需求画任意条。代码如下:</p>    <pre>  <code class="language-java">@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);       }  }</code></pre>    <p>效果如下:我是每隔1度画了一条刻度线。为便于观看,我放大了整个图片,可以看到我们的刻度线分布的还是很均匀、整齐的。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/25aa6848551c5cd4edf7ca51a2da47fe.png"></p>    <p>当然如果你觉得刻度线的长度都一样长,太单调了你也可以进行适当的改变。比如你可以每秒钟设置一个中等长度,每五秒钟设置一个最长的长度,然后其他的刻度线都设置一个最小的长度。我们知道圆是360度,并且秒针转一圈为60秒,所以一秒就对应360度/60秒=6度,那么五秒也就是5*6 = 30度。得到这两个关键的角度我们就可以写代码了:</p>    <pre>  <code class="language-java">@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);      }</code></pre>    <p>效果如下:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/162e151dd3284843daa6b9b62da6c82f.png"></p>    <h2>4.画数字</h2>    <p>接下来我们在时钟上画上1-12的数字,有关写字Canvas给我提供了这样一个方法: drawText(String text,float x,float y,Paint mPaint) ;其中text指我们要写的字,mPaint是我们的画笔,那么x,y是什么呢?很显然x和y是用来给文字定位用的,x指的文字最左边的X坐标,那么y呢,难道是文字最下边的Y坐标吗。其实不是的。我们来看下图:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/f160c333de40c376050bf7f9a2e74ac0.png"></p>    <p>上图给出个文字的一些尺寸参数,我们可以看到其中那条黑线,即Baseline,上文的y其实就是这条线的Y坐标。Baseline到文字顶部距离叫做ascent,Baseline到文字底部叫做descent,我们知道一般文字上部和下部会有一点padding,所以top和bottom的距离会略大于ascent,descent。如果有两行文字,那么上一行的descent到下一行的ascent的距离就叫做leading,即行间距。那么我们如何能得到这些参数呢。其实很简单,在调用drawText方法之前,我们一般会通过mPaint.setTextSize(float size);来设置字体大小,设完以后,我们就可以通过mPaint.getFontMetrics()方法来得到一个Paint.FontMetrics对象,这个对象封装了上述我们要的文字尺寸信息。代码如下:</p>    <pre>  <code class="language-java">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;</code></pre>    <p>注意:上述这些参数大小与具体是什么文字无关,只与字体大小和字体格式有关。并且,在Baseline上方的尺寸为负,下方为正。也就是top、ascent都是负数,bottom和descent为正数。</p>    <p>好了,知道了如何写文字后,我们就可以在时钟上写上我们要的十二个数字了,一共12个数字,一个圆360度,所以每个30度写一个字。这样我们就可以用之前的方法,没写完一个数,就将坐标系旋转30度。代码如下:</p>    <pre>  <code class="language-java">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);      }  }</code></pre>    <p>上面的代码还是好理解的,我们创建一个循环,每循环一次就写个文字,并且将坐标系顺时针旋转30度,其中我们可以看到,我们创建了一个矩形,然后我们调用mPaint.getTextBounds(String text,int start,int end,Rect textBound)将文字的边框存入其中,这个方法传入四个参数,第一个为我们要画的字符串,第二三个参数分别为这个字符串的开始角标和结束叫角标,最后一个为矩形。这样我们就可以把这个矩形理解为这个字符串的边框,有了边框我们就可以知道这个字符串的很多参数,比如上下左右的坐标,以及字符串的宽高等。这样当我们画数字时,它的X坐标就是文字宽度的一半,注意别忘了负号。好了我们来看下效果如何:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/1c1299de11197704c88d811929c712e2.png"></p>    <p>没错正如你所料,虽然数字是有了,而且还挺整齐的,不过文字也跟着旋转了。看来简单的旋转坐标系是不行了。那还有其他办法吗,有的人可能会说了,直接算出每个数字的具体坐标然后在画。这样当然可以,只要你够耐心,而三角函数还不错的话,可以尝试下。不过我还是劝你不要这么干,因为这样计算既麻烦而且算的准确度也不高。那么还有什么更好的办法呢。这里我想到了一个好办法,可以给大家参考一下。其实我们每一次画数字的时候可以提取出一个动作,举个例子,比如我们要画数字“1”,如下图所示,我们知道“12”和“1”之间为30度,那么我们可以先将图中黑色坐标系顺时针旋转30度,得到蓝色坐标系,然后我们将蓝色坐标系沿着Y轴反方向移动合适的距离,得到红色坐标系,然后再将坐标系逆时针转30度得到绿色坐标系,我们的目标就是在绿色坐标系的中心画上数字,具体怎么画,我想也不用多说了。画完后,再将坐标系原路返回。也就是,将绿色坐标系顺时针旋转30度,回到红色坐标系,然后将红色坐标系沿着Y轴正方向移动和之前平移时同样的距离,得到蓝色坐标系,最后将蓝色坐标系逆时针旋转30度回到原来的黑色坐标,即刚开始的坐标系。这样经过一系列的动作,画完一个数字,我们的坐标系还是和原来没画数字时的一样。这样我们就可把这一系列动作写成一个方法,在每次画数字之前调用它就行。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/d979657c88502b39359557f321cfd3f6.png"></p>    <p>这系列动作我们可以写成如下方法:</p>    <pre>  <code class="language-java">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);      }</code></pre>    <p>这个方法,我们传入四个参数,分别为画布,要画数字与12点之间的夹角,要画的数字以及画笔。接下来,在我们每次画数字是调用这个方法就行了:</p>    <pre>  <code class="language-java">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);      }  }</code></pre>    <p>代码还是挺直观的,我就不过多解释了。我们来看一下效果:</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/45964d7f5bd654bce4b003eb403c375d.png"></p>    <p>只能用两字形容“完美”。</p>    <h2>3.画指针</h2>    <p>好了,数字也总算画好了,接下来就只剩下指针了,指针分秒针、分针和时针,知道一种怎么画就可以。其实很简单,这里我直接调用drawLine()方法,代码如下:</p>    <pre>  <code class="language-java">//秒针  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();</code></pre>    <p>因为我们每个指针的旋转角度都不同,所以为了避免相互影响,我们把每个指针画在canvas.save()和canvas.restore()之间,相当每个指针都画在不同的图层上,最后合并为一张图。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/cc6fd007ddb96b09fab7ba2aae74ae00.png"></p>    <p>好了这样我们的时钟算是画完了,不过细心的朋友可能会发现,这里还有个bug,分针在5分钟时,时针不应该是正对着的,而是有点偏差的,那么这偏差具体是多少呢?还有现在的时钟还是静态的,又如何让它动起来呢?由于篇幅有限,这些内容将写在下篇文章中。当然全部代码我已经上传到GitHub上,有兴趣的可以去看一下,记得给个星星哦。。</p>    <p> </p>    <p> </p>