这是笔者自学习自定义View以来,分享的第五篇效果,之前分享过一篇动态时钟效果的自定义View,如果有兴趣的可以看看:
Android自定义View分享——一个时钟
之前的博客笔者一般都会说,这是不太复杂但是“长得”还可以的效果,这次的分享也长得挺好看的,但是不敢说“不太复杂”。虽然没有用到很高级的API(这篇分享还是以canvas的各个API调用为主),但是在涉及到计算的地方,确实是有些复杂(繁琐)。让我们看看这次的效果:
这里主要说明两点:
如图所示,唱针分为三个部分,第一个部分是旋转支点,就是顶部的两个重叠的圆,第二个部分是手臂,一长一短的两节,第三个部分就是头部,头部也有一长一短两段。但实际上这三个部分与绘制有关的API只需要两个:canvas.drawCircle()、canvas.drawLine()。第一个部分只要绘制两个同心圆即可,手臂和头都可以用canvas.drawLine()来绘制,只要改变Paint的宽度,就能有手臂、头的效果。
这里主要说一下唱针这四条线段的绘制方法,一个比较直观的做法是定下第一条线段起点的坐标,然后根据四条线段的长度,拐角角度,通过三角函数算出各个点坐标,然后就能绘制啦。。。。想想都觉得心好累,如果你选的不是诸如30°、45°、60°、90°这种比较特殊的角度,其他的角度算起来各种麻烦,就算你选的是特殊角度,算起来也很麻烦,还容易出错。那有没有方便的做法?有。只要利用canvas.translate()和canvas.rotate()方法(即平移和旋转坐标系),就可以只关注线段长度,彻底抛弃三角函数,来看看流程示意图:
根据图片所示流程,就可以得出代码了:
/**
* 绘制旋转了指定角度的唱针。
* 说明一下旋转了指定角度什么意思,看上面的流程图可以知道,
* 长的那段手臂和垂直方向是成角15°的,实际上这个角度不是一成不变的,
* 通过控制这个角度变化,可以达到唱针处于播放/暂停状态或者在两个状态之间摆动的效果。
*/
private void drawNeedle(Canvas canvas, int degree){
// 移动坐标到水平中点
canvas.save();
canvas.translate(halfMeasureWidth, 0);
// 准备绘制唱针手臂
needlePaint.setStrokeWidth(20);
needlePaint.setColor(Color.parseColor("#C0C0C0"));
// 绘制第一段臂
canvas.rotate(degree);
canvas.drawLine(0, 0, 0, longArmLength, needlePaint);
// 绘制第二段臂
canvas.translate(0, longArmLength);
canvas.rotate(-30);
canvas.drawLine(0, 0, 0, shortArmLength, needlePaint);
// 绘制唱针头
// 绘制第一段唱针头
canvas.translate(0, shortArmLength);
needlePaint.setStrokeWidth(40);
canvas.drawLine(0, 0, 0, longHeadLength, needlePaint);
// 绘制第二段唱针头
canvas.translate(0, longHeadLength);
needlePaint.setStrokeWidth(60);
canvas.drawLine(0, 0, 0, shortHeadLength, needlePaint);
canvas.restore();
// 两个重叠的圆形,即唱针顶部的旋转点
canvas.save();
canvas.translate(halfMeasureWidth, 0);
needlePaint.setStyle(Paint.Style.FILL);
needlePaint.setColor(Color.parseColor("#C0C0C0"));
canvas.drawCircle(0, 0, bigCircleRadius, needlePaint);
needlePaint.setColor(Color.parseColor("#8A8A8A"));
canvas.drawCircle(0, 0, smallCircleRadius, needlePaint);
canvas.restore();
}
唱片分为两个部分,一个是黑色圆环,一个是圆形图片。
黑色圆环很好办,绘制一个空心圆就行,Paint宽度调大一些,就有圆环的效果了。
比较特殊的是图片,图片素材本身肯定是矩形的,那么怎么绘制一个圆形的图片呢?Canvas提供有裁切绘制区域的API——canvas.clipPath(),它接受一个Path对象,然后Path有添加圆形的方法——path.addCircle(),这两个API结合起来,就可以裁切出一个圆形区域,此时调用canvas.drawBitmap()方法,只有处于该圆形区域的内容会被绘制,其它内容不会绘制,这样就能达到绘制圆形图片的效果了。接下来是绘制唱针的代码:
// 绘制旋转了指定角度的唱片(类似唱针,唱片里面的图片是会旋转不同角度的)
private void drawDisk(Canvas canvas, float degree){
// 移动坐标系到唱针下方合适位置,然后旋转指定角度
canvas.save();
canvas.translate(halfMeasureWidth, pictureRadius+ringWidth+longArmLength);
canvas.rotate(degree);
// 绘制圆环
canvas.drawCircle(0, 0, pictureRadius+ringWidth/2, discPaint);
// 绘制图片
canvas.clipPath(clipPath);
canvas.drawBitmap(bitmap, srcRect, dstRect, discPaint);
canvas.restore();
}
由于在onDraw()方法里面不适合创建对象,所以把Path的初始化代码单独拿出来:
Path clipPath = new Path();
clipPath.addCircle(0, 0, pictureRadius, Path.Direction.CW);
至此,唱片机的静态样式就绘制完成了,接下来看动态效果。
关于动画效果,需要先说一下唱针的角度问题。在笔者的设计里,当唱针第一段手臂和垂直方向成角15°时,表明处于播放状态,当唱针第一段手臂和垂直方向成角45°时,表明处于暂停状态。在两者之间就属于播放/暂停切换的动画。又由于在canvas.rotate()方法里面,逆时针角度是负数,顺时针是正数,所以得出的两个角度常量是这样的:
// 播放状态时唱针的旋转角度
private static final int PLAY_DEGREE = -15;
// 暂停状态时唱针的旋转角度
private static final int PAUSE_DEGREE = -45;
既然要支持动画,就要不断地重绘,那么怎么判断什么时候需要重绘?我们既要绘制唱针,又要绘制唱片,难道要在两个函数分别判断?其实不用。仔细观察本文开头的动态效果图,你会发现,其实只有处于暂停的状态时,才停止重绘(即停止动画)。其它的不论出于播放状态还是播放/暂停切换状态,都要保持不断重绘。那么重绘的触发点就有了:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// ........省略绘制唱片和唱针的代码........
// 如果唱针当前角度大于暂停状态下的角度(注意了由于是负数所以是大于),
// 继续重绘
if(needleDegreeCounter > PAUSE_DEGREE){
invalidate();
}
}
主要说的是用来控制唱针和唱片角度的计数器,也是实现动画效果的关键计数器。由于唱针播放/暂停状态之间相差了30°,所以每次变化的角度,就选择30的约数,笔者选择了3°。对于唱片,控制唱片每次旋转角度变化,就等于控制了整个唱片机看起来的旋转速度,虽然一周是360°,但是唱片每次变化角度不需要是360°的约数,因为每次叠加之后都会对360取余数,以达到循环转动的效果。现在可以来看看关于动画的几部分关键代码:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
halfMeasureWidth = getMeasuredWidth()>>1;
// 绘制唱片
drawDisk(canvas);
// 绘制唱针
drawNeedle(canvas);
// 根据唱针当前角度判断是否继续重绘
if(needleDegreeCounter > PAUSE_DEGREE){
invalidate();
}
}
// 绘制唱片,该方法主要是控制旋转角度用的
private void drawDisk(Canvas canvas){
// 这里的diskRotateSpeed变量就是唱片每次变化角度,就是旋转速度的意思
diskDegreeCounter = diskDegreeCounter%360+diskRotateSpeed;
// 该方法就是前面讨论唱片绘制时说的“绘制旋转了指定角度的唱片”方法
drawDisk(canvas, diskDegreeCounter);
}
// 绘制唱针,该方法主要是控制唱针旋转角度用的
private void drawNeedle(Canvas canvas){
// 根据播放/暂停状态控制唱针角度的加/减变化
if(isPlaying){
if(needleDegreeCounter < PLAY_DEGREE){
needleDegreeCounter+=3;
}
} else {
if(needleDegreeCounter > PAUSE_DEGREE){
needleDegreeCounter-=3;
}
}
// // 该方法就是前面讨论唱针绘制时说的“绘制旋转了指定角度的唱针”方法
drawNeedle(canvas, needleDegreeCounter);
}
至此,关于整个View的静态、动画效果的介绍,都说完了,但是事情还没完,还有一些很关键的细节问题。接下来继续。
整个View的绘制牵扯到若干尺寸(或者说叫做长度),比如图片的半径,圆环半径,唱针四个部分的长度。还有一个很关键的问题就是,唱针和唱片之间的位置关系,我们来看下播放状态的静态图。
可以看到,播放状态下的唱针头,是要刚好摆放在圆环上的,既不能没压到圆环上,也不能压到旋转的图片上。这就要求我们前面说的那几个尺寸之间有一定的计算关系,否则的话就会出现下面这些bug:
那么现在问题来了,到底尺寸该怎么设计,怎样的长度关系才能避免这些bug呢?从理论的层面来讲,笔者也没有什么推理论证的办法,不过笔者经过反复尝试,最终得到一个还算合适的尺寸关系,在笔者能搞到的三台屏幕大小和分辨率不同的手机上测试,效果还可以。这里直接放笔者的结果吧:
/**
* 尺寸关系设计说明:
* 1、唱片有两个主要尺寸:中间图片的半径、黑色圆环的宽度。
* 黑色圆环的宽度 = 图片半径的一半。
* 2、唱针分为“手臂”和“头”,手臂分两段,一段长的一段短的,头也是一段长的一段短的。
* 唱针四个部分的尺寸求和 = 唱片中间图片的半径+黑色圆环的宽度(即整个唱片的半径)
* 唱针各部分长度比例————长的手臂:短的手臂:长的头:短的头 = 8:4:2:1
* 3、唱片黑色圆环顶部(即唱片顶部)到唱针顶端的距离 = 唱针长的手臂的长度。
*/
基于以上的尺寸关系,整个View的各个尺寸,基本都依赖于唱片的图片半径,只要确定了唱片里的图片半径,其他各个尺寸都能算出来。那么这个图片半径怎么确定?没有什么特别的法则,因为这个变量是要提供给调用者来设置的,也可以提供给xml属性,当然了我们也会给他一个默认数值。
至此,关于尺寸适配的问题,也说完了。
为了让这个唱片机功能更饱满(完善),我们来加上xml属性,首先在res/values/路径下新建attrs.xml文件,然后添加标签作为属性声明:
<declare-styleable name="GramophoneView">
<attr name="picture_radius" format="dimension"/>
<attr name="src" format="reference"/>
<attr name="disk_rotate_speed" format="float"/>
declare-styleable>
这是一个关于图片加载的问题。有相关经验的同学都知道,对于尺寸未知的图片,在加载到内存之前,需要进行各种运算,主要是为了避免内存和性能的问题。这是笔者在这个View里面没有解决的问题,但是笔者也不打算解决,因为关于图片加载、尺寸计算、优化这些知识点,几篇博客都讲不完,在这里讲这些,还是算了吧。。。。笔者的这个demo里面选的都是尺寸不太大的图片,所以运行起来不会出大事,如果读者要将这个demo用于项目里,可套用一些第三方框架处理图片问题。
总体来讲调用的API不算复杂,主要是应用了一些技巧,复杂的地方都在尺寸相关的计算上。大概总结如下几点:
https://github.com/kingfarou/SimpleCustomView
这个项目是笔者一系列自定义View的集合,对应本文的View的文件名是:GramophoneView.java