自定义Drawable实现灵动的红鲤鱼动画(上篇)

1,375
原文链接: www.jianshu.com

  此篇中的小鱼动画是模仿国外一个大牛做的flash动画,第一眼就爱上它了,简约灵动又不失美学,于是抽空试着尝试了一下,如下是我用Android实现的效果图:


小鱼儿

由于整个绘制分析过程比较繁琐所以灵动的红鲤鱼准备做成上下两篇,本篇是小鱼儿绘制的实现篇,第二篇是小鱼儿游动控制篇。本篇实现如下效果:


原地摆尾版

绘制实现篇用到如下主要的技术:  

1)、自定义Drawable动画
2)、Android的坐标及角度
3)、Canvas中layer的使用
4)、正余弦函数的使用以及角度角和弧度角的转换

下图是我实现小鱼儿的分解图纸:


部件分解图

一、动画拆解

拿到动画需求或者模仿一个动画首先需要分析动画主体如何绘制部件如何活动,就此动画外观分析如下:
1)、小鱼的身体各个部件都是简单的半透明几何图形
2)、各个部件都可以活动
3)、从头到尾方向的部件摆动幅度越来越大、频率越来越高

二、技术分析

  小鱼摆动是周期运动,三角函数正好有此特性,角度问题也需要和坐标挂钩,所以我们先来明确一下两个最重要也是最基本的问题:坐标和角度。与平面直角坐标系不同的是Android的坐标系中Y轴正方向是朝下的,但是角度却和平面直角坐标系的计算方法一样,即原点指向X轴正方向为0°,正角度是逆时针旋转,负角度是顺时针旋转那么问题就来了:坐标系不同,角度转动方式却一样,为了让java中的Math函数计算出来的角度跟Android的坐标习惯一致我们需要将与Y轴相关的角度都减去180°,这样解决了既用Android的坐标又用自然角度的问题,即下图所示的角度和坐标系关系
  


Android坐标系下的自然角度


  
  统一完角度问题,接下来我们就看看鱼的各部件是怎么关联在一起的。需要先了解三个重要参数

1)、鱼的重心  

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

2)、鱼头半径


比例示意图


此案例中鱼的各个部件都是以鱼头半径R为单位衡量的,比如鱼的身子第一节长度是3.2R,依次确定好身体的各个部件相对于鱼头半径的尺寸就能确定整条鱼的总长度为6.79R,继而确定控件的总尺寸。如下图,经过计算控件最小尺寸为8.36R,这样就保证鱼儿转动任意角度都在控件之内


打转图

3)、鱼身角度

此处的鱼身角度是指重心到鱼头圆心的连线和X轴正方向的夹角角度,即鱼儿前进方向的角度。此方向是确定各个部件方向及位置的的基础方向,部件的定位、鱼身角度以及尾部的摆动角度都是在此角度基础上通过加减角度来控制左右摇摆。
 下边我将演示一下如何通过这三个因素来确定头部以及鱼鳍的点坐标(其他部位原理相同)
 先假设鱼身角度为0°,即头朝向X轴正方向。通过重心点以及第一节身长的一半的长度,以及角度即可计算出头部的圆心坐标,然后再以头部圆心坐标和0.9R的长度,顺时针旋转80°确定右边鱼鳍的坐标点
 


鱼鳍定位过程

 鱼鳍绘制原理相似,通过上文的右鳍坐标可以计算出右鳍的另一端坐标,鱼鳍弧度是通过二阶贝塞尔曲线绘制的   

  鱼尾张合分析。鱼尾是内外两个三角形叠加而成的,三角形顶点和三角形底边中点连线的角度和最后一节身体的角度一直,三角形底边左右两点通过底边的中点以及动态计算出来的长度确定的
    
  最后用放出骨架系统:黑线为各个部件的主轴,圆圈为各个部件边界的定位点或贝塞尔曲线的控制点,是不是很酷,像不像电影里的动作捕捉
  


骨架系统

三、代码实现

文章只贴出主要代码,完整代码文末提供链接

0)自定义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绘制各种想要的东西。

1)坐标部分

  最最最主要的坐标计算代码,小鱼儿所有部件都是通过此方法计算出坐标的 ,功能是计算一个点的坐标,可以理解为一个长度为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的精度问题。

2)、第一节身体

第一节身体包括头部和身体的第一段,代码如下(虚线部分是身体其他部分的生成方法,暂时不管)


头身
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);
}

其中最难理解的是角度的计算这句话:

    float angle = mainAngle + (float) Math.sin(Math.toRadians(currentValue * 1.2 * waveFrequence)) * 2;//中心轴线和X轴顺时针方向夹角

这里Math.sin(Math.toRadians(currentValue * 1.2 * waveFrequence))是控制第一节身体摆动的核心方法,变量currentValue是ValueAnimator动画的过程数值,1.2是用来控制身体摆动的固有频率,waveFrequence是全局频率,用于控制鱼儿运动时的摆动频率,因为sin函数是周期函数,且值域为[-1,1],计算结果乘2之后这句话就可以生成一个[-2,2]的变化范围,用这个值加上mainAngle(身体前进方向和X轴正方向夹角)就可以让鱼的第一节身体在身体主轴左右摇摆2°了。上边的代码生成了头的圆心坐标,第一节身体的四个顶角以及身体两侧的贝塞尔曲线控制点,通过这几个点,就可以画出鱼的头和第一节身体了,并且可以根据动画控制器的数值左右摆动身体

  
第二节第三节身体思想和第一节身体一致,不过腰线没有用贝塞尔曲线,而是直接用直线代替,所以二三节身体是梯形,需要注意的是在计算第二三节身体角度的时候摆动核心方法要正余弦相互交替,否则就顺拐了

3)、鱼鳍

鱼鳍的画法也不难,麻烦的地方在于要判断鱼鳍是左边的还是右边的,因为鱼鳍的弧线是贝塞尔曲线生成的,而曲线的控制点要分左右。其中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));

    }

鱼鳍定位过程

4)、鱼尾

鱼尾是大小两个等腰三角形叠加而成的,三角形的顶点重合。绘制原理是根据三角形底边中点来确定底边的两个点,其中角度和鱼尾主方向垂直。其中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)、动画引擎

接下来就是激动人心的引擎“发动”时间了,看过上篇文章Android仿百度贴吧客户端Loading小球的朋友就知道引擎部分是一个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();
    }
});

运行结果:


谢谢我的女朋友支持

四、结语

动画的分析和实现是一个枯燥又费脑筋的过程,时不时还要复习一下还给老师的数学知识,不过当引擎发动的时候看到绘制的东西动起来了你会觉得所有的努力都是值得的。下一篇将分析如何让鱼儿游动起来,希望大家继续关注。
绘制部分源码:灵动的红鲤鱼Github源码
CSDN同步分析文章链接: 自定义Drawable实现灵动的红鲤鱼动画(上篇)