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

此篇中的小鱼动画是模仿国外一个大牛做的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实现灵动的红鲤鱼动画(上篇)
下篇链接: 自定义Drawable实现灵动的红鲤鱼动画(下篇)

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容