1、概述
前几日出差,每晚回到酒店的时候,睡前打发时间就是拿起自己的小米手机撸剧,酒店的wifi网络实在太差,眼睁睁的看着小米视频的加载动画一直拼命的loading中,正好最近一直在看自定义view的东西,何不乘此撸一个山寨的小米视频动画练练手,废话不多说了,先上效果图。
2、原理分析
2.1 总体结构分析
如上图所示,动画主要由四个三角形构成,对四个三角形进行标号,其中中间的三角形为1号,外围顺时针方向依次为2、3、4号。该图形的巧妙之处在于四个三角形整体又组合为一个新的大三角形。根据上文的预览动画可以看出,四个三角形是依次出现和消失的,四个三角形出现的顺序正好是按照编号1-2-3-4出现的,消失的顺序是按照4-2-3-1(不是4-3-2-1)。
2.2 三角形的出现形式
顾名思义,只要知道三角形的三个顶点,即可以绘制出对应的三角形来。动画中三角形的绘制有个渐变过程,三角形的出现不是一蹴而就的,这里以上文中的1号三角形为例进行说明,三角形在加载的过程中,会以其中一个顶点开始,向其他两个顶点进行延伸扩展。我们这里把延伸的起点称为start,延伸中的两个顶点分别是current1和current2,延伸的终点称为end1和end2,演变的过程很简单,即start点保持不变,其余两个顶点分别从start点向end点延伸。
3、代码实现
说了这么多,可以着手开始撸代码了。既然我们的动画都是以三角形为单元的,所以我们可以定义一个三角形类TriangleView,这个类至少包含如下几个属性:
•三角形的三个顶点坐标,即上文分析中提到的start、end1、end2坐标;
•三角形的背景色;
•三角形目前加载过程中current1和current2的坐标位置;
1.public class TriangleView { 2. 3. // 起始点坐标 4. public int startX; 5. public int startY; 6. //终点坐标 7. public int endX1; 8. public int endY1; 9. public int endX2; 10. public int endY2; 11. //当前延伸中的坐标位置 12. public int currentX1; 13. public int currentY1; 14. 15. public int currentX2; 16. public int currentY2; 17. 18. //背景色 19. public String color; 20. 21.}
代码很简单,仅仅是我们上述所描述的属性,起点坐标、终点坐标、延伸中的当前坐标和背景色。
下面自定义我们的View,我这里起名为MyVideoView,代码如下:
1.public class MyVideoView extends View{ 2. 3. //控件中心点坐标 4. private int cvX,cvY; 5. //三角形边长 6. private int edge = 200; 7. //画笔 8. private Paint myPaint; 9. //绘制三角形的路径 10. private Path mPath; 11. //存放三角形的数组,一共有4个三角形 12. private TriangleView[] triangles = new TriangleView[4]; 13. //绘制状态,用来标记当前应该绘制哪个三角形 14. private STATUS currentStatus = STATUS.MID_LOADING; 15. //绘制动画 16. private ValueAnimator valueAnimator; 17. //枚举变量,存放绘制状态 18. private enum STATUS { 19. MID_LOADING, 20. FIRST_LOADING, 21. SECOND_LOADING, 22. THIRD_LOADING, 23. LOADING_COMPLETE, 24. THIRD_DISMISS, 25. FIRST_DISMISS, 26. SECOND_DISMISS, 27. MID_DISMISS 28. } 29. 30. 31. public MyVideoView(Context context) { 32. super(context); 33. init(); 34. } 35. 36. private void init() { 37. //初始画笔和路径 38. myPaint = new Paint(); 39. myPaint.setStyle(Paint.Style.FILL); 40. myPaint.setAntiAlias(true); 41. mPath = new Path(); 42. } 43. 44. public MyVideoView(Context context, @Nullable AttributeSet attrs) { 45. super(context, attrs); 46. init(); 47. } 48. 49. public MyVideoView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) { 50. super(context, attrs, defStyleAttr); 51. init(); 52. } 53. 54. @Override 55. protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 56. super.onMeasure(widthMeasureSpec, heightMeasureSpec); 57. //初始化控件中心点和四个三角形的位置 58. cvX = getMeasuredWidth()/2; 59. cvY = getMeasuredHeight()/2; 60. initTriangle(); 61. }
如上代码,注释已经很清楚了,相信你们都可以看明白,需要说明的是,因为我们的动画是由四个三角形构成,所以我们用TriangleView数组来进行存放,存放的顺序按照上文中的1-2-3-4号三角形存放,即三角形的出现顺序。另外,为了让程序在特定的时间点知道应当绘制哪一个三角形,设置一个枚举变量来存放当前应当绘制的状态。枚举变量的含义这里简单说下,XXXX_LOADING表示是第几个三角形正在展现,例如MID_LOADING表示中间的三角形开始绘制,XXXX_DISMISS表示第几个三角形正在消失,LOADING_COMPLETE表示四个三角形全部展现完毕,需要在这个状态下停留一段时间。
在onMeasure的时候,除了获取控件的中心点坐标以为,还需要对四个三角形的坐标进行初始化,首先需要确定的就是1号三角形,即中间三角形的位置,为了进一步说明坐标的计算,示意图如下所示:
如上图是中间三角形的示意图,三个顶点我们记为A、B、C,根据最终三角形的构造来看,中间三角形的中心点E为控件的中心点,即上述代码中的CvX和CvY,我们需要通过中心点CvX和CvY,即E点,求出A、B、C点的坐标,三角形是一个等边三角形,同时边长Edge是已知的,所以要求出线段AD、DB、ED、CE的长度就可以计算出A、B、C点三点的坐标。由于AD、DB正好是二分之一的边长,所以也是已知的,E是中心点,所以ED和CE的长度一样,所以关键只要求出CD的值就可以了,而三角形BCD又是一个直角三角形,CD是直角边,根据勾股定理,可以求出CD的值,CD的长度为BC的平方-DB的平方再开方即可(这里吐槽一下,初中的几何知识较多尴尬)
废话又太多了,上代码:
1.private void initTriangle() { 2. //计算中间三角形的坐标位置,startx表示要开始延伸的起点,endx1和endx2表示延伸的两个终点,currentX1、currentX2表示的是正在延伸的点的位置 3. currentStatus = STATUS.MID_LOADING; 4. TriangleView triangleView = new TriangleView(); 5. //offset就是CD的长度,利用勾股定理 6. int offset = (int) Math.sqrt(Math.pow(edge,2) - Math.pow(edge/2,2)); 7. triangleView.startX = cvX + offset/2; 8. triangleView.startY = cvY + edge/2; 9. triangleView.endX1 = cvX + offset/2; 10. triangleView.endY1 = cvY - edge/2; 11. triangleView.endX2 = cvX - offset/2; 12. triangleView.endY2 = cvY; 13. //current为延伸中的实时坐标,默认在起始点位置 14. triangleView.currentX1 = triangleView.startX; 15. triangleView.currentY1 = triangleView.startY; 16. triangleView.currentX2 = triangleView.startX; 17. triangleView.currentY2 = triangleView.startY; 18. triangleView.color = "#be8cd5"; 19. triangles[0] = triangleView; 20. //计算第一个三角形的坐标位置 21. TriangleView firstTriangle = new TriangleView(); 22. firstTriangle.startX = triangleView.endX2; 23. firstTriangle.startY = triangleView.endY2; 24. firstTriangle.endX1 = triangleView.endX1; 25. firstTriangle.endY1 = triangleView.endY1; 26. firstTriangle.endX2 = firstTriangle.startX; 27. firstTriangle.endY2 = firstTriangle.startY - edge; 28. firstTriangle.color = "#fcb131"; 29. triangles[1] = firstTriangle; 30. //计算第二个三角形的坐标位置 31. TriangleView secondTriangle = new TriangleView(); 32. secondTriangle.startX = triangleView.endX1; 33. secondTriangle.startY = triangleView.endY1; 34. secondTriangle.endX1 = secondTriangle.startX; 35. secondTriangle.endY1 = secondTriangle.startY + edge; 36. secondTriangle.endX2 = secondTriangle.startX + offset; 37. secondTriangle.endY2 = secondTriangle.startY + edge/2; 38. secondTriangle.color = "#67c6ca"; 39. triangles[2] = secondTriangle; 40. //计算第三个三角形的坐标位置 41. TriangleView thirdTriangle = new TriangleView(); 42. thirdTriangle.startX = triangleView.startX; 43. thirdTriangle.startY = triangleView.startY; 44. thirdTriangle.endX1 = triangleView.endX2; 45. thirdTriangle.endY1 = triangleView.endY2; 46. thirdTriangle.endX2 = triangleView.endX2; 47. thirdTriangle.endY2 = thirdTriangle.endY1 + edge; 48. thirdTriangle.color = "#eb7583"; 49. triangles[3] = thirdTriangle; 50. }
相信上面说了那么多,这块代码应当很容易理解,在纸上把四个三角形的相对位置绘制出来,坐标位置便一目了然。
三角形的具体绘制放到onDraw中,代码如下:
1.protected void onDraw(Canvas canvas) { 2. super.onDraw(canvas); 3. for (int i = 0; i < triangles.length;i++){ 4. mPath.reset(); 5. //移动到当前三角形的起始点位置上 6. mPath.moveTo(triangles[i].startX,triangles[i].startY); 7. //连接目前的current1 8. mPath.lineTo(triangles[i].currentX1,triangles[i].currentY1); 9. //连接目前的current2 10. mPath.lineTo(triangles[i].currentX2,triangles[i].currentY2); 11. //三角形线段闭合 12. mPath.close(); 13. //设置三角形颜色 14. myPaint.setColor(Color.parseColor(triangles[i].color)); 15. //绘制三角形 16. canvas.drawPath(mPath,myPaint); 17. //当只绘制中间三角形时,其他三角形不需要进行绘制 18. if (currentStatus == STATUS.MID_LOADING){ 19. break; 20. } 21. } 22. 23. }
onDraw的方法是对三角形的实际绘制,代码量没有几行,原理也很简单,只是将三角形数组triangles中的三角形对象取出来分别进行绘制。这里有个简单的处理,即如果当前的绘制状态是MID_LOADING的时候,即最开始绘制中间三角形的时候,其他三角形没有必要绘制了,通过break跳出循环。
动画的制作最为关键的就是插值,在绘图的过程中不停的改变绘制的变量来达到动画形变的效果,好了继续粘贴动画插值的代码:
1.public void startTranglesAnimation() { 2. //初始化三角形位置 3. initTriangle(); 4. //如果有动画已经在执行了,取消当前执行的动画。 5. if (valueAnimator != null && valueAnimator.isRunning()){ 6. valueAnimator.cancel(); 7. } 8. //动画插值从0变成1 9. valueAnimator = ValueAnimator.ofFloat(0,1); 10. //每次动画的执行时长为300毫秒 11. valueAnimator.setDuration(300); 12. //无限次执行 13. valueAnimator.setRepeatCount(-1); 14. //每次执行的方案都是从头开始 15. valueAnimator.setRepeatMode(ValueAnimator.RESTART); 16. //监听每次动画的循环情况,没循环一次进入下一个阶段 17. valueAnimator.addListener(new Animator.AnimatorListener() { 18. @Override 19. public void onAnimationStart(Animator animation) { 20. 21. } 22. 23. @Override 24. public void onAnimationEnd(Animator animation) { 25. 26. } 27. 28. @Override 29. public void onAnimationCancel(Animator animation) { 30. 31. } 32. 33. @Override 34. public void onAnimationRepeat(Animator animation) { 35. //当上一个动画状态执行完之后进入下一个阶段。 36. if (currentStatus == STATUS.MID_LOADING){ 37. currentStatus = STATUS.FIRST_LOADING; 38. }else if (currentStatus == STATUS.FIRST_LOADING){ 39. currentStatus = STATUS.SECOND_LOADING; 40. }else if (currentStatus == STATUS.SECOND_LOADING){ 41. currentStatus = STATUS.THIRD_LOADING; 42. }else if (currentStatus == STATUS.THIRD_LOADING){ 43. currentStatus = STATUS.LOADING_COMPLETE; 44. reverseTriangleStart(); 45. }else if (currentStatus == STATUS.LOADING_COMPLETE){ 46. currentStatus = STATUS.THIRD_DISMISS; 47. }else if (currentStatus == STATUS.THIRD_DISMISS){ 48. currentStatus = STATUS.FIRST_DISMISS; 49. }else if (currentStatus == STATUS.FIRST_DISMISS){ 50. currentStatus = STATUS.SECOND_DISMISS; 51. }else if (currentStatus == STATUS.SECOND_DISMISS){ 52. currentStatus = STATUS.MID_DISMISS; 53. }else if (currentStatus == STATUS.MID_DISMISS){ 54. Log.e("wangjinfeng","onAnimationRepeat"); 55. currentStatus = STATUS.MID_LOADING; 56. reverseTriangleStart(); 57. } 58. } 59. }); 60. //监听动画执行过程 61. valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { 62. @Override 63. public void onAnimationUpdate(ValueAnimator animation) { 64. //或者目前的插值(0-1) 65. float fraction = animation.getAnimatedFraction(); 66. //如果目前的动画是消失状态,则插值正好是反过来的,是1-0,所以需要用1-fraction 67. if (currentStatus == STATUS.FIRST_DISMISS || currentStatus == STATUS.SECOND_DISMISS || currentStatus == STATUS.THIRD_DISMISS || currentStatus == STATUS.MID_DISMISS){ 68. fraction = 1 - fraction; 69. } 70. //根据目前执行的状态,取出对应的需要处理的三角形 71. TriangleView triangleView = triangles[0]; 72. if (currentStatus == STATUS.MID_LOADING || currentStatus == STATUS.MID_DISMISS){ 73. triangleView = triangles[0]; 74. }else if (currentStatus == STATUS.FIRST_LOADING || currentStatus == STATUS.FIRST_DISMISS){ 75. triangleView = triangles[1]; 76. }else if (currentStatus == STATUS.SECOND_LOADING || currentStatus == STATUS.SECOND_DISMISS){ 77. triangleView = triangles[2]; 78. }else if (currentStatus == STATUS.THIRD_LOADING || currentStatus == STATUS.THIRD_DISMISS){ 79. triangleView = triangles[3]; 80. }else if (currentStatus == STATUS.LOADING_COMPLETE){ 81. //如果是LOADING_COMPLETE状态的话,此次动画效果保持不变 82. invalidate(); 83. return; 84. } 85. //这里是三角形变化的过程,计算目前current的坐标应当处在什么位置上 86. //当fration为0的时候,current的坐标为start位置,当fratcion为1的时候,current的坐标是end位置 87. triangleView.currentX1 = (int) (triangleView.startX + fraction * (triangleView.endX1 - triangleView.startX)); 88. triangleView.currentY1 = (int) (triangleView.startY + fraction * (triangleView.endY1 - triangleView.startY)); 89. triangleView.currentX2 = (int) (triangleView.startX + fraction * (triangleView.endX2 - triangleView.startX)); 90. triangleView.currentY2 = (int) (triangleView.startY + fraction * (triangleView.endY2 - triangleView.startY)); 91. invalidate(); 92. } 93. }); 94. 95. valueAnimator.start(); 96. }
上述代码中需要对动画的循环onAnimationRepeat进行监听,每次循环会更改一次动画状态,例如每次循环会绘制一个三角形或者消失一个三角形,然后对onAnimationUpdate进行监听,这里需要注意的是,如果目前的动画效果是要显示三角形,则提取到的fraction应当为从0到1的渐变过程,如果动画效果是消失一个三角形,则fraction应当是从1到0的渐变过程,所以代码中有一句1-fraction的操作。current的值初始是在start位置上,随着fraction的演进,current的值需要在start的基础上增加对应的演进过程,演进的变化量就是fraction乘以start与end节点之间的距离。
细心的同学或许已经发现在上述代码的第56行调用了reverseTriangleStart方法,这个方法有什么用呢,如果大家再回头本文的开头部分观察动画的演示效果的话,细心一点会发现,三角形出现的时候起始点的位置和三角形消失的时候起始点的位置是不同的,即原来的起始点在三角形消失的时候是作为end点进行的,而原来的其中一个end点变成了start点,这个动画的巧妙之处就在于看似有规律的三角形绘制顺序,其实并不是按照既定的规则来的,而且三角形消失的顺序与三角形出现的顺序是不一致的。所以我们的枚举变量中在THIRD_DISMISS之后是FIRST_DISMISS,并不是SECONDE_DISMISS。好了下面继续粘贴代码:
1.private void reverseTriangleStart(){ 2. for (int i = 0; i < triangles.length; i++){ 3. int startX = triangles[i].startX; 4. int startY = triangles[i].startY; 5. triangles[i].startX = triangles[i].endX1; 6. triangles[i].startY = triangles[i].endY1; 7. triangles[i].endX1 = startX; 8. triangles[i].endY1 = startY; 9. triangles[i].currentX1 = triangles[i].endX1; 10. triangles[i].currentY1 = triangles[i].endY1; 11. } 12. }
代码很简单,仅仅是交换每个三角形start和end1的值,让之前的end1变成了新的start顶点,这里需要注意的是,current的值应当与end1的值保持一致。否则在onDraw中,当处于LOADING_COMPLETE状态下,由于start与end1的值进行了交换,current的值还是按照之前的来会出现问题,大家可以手动调试下就知道了。
4、结语
本工程主要传递该动画的制作思路,还有些地方并不完善,例如没有把属性自定义抽取出来,onMeasure没有针对自适应进行重构等,第一篇博文欢迎各位拍砖。