Jics | 作者
承香墨影 | 校对
http://www.jianshu.com/p/3dd3d1524851 | 原文
一、前言
Hi,大家好,这里是承香墨影!
此篇中的小鱼动画是模仿国外一个大牛做的 Flash 动画,第一眼就爱上它了,简约灵动又不失美学,于是抽空试着尝试了一下,如下是我用 Android 实现的效果图:
由于整个绘制分析过程比较繁琐,所以灵动的红鲤鱼准备做成上下两篇,本篇是小鱼儿绘制的实现篇,第二篇是小鱼儿游动控制篇。
本篇实现如下效果:
绘制实现篇用到如下主要的技术:
自定义 Drawable 动画;
Android 的坐标及角度;
Canvas 中 Layer 的使用;
正余弦函数的使用以及角度角和弧度角的转换;
下图是我实现小鱼儿的分解图纸:

二、动画拆解
拿到动画需求或者模仿一个动画,首先需要分析动画主体,如何绘制部件如何活动,就此动画外观分析如下:
小鱼的身体各个部件都是简单的半透明几何图形;
各个部件都可以活动;
从头到尾方向的部件摆动幅度越来越大、频率越来越高;
三、技术分析
小鱼摆动是周期运动,三角函数正好有此特性,角度问题也需要和坐标挂钩,所以我们先来明确一下两个最重要也是最基本的问题:坐标和角度。
与平面直角坐标系不同的是 Android 的坐标系中 Y 轴正方向是朝下的 ,但是角度却和平面直角坐标系的计算方法一样,即 原点指向 X 轴正方向为 0°,正角度是逆时针旋转,负角度是顺时针旋转 。
那么问题就来了:坐标系不同,角度转动方式却一样,为了让 Java 中的 Math 函数计算出来的角度跟 Android 的坐标习惯一致,我们需要将与 Y 轴相关的角度都减去 180°,这样解决了既用 Android 的坐标又用自然角度的问题,即下图所示的角度和坐标系关系。

统一完角度问题,接下来我们就看看鱼的各部件,是怎么关联在一起的?
需要先了解三个重要参数。
3.1 鱼的重心
因为最终我们要实现鱼儿,根据手指点击的位置,而移动的效果,必须确保能让点击点,成为唯一确定鱼儿位置的点,所以我们必须找到一个让鱼儿的各个部件,都相对此点绘制的点。
参考点可以任意选,但是考虑到转弯的时候,或者身体摆动的时候不会往某一边偏,于是将参考点选在鱼的中轴线上,本来选在中轴线和鱼儿头顶橡胶的点但是最后转弯的时候就跟秋名山老司机漂移一样,那叫一个飘逸,最后将参考点选在了鱼的腹部重心处。
3.2 鱼的半径

此案例中鱼的各个部件都是以 鱼头半径 R 为单位 衡量的,比如鱼的身子第一节长度是 3.2R,依次确定好身体的各个部件,相对于鱼头半径的尺寸就能确定整条鱼的总长度为 6.79R,继而确定控件的总尺寸。
如下图,经过计算控件最小尺寸为 8.36R,这样就保证鱼儿转动任意角度都在控件之内。
3、鱼身角度
此处的鱼身角度,是指重心到鱼头圆心的连线和 X 轴正方向的夹角角度,即鱼儿前进方向的角度。此方向是确定各个部件方向及位置的的基础方向,部件的定位、鱼身角度,以及尾部的摆动角度,都是在此角度基础上,通过加减角度来控制左右摇摆。
下边我将演示一下,如何通过这三个因素来确定头部以及鱼鳍的点坐标(其他部位原理相同)。
先假设鱼身角度为 0°,即头朝向 X 轴正方向。通过重心点以及第一节身长的一半的长度,以及角度即可计算出头部的圆心坐标,然后再以头部圆心坐标和 0.9R 的长度,顺时针旋转 80° 确定右边鱼鳍的坐标点。

鱼鳍绘制原理相似,通过上文的右鳍坐标,可以计算出右鳍的另一端坐标,鱼鳍弧度是通过二阶贝塞尔曲线绘制的。
鱼尾张合分析。鱼尾是内外两个三角形叠加而成的,三角形顶点和三角形底边中点连线的角度,和最后一节身体的角度一直,三角形底边左右两点通过底边的中点,以及动态计算出来的长度确定的。
后用放出骨架系统:黑线为各个部件的主轴,圆圈为各个部件边界的定位点或贝塞尔曲线的控制点,是不是很酷,像不像电影里的动作捕捉。
四、代码实现
文章只贴出主要代码,完整代码文末提供链接。
1、自定义 Drawable
自定义 View 可能大家都知道,但是自定义 Drawable 却并不是很常见。
我们知道 Drawable 在 Android 里常常和 ImageView 配合使用,或者作为某个 View 的 Background ,它不能通过标签的方式在 XML 里定义,所以严格意义上来说它不是一个可以独立展示的控件,需要依附在其他控件中。
在 attrs.xml 里自定义属性也和它无缘, measure 测量也可以省略,这么一看 Drawabe 好像就只是专注绘制,没错,这就是它比 View 和 ViewGroup 绘图的优势 —— 轻量。
既然说到不用 Measure ,那么它的大小怎么确定呢?
当 ImageView 使用我们自定义 Drawable 的时候,如果设置的是 wrap_content,那么 content 的内容宽高从哪里来?
Drawable 提供了两个函数 getIntrinsicHeight()
、getIntrinsicWidth()
,从名字上看是获得固有宽高,所以我们就可以在这里控制我们的 Drawable 本来的宽高。
如果 ImageView 的宽高是具体值的话,具体值超过 Drawable 的固有宽高,那么 Drawable 就会被拉伸(具体拉伸方案是依据 ImageView 的 scaleType 类型),如果不想让自己的内容因拉伸而导致不清晰的话可以在 draw()
函数里通过 canvas.getHeight()
和 canvas.getWidth()
来获取 ImageView 的大小。也可以通过 getBounds()
方法获取到一个 Rect 边界来获取尺寸。
本例中的固有宽高就是可以容纳小鱼 360° 旋转的尺寸 8.38R。
@Override
public int getIntrinsicHeight() {
return (int) (8.38f * HEAD_RADIUS);
}
@Override
public int getIntrinsicWidth() {
return (int) (8.38f * HEAD_RADIUS);
}
其次自定义 Drawable 只需复写必要的四个函数,比较简单具体作用见注释。
@Override
public void draw(Canvas canvas) {
//和自定义View中的onDraw()异曲同工
}
@Override
public void setAlpha(int alpha) {
//设置Drawable的透明度,一般情况下将此alpha值设置给Paint
}
@Override
public void setColorFilter(ColorFilter colorFilter) {
//设置颜色滤镜,一般情况下将此值设置给Paint
}
@Override
public int getOpacity() {
//决定绘制的部分是否遮住Drawable下边的东西,有点抽象,有几种模式
//PixelFormat.UNKNOWN
//PixelFormat.TRANSLUCENT 只有绘制的地方才盖住下边
//PixelFormat.TRANSPARENT 透明,不显示绘制内容
//PixelFormat.OPAQUE 完全盖住下边内容
return PixelFormat.TRANSLUCENT;
}
主要是复写 draw()
方法,利用 Canvas 绘制各种想要的东西。
2、坐标部分
最最最主要的坐标计算代码,小鱼儿所有部件都是通过此方法计算出坐标的,功能是计算一个点的坐标,可以理解为一个长度为 length 的线绕起点 startPoint 旋转 angle 角度后线段另一端的坐标。
/**
* 输入起点、长度、旋转角度计算终点
* @param startPoint 起点
* @param length 长度
* @param angle 旋转角度
* @return 计算结果点
*/
private static PointF calculatPoint(PointF startPoint, float length, float angle) {
float deltaX = (float) Math.cos(Math.toRadians(angle)) * length;
//符合Android坐标的y轴朝下的标准
float deltaY = (float) Math.sin(Math.toRadians(angle-180)) * length;
return new PointF(startPoint.x + deltaX, startPoint.y + deltaY);
}
这里要特别说明一下 Math.sin()
、Math.cos()
、Math.toRadians()
这三个函数,其中 sin\cos
的参数是弧度制角度。
说到弧度制可能大家都忘得差不多了,带大家回顾一下中学数学。角的度量可以用弧度制也可以用角度制表示,其中弧度和角度转换的桥梁就是圆周率 π。
1角度=(π/180)弧度
比如说想计算 30° 的正弦值,用 Java 代码需要先将角度制的 30° 转为弧度值即通过 Math.toRadians(30)
得到 30° 对应的弧度,完整代码如下:
double sin30 = Math.sin( Math.toRadians(30) );
打印结果是:
0.49999999999999994
如果非要得到 0.5 的话就强转成 float 型就行了,可能是由于 double 的精度问题。
3、第一节身体
第一节身体包括头部和身体的第一段,代码如下(虚线部分是身体其他部分的生成方法,暂时不管)。

private void makeBody(Canvas canvas, float headRadius) {
float angle = mainAngle + (float) Math.sin(Math.toRadians(currentValue * 1.2 * waveFrequence)) * 2;
headPoint = calculatPoint(middlePoint, BODY_LENGHT / 2,mainAngle);
//画头
canvas.drawCircle(headPoint.x, headPoint.y, HEAD_RADIUS, mPaint);
........
.......
PointF point1, point2, point3, point4, contralLeft, contralRight;
//point1和4的初始角度决定发髻线的高低值越大越低
point1 = calculatPoint(headPoint, headRadius, angle-80);
point2 = calculatPoint(endPoint, headRadius * 0.7f, angle-90);
point3 = calculatPoint(endPoint, headRadius * 0.7f, angle +90);
point4 = calculatPoint(headPoint, headRadius, angle +80);
//决定胖瘦
contralLeft = calculatPoint(headPoint, BODY_LENGHT * 0.56f, angle -130);
contralRight = calculatPoint(headPoint, BODY_LENGHT * 0.56f, angle +130);
mPath.reset();
mPath.moveTo(point1.x, point1.y);
mPath.quadTo(contralLeft.x, contralLeft.y, point2.x, point2.y);
mPath.lineTo(point3.x, point3.y);
mPath.quadTo(contralRight.x, contralRight.y, point4.x, point4.y);
mPath.lineTo(point1.x, point1.y);
mPaint.setColor(Color.argb(BODY_ALPHA, 244, 92, 71));
//画身子
canvas.drawPath(mPath, mPaint);
}
其中最难理解的是角度的计算这句话:
//中心轴线和X轴顺时针方向夹角
float angle = mainAngle + (float) Math.sin(Math.toRadians(currentValue * 1.2 * waveFrequence)) * 2;
这里 Math.sin(Math.toRadians(currentValue * 1.2 * waveFrequence))
是控制第一节身体摆动的核心方法,变量 currentValue
是 ValueAnimator 动画的过程数值, 1.2 是用来控制身体摆动的固有频率, waveFrequence 是全局频率,用于控制鱼儿运动时的摆动频率,因为 sin 函数 是周期函数,且值域为 [-1,1]
,计算结果乘 2 之后这句话就可以生成一个 [-2,2]
的变化范围,用这个值加上 mainAngle(身体前进方向和 X 轴正方向夹角)就可以让鱼的第一节身体在身体主轴左右摇摆 2° 了。上边的代码生成了头的圆心坐标,第一节身体的四个顶角以及身体两侧的贝塞尔曲线控制点,通过这几个点,就可以画出鱼的头和第一节身体了,并且可以根据动画控制器的数值左右摆动身体。
第二节第三节身体思想和第一节身体一致,不过腰线没有用贝塞尔曲线,而是直接用直线代替,所以二三节身体是梯形,需要注意的是在计算第二三节身体角度的时候摆动核心方法要正余弦相互交替,否则就顺拐了。
4、鱼鳍
鱼鳍的画法也不难,麻烦的地方在于,要判断鱼鳍是左边的还是右边的,因为鱼鳍的弧线是贝塞尔曲线生成的,而曲线的控制点要分左右。其中 fatherAngle 是鱼身主轴方向和 X 轴 的的夹角,finsAngle 是鱼鳍向内摆动时的偏移角度。
private void makeFins(Canvas canvas, PointF startPoint, int type, float fatherAngle) {
//鱼鳍控制点相对于鱼主轴方向的角度
float contralAngle = 115;
mPath.reset();
mPath.moveTo(startPoint.x, startPoint.y);
//鱼鳍的另一端
PointF endPoint = calculatPoint(startPoint, FINS_LENGTH, type == FINS_RIGHT ? fatherAngle - finsAngle-180 : fatherAngle + finsAngle+180);
//曲线的控制点
PointF contralPoint = calculatPoint(startPoint, FINS_LENGTH * 1.8f, type == FINS_RIGHT ?
fatherAngle - contralAngle - finsAngle : fatherAngle + contralAngle + finsAngle);
mPath.quadTo(contralPoint.x, contralPoint.y, endPoint.x, endPoint.y);
mPath.lineTo(startPoint.x, startPoint.y);
mPaint.setColor(Color.argb(FINS_ALPHA, 244, 92, 71));
canvas.drawPath(mPath, mPaint);
mPaint.setColor(Color.argb(OTHER_ALPHA, 244, 92, 71));
}

5、鱼尾
鱼尾是大小两个等腰三角形叠加而成的,三角形的顶点重合。绘制原理是根据三角形底边中点来确定底边的两个点,其中角度和鱼尾主方向垂直。其中newWith
变量的是根据当前动画的过程值动态生成的。
private void makeTail(Canvas canvas, PointF mainPoint, float length, float maxWidth, float angle) {
float newWidth = (float) Math.abs(Math.sin(Math.toRadians(currentValue * 1.7 * waveFrequence)) * maxWidth + HEAD_RADIUS/5*3);
//endPoint为三角形底边中点
PointF endPoint = calculatPoint(mainPoint, length, angle-180);
PointF endPoint2 = calculatPoint(mainPoint, length - 10, angle-180);
PointF point1, point2, point3, point4;
point1 = calculatPoint(endPoint, newWidth, angle-90);
point2 = calculatPoint(endPoint, newWidth, angle +90);
point3 = calculatPoint(endPoint2, newWidth - 20, angle-90);
point4 = calculatPoint(endPoint2, newWidth - 20, angle +90);
//内
mPath.reset();
mPath.moveTo(mainPoint.x, mainPoint.y);
mPath.lineTo(point3.x, point3.y);
mPath.lineTo(point4.x, point4.y);
mPath.lineTo(mainPoint.x, mainPoint.y);
canvas.drawPath(mPath, mPaint);
//外
mPath.reset();
mPath.moveTo(mainPoint.x, mainPoint.y);
mPath.lineTo(point1.x, point1.y);
mPath.lineTo(point2.x, point2.y);
mPath.lineTo(mainPoint.x, mainPoint.y);
canvas.drawPath(mPath, mPaint);
}
5、动画引擎
接下来就是激动人心的引擎“发动”时间了,此篇的引擎是一个 ValueAnimator。
动画周期 180 秒,数值变化从 0 到 54000,无限循环往复运行,将过程值赋值给 currentValue 然后刷新 Drawable。
//引擎部分
ValueAnimator valueAnimator = ValueAnimator.ofInt(0, 54000);
valueAnimator.setDuration(180 * 1000);
valueAnimator.setInterpolator(new LinearInterpolator());
valueAnimator.setRepeatCount(ValueAnimator.INFINITE);
valueAnimator.setRepeatMode(ValueAnimator.REVERSE);
valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
currentValue = (int) (animation.getAnimatedValue());
invalidateSelf();
}
});
运行结果:
五、结语
动画的分析和实现是一个枯燥又费脑筋的过程,时不时还要复习一下还给老师的数学知识,不过当引擎发动的时候看到绘制的东西动起来了你会觉得所有的努力都是值得的。下一篇将分析如何让鱼儿游动起来,希望大家继续关注。
绘制部分源码:https://github.com/Jichensheng/Fish
-- End --
本文对你有帮助吗?留言、转发、点好看是最大的支持,谢谢!
推荐阅读:
