Android自定义View分享——仿网易云音乐留声机效果

写在前面

这是笔者自学习自定义View以来,分享的第五篇效果,之前分享过一篇动态时钟效果的自定义View,如果有兴趣的可以看看:
Android自定义View分享——一个时钟

之前的博客笔者一般都会说,这是不太复杂但是“长得”还可以的效果,这次的分享也长得挺好看的,但是不敢说“不太复杂”。虽然没有用到很高级的API(这篇分享还是以canvas的各个API调用为主),但是在涉及到计算的地方,确实是有些复杂(繁琐)。让我们看看这次的效果:

效果说明

这里主要说明两点:

  1. 除了中间旋转的图片,所有其它都是“画”上去的。包括上面的唱针(就是那个摆动的手臂,在唱片机/留声机里面的叫法,好像是唱针)都是绘制上去的,笔者看过其它博客实现方式,有直接拿ImageView或者一个唱针的Bitmap来旋转的,好坏不评价,但是从学习的角度来讲,自己绘制比较过瘾。
  2. 关于动画,每一次的绘制都是静态的,所谓的动画都是通过不断重绘,在重绘的同时改变旋转角度来实现的,最终的动画不过是眼睛的感觉而已。接下来看设计思路。

设计思路——绘制唱针

Android自定义View分享——仿网易云音乐留声机效果_第1张图片

如图所示,唱针分为三个部分,第一个部分是旋转支点,就是顶部的两个重叠的圆,第二个部分是手臂,一长一短的两节,第三个部分就是头部,头部也有一长一短两段。但实际上这三个部分与绘制有关的API只需要两个:canvas.drawCircle()、canvas.drawLine()。第一个部分只要绘制两个同心圆即可,手臂和头都可以用canvas.drawLine()来绘制,只要改变Paint的宽度,就能有手臂、头的效果。

这里主要说一下唱针这四条线段的绘制方法,一个比较直观的做法是定下第一条线段起点的坐标,然后根据四条线段的长度,拐角角度,通过三角函数算出各个点坐标,然后就能绘制啦。。。。想想都觉得心好累,如果你选的不是诸如30°、45°、60°、90°这种比较特殊的角度,其他的角度算起来各种麻烦,就算你选的是特殊角度,算起来也很麻烦,还容易出错。那有没有方便的做法?有。只要利用canvas.translate()和canvas.rotate()方法(即平移和旋转坐标系),就可以只关注线段长度,彻底抛弃三角函数,来看看流程示意图:

Android自定义View分享——仿网易云音乐留声机效果_第2张图片

根据图片所示流程,就可以得出代码了:

/** 
 * 绘制旋转了指定角度的唱针。
 * 说明一下旋转了指定角度什么意思,看上面的流程图可以知道,
 * 长的那段手臂和垂直方向是成角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的绘制牵扯到若干尺寸(或者说叫做长度),比如图片的半径,圆环半径,唱针四个部分的长度。还有一个很关键的问题就是,唱针和唱片之间的位置关系,我们来看下播放状态的静态图。
Android自定义View分享——仿网易云音乐留声机效果_第3张图片

可以看到,播放状态下的唱针头,是要刚好摆放在圆环上的,既不能没压到圆环上,也不能压到旋转的图片上。这就要求我们前面说的那几个尺寸之间有一定的计算关系,否则的话就会出现下面这些bug:

第一个情况:唱针压到图片上了
Android自定义View分享——仿网易云音乐留声机效果_第4张图片

第二个情况:唱针离唱片太远了
Android自定义View分享——仿网易云音乐留声机效果_第5张图片

那么现在问题来了,到底尺寸该怎么设计,怎样的长度关系才能避免这些bug呢?从理论的层面来讲,笔者也没有什么推理论证的办法,不过笔者经过反复尝试,最终得到一个还算合适的尺寸关系,在笔者能搞到的三台屏幕大小和分辨率不同的手机上测试,效果还可以。这里直接放笔者的结果吧:

/**
 * 尺寸关系设计说明:
 * 1、唱片有两个主要尺寸:中间图片的半径、黑色圆环的宽度。
 *    黑色圆环的宽度 = 图片半径的一半。
 * 2、唱针分为“手臂”和“头”,手臂分两段,一段长的一段短的,头也是一段长的一段短的。
 *    唱针四个部分的尺寸求和 = 唱片中间图片的半径+黑色圆环的宽度(即整个唱片的半径)
 *    唱针各部分长度比例————长的手臂:短的手臂:长的头:短的头 = 8:4:2:1
 * 3、唱片黑色圆环顶部(即唱片顶部)到唱针顶端的距离 = 唱针长的手臂的长度。
 */

基于以上的尺寸关系,整个View的各个尺寸,基本都依赖于唱片的图片半径,只要确定了唱片里的图片半径,其他各个尺寸都能算出来。那么这个图片半径怎么确定?没有什么特别的法则,因为这个变量是要提供给调用者来设置的,也可以提供给xml属性,当然了我们也会给他一个默认数值。

至此,关于尺寸适配的问题,也说完了。

添加对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不算复杂,主要是应用了一些技巧,复杂的地方都在尺寸相关的计算上。大概总结如下几点:

  1. 整个View主要包括绘制唱针、唱片、实现动画效果、适配尺寸四个问题。
  2. 唱针的绘制主要通过canvas.rotate()和canvas.translate()方法代替复杂的三角函数运算。唱片的实现主要通过canvas.clipPath()来实现圆形图片绘制。
  3. 动画效果的本质是不断重绘,重点在于对角度计数器的计算以及对关键角度的控制和判断。
  4. 尺寸适配主要是在唱针和唱片各个尺寸之间建立联系,难点在于当唱片设置了不同大小(半径)时,唱针和唱片之间仍然能保持合适的位置关系。
  5. 不足之处在于剩下一个没有处理的问题:图片加载。

项目源码

https://github.com/kingfarou/SimpleCustomView
这个项目是笔者一系列自定义View的集合,对应本文的View的文件名是:GramophoneView.java

你可能感兴趣的:(Android自定义View)