Android实现灵动的锦鲤动画

2025-10-31 10:27:05

看文章时无意间发现了一个很有趣的动画效果,于是自己动手实现了一下。点击屏幕上的任意一点,鱼会朝向该点游动,效果图如下:

效果图

参考的文章链接如下:

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

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

我的实现步骤为:

画出静态的鱼。

鱼自身从头到尾摆动。

手指点击屏幕时的水波纹效果。

鱼朝着被点击位置游动。

一、绘制静态鱼

1.理论解析

首先来看鱼的分解图:

鱼鳍和身体两侧是利用了二阶贝塞尔曲线绘制的,其余部位都是由简单的图形(圆、三角形、梯形)构成。

把鱼画在一个自定义的 Drawable 中,重点在于如何求关键点的坐标,比如画头部时,需要先求出头部圆心的坐标,画鱼鳍时,需要求出鱼鳍所在的二阶贝塞尔曲线的起点、终点和控制点。求点需要借助三角函数,通常我们会知道一个参照点A的坐标,并且知道待求点B与A的直线距离,以及AB与x轴正方向的夹角角度:

例如在上图中,一个锐角的 sin 值是一个正数,进而 deltaY 也是正数,在数学坐标系下 yb = ya + deltaY 是正确的,但是到了屏幕坐标系,则应该是 yb = ya - deltaY。

此外还要先考虑好鱼的重心以及鱼头方向如何描述的问题。

鱼身比例数据

上图是我们画鱼时采用的数据,红点标记位置是鱼的重心,鱼在自转时会以重心为原点,4.19R为半径(R是鱼头圆的半径,重心到鱼尾的距离为4.19R)画出一个圆形。因此我们在用 Drawable 画鱼时,Drawable 的宽高至少要为4.19R*2。

至于鱼头方向,还是使用与x轴正方向夹角来描述:

Drawable计算示意图

上面说过 Drawable 的宽高至少为 8.38R,我们假设 Drawable 宽高就是 8.38R,那么重心坐标刚好就是宽高的一半(4.19R,4.19R),并且不论鱼怎样自转,重心坐标不变(相对于 Drawable 内部来说)。

在知晓重心坐标后,就可以通过它来计算鱼头圆形的圆心坐标了,因为重心与圆心距离此前测量时已经给出了,以其为斜边的直角三角形也容易画出,并且其中一个叫就是鱼头与x轴的夹角,通过三角函数就容易求出鱼头圆心的坐标了。当然,其它关键点也是用类似的方式求出的。

2.关键代码

重写 Drawable 必须要实现的四个方法:

public class FishDrawable extends Drawable {

@Override

public void setAlpha(int alpha) {

mPaint.setAlpha(alpha);

}

@Override

public void setColorFilter(@Nullable ColorFilter colorFilter) {

mPaint.setColorFilter(colorFilter);

}

@Override

public int getOpacity() {

return PixelFormat.TRANSLUCENT;

}

@Override

public void draw(@NonNull Canvas canvas) {

// 绘制鱼的过程...

}

}

draw() 内部就是绘制鱼的整个过程,稍后再详细介绍,另外要指定 Drawable 本身的宽高,就是我们前面说到的 8.38R:

// 鱼头半径

private static final int HEAD_RADIUS = 100;

// 默认的 Drawable 大小是鱼头半径 x 倍

private static final float SIZE_MULTIPLE_NUMBER = 8.38f;

@Override

public int getIntrinsicHeight() {

return (int) (SIZE_MULTIPLE_NUMBER * HEAD_RADIUS);

}

@Override

public int getIntrinsicWidth() {

return (int) (SIZE_MULTIPLE_NUMBER * HEAD_RADIUS);

}

初始化画笔等元素:

// 默认的 Drawable 大小是鱼头半径 x 倍

private static final float SIZE_MULTIPLE_NUMBER = 8.38f;

// 身体透明值比其它部分大一些

private static final int BODY_ALPHA = 160;

private static final int OTHER_ALPHA = 110;

// 鱼的重心点

private PointF middlePoint;

public FishDrawable() {

mPath = new Path();

mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);

mPaint.setStyle(Paint.Style.FILL);

mPaint.setARGB(OTHER_ALPHA, 244, 92, 71);

// 鱼的重心点位于整个 Drawable 的中心

middlePoint = new PointF(SIZE_MULTIPLE_NUMBER / 2 * HEAD_RADIUS, SIZE_MULTIPLE_NUMBER / 2 * HEAD_RADIUS);

}

首先要把求点的工具方法搞定,后面或多次用到这个方法:

/**

* 利用三角函数,通过两点形成的线长以及该线与x轴形成的夹角求出待求点坐标

*

* @param startPoint 起始点

* @param length 待求点与起始点的直线距离

* @param angle 两点连线与x轴夹角

*/

private PointF calculatePoint(PointF startPoint, float length, float angle) {

float deltaX = (float) (Math.cos(Math.toRadians(angle)) * length);

// 计算Y轴坐标时,把角度减去180再参与计算,相当于是把数学坐标系中的角度

// 转换为屏幕坐标系中的角度了。

float deltaY = (float) (Math.sin(Math.toRadians(angle - 180)) * length);

return new PointF(startPoint.x + deltaX, startPoint.y + deltaY);

}

然后在 draw() 中画这条鱼:

// 当前先指定鱼的朝向与x轴正方向的夹角为90°

private float fishMainAngle = 90;

@Override

public void draw(@NonNull Canvas canvas) {

float fishAngle = fishMainAngle;

// 1.先画鱼头,就是一个圆,圆心与重心距离为鱼身长一半,1.6R

PointF headPoint = calculatePoint(middlePoint, BODY_LENGTH / 2, fishAngle);

canvas.drawCircle(headPoint.x, headPoint.y, HEAD_RADIUS, mPaint);

// 2.画鱼鳍,身体两侧各一个。鱼鳍是一个二阶贝塞尔曲线,其起点与鱼头圆心的距离为0.9R,

// 两点连线与x轴正方向的角度为110°

PointF leftFinPoint = calculatePoint(headPoint, FIND_FINS_LENGTH, fishAngle + 110);

PointF rightFinPoint = calculatePoint(headPoint, FIND_FINS_LENGTH, fishAngle - 110);

makeFin(canvas, leftFinPoint, fishAngle, true);

makeFin(canvas, rightFinPoint, fishAngle, false);

// 3.画节肢,节肢1是两个圆相切,并且还有个以两个圆的直径为上下底的梯形,

// 节肢2是一个梯形加一个小圆。

PointF bigCircleCenterPoint = calculatePoint(headPoint, BODY_LENGTH, fishAngle - 180);

// 计算两个圆中较小圆心的工作要交给 makeSegment,因为节肢摆动的角度与鱼身摆动角度不同,

// 不能直接用 fishAngle 计算圆心,否则圆心点计算就不准了。

// PointF middleCircleCenterPoint1 = calculatePoint(bigCircleCenterPoint, BigMiddleCenterLength, fishAngle - 180);

PointF middleCircleCenterPoint = makeSegment(canvas, bigCircleCenterPoint, BIG_CIRCLE_RADIUS, MIDDLE_CIRCLE_RADIUS,

BigMiddleCenterLength, fishAngle, true);

makeSegment(canvas, middleCircleCenterPoint, MIDDLE_CIRCLE_RADIUS, SMALL_CIRCLE_RADIUS,

MiddleSmallCenterLength, fishAngle, false);

// 4.画尾巴,是两个三角形,一个顶点在中圆圆心,该顶点到大三角形底边中点距离为中圆半径的2.7倍

makeTriangle(canvas, middleCircleCenterPoint, FIND_TRIANGLE_LENGTH, BIG_CIRCLE_RADIUS, fishAngle);

makeTriangle(canvas, middleCircleCenterPoint, FIND_TRIANGLE_LENGTH - 10, BIG_CIRCLE_RADIUS - 20, fishAngle);

// 5.画身体,身体两侧的线条也是二阶贝塞尔曲线

makeBody(canvas, headPoint, bigCircleCenterPoint, fishAngle);

}

注释中给出了绘制的顺序,画鱼头的关键在于正确计算出鱼头圆心坐标。

绘制鱼鳍

鱼鳍其实是一个二阶贝塞尔曲线,先看下图:

右鳍绘制参数

画鱼鳍需要求出三个点,鱼鳍起点、鱼鳍终点、二阶贝塞尔曲线的控制点。以图中右鳍为例,假设鱼头与x轴夹角为 fishAngle(图中画的是 fishAngle = 0 的特殊情况),说一下三个点是怎么求的:

鱼头圆心到起始点的距离为0.9R,二者连线与鱼头方向夹角为110°,转换成与x轴的夹角就为 fishAngle - 110(左鱼鳍为 fishAngle + 110,顺时针旋转是减,逆时针加)在前面已经求出了鱼头圆心的情况下,直接带入 calculatePoint() 即可求出起始点。

起始点到结束点的长度就是鱼鳍的长度(已知),二者连线方向与鱼头方向刚好相反,那么与x轴夹角就为 fishAngle - 180,上一步刚求出起始点,同样带入 calculatePoint() 可以计算出结束点。

起始点到控制点长度已知,二者连线与鱼头方向夹角为110°,转换成与x轴夹角就是 fishAngle - 110(同样还是左鳍为+),与上面类似,控制点也可求。

代码如下:

// 鱼鳍长度

private static final float FINS_LENGTH = 1.3f * HEAD_RADIUS;

/**

* 鱼鳍其实用二阶贝塞尔曲线画出来的,鱼鳍长度是已知的,我们设置 FINS_LENGTH 为 1.3R,

* 另外控制点与起点的距离,以及这二点连线与x轴的夹角,也是根据效果图测量后按比例给出的。

*/

private void makeFin(Canvas canvas, PointF startPoint, float fishAngle, boolean isLeftFin) {

// 鱼鳍的二阶贝塞尔曲线,控制点与起点连线长度是鱼鳍长度的1.8倍,夹角为110°

float controlPointAngle = 110;

// 计算鱼鳍终点坐标,起始点与结束点方向刚好与鱼头方向相反,因此要-180

PointF endPoint = calculatePoint(startPoint, FINS_LENGTH, fishAngle - 180);

// 控制点,以鱼头方向为准,左侧鱼鳍增加 controlPointAngle,右侧则减。

PointF controlPoint = calculatePoint(startPoint, FINS_LENGTH * 1.8f,

isLeftFin ? fishAngle + controlPointAngle : fishAngle - controlPointAngle);

// 开始绘制

mPath.reset();

mPath.moveTo(startPoint.x, startPoint.y);

mPath.quadTo(controlPoint.x, controlPoint.y, endPoint.x, endPoint.y);

canvas.drawPath(mPath, mPaint);

}

绘制节肢

接下来画节肢,分为节肢1、2,其实都是两个圆中间夹着一个梯形,只不过节肢1的小圆就是节肢2的大圆,因此节肢2不用再画一次大圆了:

/**

* 绘制节肢部分的大圆和小圆,以及两个圆之间的梯形。返回小圆圆心,在绘制

* 节肢2时要作为节肢2的大圆圆心用。

*

* @param bigCircleCenterPoint 大圆圆心

* @param bigCircleRadius 大圆半径

* @param smallCircleRadius 小圆半径

* @param circleCenterLength 两个圆心之间的距离

* @param fishAngle 鱼头方向与x轴夹角

* @param hasBigCircle 是否绘制大圆,节肢1要画大圆和小圆,而节肢2只需要画一个小圆

*/

private PointF makeSegment(Canvas canvas, PointF bigCircleCenterPoint, float bigCircleRadius, float smallCircleRadius,

float circleCenterLength, float fishAngle, boolean hasBigCircle) {

// 先计算两个圆中较小圆的圆心

PointF smallCircleCenterPoint = calculatePoint(bigCircleCenterPoint, circleCenterLength, fishAngle - 180);

// 再计算梯形四个角的点,给点命名时,靠近鱼头方向的直径称为 upper,在鱼身左侧的称为 left

PointF upperLeftPoint = calculatePoint(bigCircleCenterPoint, bigCircleRadius, fishAngle + 90);

PointF upperRightPoint = calculatePoint(bigCircleCenterPoint, bigCircleRadius, fishAngle - 90);

PointF bottomLeftPoint = calculatePoint(smallCircleCenterPoint, smallCircleRadius, fishAngle + 90);

PointF bottomRightPoint = calculatePoint(smallCircleCenterPoint, smallCircleRadius, fishAngle - 90);

// 先画大圆(如果需要)和小圆

if (hasBigCircle) {

canvas.drawCircle(bigCircleCenterPoint.x, bigCircleCenterPoint.y, bigCircleRadius, mPaint);

}

canvas.drawCircle(smallCircleCenterPoint.x, smallCircleCenterPoint.y, smallCircleRadius, mPaint);

// 再画梯形

mPath.reset();

mPath.moveTo(upperLeftPoint.x, upperLeftPoint.y);

mPath.lineTo(upperRightPoint.x, upperRightPoint.y);

mPath.lineTo(bottomRightPoint.x, bottomRightPoint.y);

mPath.lineTo(bottomLeftPoint.x, bottomLeftPoint.y);

// 因为 mPaint 的类型是 FILL,所以划线时不闭合也会自动将收尾相连

// mPath.lineTo(upperLeftPoint.x,upperLeftPoint.y);

canvas.drawPath(mPath, mPaint);

return smallCircleCenterPoint;

}

画出两个圆并不难,简单说一下的就是梯形的四个点是怎么求的:

梯形坐标示意图

假设梯形的四个点分别为ABCD,其中AB也是大圆直径,CD是小圆直径,AB与CD都垂直于鱼头朝向。看图片右侧,当求A点坐标时,起点为O,OA长度为半径,OA与x轴夹角为 fishAngle + 90°,则A点坐标可求。类似的,OB与x轴夹角就是 fishAngle - 90°(其实把这个角度看成是鱼头方向OE分别逆时针、顺时针转90°得到OA、OB更直接一些,顺时针旋转减去旋转度数,逆时针则加)。

绘制鱼身和鱼尾

有了以上基础,三角形和鱼身的二阶贝塞尔曲线就容易画出了,直接附上代码:

private void makeBody(Canvas canvas, PointF headPoint, PointF bigCircleCenterPoint, float fishAngle) {

// 先求头部圆和大圆直径上的四个点

PointF upperLeftPoint = calculatePoint(headPoint, HEAD_RADIUS, fishAngle + 90);

PointF upperRightPoint = calculatePoint(headPoint, HEAD_RADIUS, fishAngle - 90);

PointF bottomLeftPoint = calculatePoint(bigCircleCenterPoint, BIG_CIRCLE_RADIUS, fishAngle + 90);

PointF bottomRightPoint = calculatePoint(bigCircleCenterPoint, BIG_CIRCLE_RADIUS, fishAngle - 90);

// 两侧的控制点,长度和角度是在画图调整后测量出来的

PointF controlLeft = calculatePoint(headPoint, BODY_LENGTH * 0.56f,

fishAngle + 130);

PointF controlRight = calculatePoint(headPoint, BODY_LENGTH * 0.56f,

fishAngle - 130);

// 绘制

mPath.reset();

mPath.moveTo(upperLeftPoint.x, upperLeftPoint.y);

mPath.quadTo(controlLeft.x, controlLeft.y, bottomLeftPoint.x, bottomLeftPoint.y);

mPath.lineTo(bottomRightPoint.x, bottomRightPoint.y);

mPath.quadTo(controlRight.x, controlRight.y, upperRightPoint.x, upperRightPoint.y);

mPaint.setAlpha(BODY_ALPHA);

canvas.drawPath(mPath, mPaint);

}

/**

* @param startPoint 与中圆圆心重合的那个顶点

* @param toEdgeMiddleLength startPoint 到对边中点的距离

* @param edgeLength startPoint 对边长度的一半

*/

private void makeTriangle(Canvas canvas, PointF startPoint, float toEdgeMiddleLength, float edgeLength, float fishAngle) {

// 对边中点

PointF edgeMiddlePoint = calculatePoint(startPoint, toEdgeMiddleLength, fishAngle - 180);

// 三角形另外两个顶点

PointF leftPoint = calculatePoint(edgeMiddlePoint, edgeLength, fishAngle + 90);

PointF rightPoint = calculatePoint(edgeMiddlePoint, edgeLength, fishAngle - 90);

// 开始绘制

mPath.reset();

mPath.moveTo(startPoint.x, startPoint.y);

mPath.lineTo(leftPoint.x, leftPoint.y);

mPath.lineTo(rightPoint.x, rightPoint.y);

canvas.drawPath(mPath, mPaint);

}

这样一个静态的鱼就绘制完成了。

二、鱼自身的摆动效果

鱼的摆动,尾部的摆动频率比头部快,并且摆动角度也要更大。通过属性动画实现这个摆动,要改变如下几点:

鱼头与x轴夹角角度。

节肢1与x轴角度。

节肢2和尾部与x轴角度,这两者的角度变化一致,且应该比节肢1角度变化更大一些。

首先来实现一个最简单的效果,就是鱼的整体摆动,假如想让鱼左右摆动10°,可以这样做:

// 属性动画值

private float currentAnimatorValue;

public FishDrawable() {

//...

// 属性动画值为[-1,1],动画持续1s,无限循环

ValueAnimator valueAnimator = ValueAnimator.ofFloat(-1f, 1f);

valueAnimator.setRepeatCount(ValueAnimator.INFINITE);

valueAnimator.setRepeatMode(ValueAnimator.RESTART);

valueAnimator.setInterpolator(new LinearInterpolator());

valueAnimator.setDuration(1000);

valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {

@Override

public void onAnimationUpdate(ValueAnimator animation) {

currentAnimatorValue = (float) animation.getAnimatedValue();

invalidateSelf();

}

});

valueAnimator.start();

}

@Override

public void draw(@NonNull Canvas canvas) {

// 原本的是让鱼头朝向一个固定的角度,现在让它在固定角度的[-10,10]范围内变化。

// float fishAngle = fishMainAngle;

float fishAngle = fishMainAngle + currentAnimatorValue * 10;

}

节肢和尾部的变化角度应该比鱼头更大,才能有甩尾的效果,并且频率更快。这里想使用一个动画控制所有位置的摆动,采用的方式是把属性动画的取值范围由[-1,1]变成[0,360]:

// ValueAnimator valueAnimator = ValueAnimator.ofFloat(-1f, 1f);

ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, 360f);

这样做其实是把动画内变化的值,由振幅变成了角度,在计算鱼头角度时,使用三角函数计算:

@Override

public void draw(@NonNull Canvas canvas) {

// float fishAngle = fishMainAngle + currentAnimatorValue * 10;

float fishAngle = (float) (fishMainAngle + Math.sin(Math.toRadians(currentAnimatorValue)) * 10);

}

sin 在[0,360]这个区间内刚好完成了一个周期的变化,并且振幅为[-1,1],从计算结果上看与原来的计算方式是一样的,区别在于,可以通过三角函数控制尾部摆动的周期。例如画节肢时:

private void makeSegment(Canvas canvas, PointF bigCircleCenterPoint, float bigCircleRadius, float smallCircleRadius,

float circleCenterLength, float fishAngle, boolean hasBigCircle) {

// float segmentAngle = fishAngle + currentAnimatorValue * 10;

float segmentAngle;

if (hasBigCircle) {

// 节肢1

segmentAngle = (float) (fishAngle + Math.cos(Math.toRadians(currentAnimatorValue * 1.5)) * 15);

} else {

// 节肢2

segmentAngle = (float) (fishAngle + Math.sin(Math.toRadians(currentAnimatorValue * 1.5)) * 25);

}

// 更新计算角度

PointF smallCircleCenterPoint = calculatePoint(bigCircleCenterPoint, circleCenterLength, segmentAngle - 180);

// 更新计算角度

PointF upperLeftPoint = calculatePoint(bigCircleCenterPoint, bigCircleRadius, segmentAngle + 90);

PointF upperRightPoint = calculatePoint(bigCircleCenterPoint, bigCircleRadius, segmentAngle - 90);

PointF bottomLeftPoint = calculatePoint(smallCircleCenterPoint, smallCircleRadius, segmentAngle + 90);

PointF bottomRightPoint = calculatePoint(smallCircleCenterPoint, smallCircleRadius, segmentAngle - 90);

//...

}

计算 segmentAngle 时把 currentAnimatorValue 乘以1.5,表示让该三角函数的频率变为原来的1.5倍,也就是使得尾部摆动速度变为正常速度的1.5倍。在整个 Math.cos() 的结果乘以 15,表示把三角函数[-1,1]的振幅扩大了15倍,也就完成了节肢1在鱼头方向上可以左右摆动15°的效果。至于为什么节肢1用 cos 而节肢2用 sin,这与两个函数的波形图有关。

我们要清楚鱼的摆动是由头部开始向下传递,先到节肢1再到节肢2,即节肢1优先于节肢2摆动一段时间,而 sin 和 cos 的波形图也是类似的:

可以看到余弦曲线要比正弦曲线“快” π/2 个周期,即 sin(x+π/2) = cosx,所以我们给摆动较快的节肢1使用 cos,给具有延后性的节肢2使用 sin。另外,尾部的三角形与节肢2的摆动频率、角度和振幅都是一样的,所以角度计算公式一样:

private void makeTriangle(Canvas canvas, PointF startPoint, float toEdgeMiddleLength, float edgeLength, float fishAngle) {

// float triangleAngle = fishAngle + currentAnimatorValue * 10;

float triangleAngle = (float) (fishAngle + Math.sin(Math.toRadians(currentAnimatorValue * 1.5)) * 25);

// 对边中点

PointF edgeMiddlePoint = calculatePoint(startPoint, toEdgeMiddleLength, triangleAngle - 180);

// 三角形另外两个顶点

PointF leftPoint = calculatePoint(edgeMiddlePoint, edgeLength, triangleAngle + 90);

PointF rightPoint = calculatePoint(edgeMiddlePoint, edgeLength, triangleAngle - 90);

// 绘制...

}

最后还有一个问题,看图:

放慢动画速度后能明显看出节肢1处摆动过程中有一个很不自然的“抽动”。这是因为我们设置了该部分摆动频率为鱼头的1.5倍,而属性动画的变化范围是[0,360],并且重复模式为 RESTART,这就导致头部摆动完成时,节肢1正处于第二个摆动周期的中间,没有回到动画开始的初始位置。随后下一次动画开始执行,节肢1“跳到”初始位置开始执行动画,这个位置的变化造成了尾部的“抽动”。所以属性动画的取值范围,需要让所有摆动位置的动画都执行完一次完整的周期,鱼头是360,尾部一个周期需要360/1.5=240,取最小公倍数720设置给动画即可。调整后的效果:

三、波纹效果

波纹效果其实也是个属性动画,这个需要在包含 FishDrawable 的自定义 ViewGroup 里实现。初始化设置:

private void init(Context context) {

// ViewGroup 默认不会调用 onDraw(),需要手动设置一下

setWillNotDraw(false);

// 波纹画笔设置

mPaint = new Paint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);

mPaint.setStyle(Paint.Style.STROKE);

mPaint.setStrokeWidth(8);

// 把 FishDrawable 添加到当前 ViewGroup 中

ivFish = new ImageView(context);

LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);

ivFish.setLayoutParams(params);

fishDrawable = new FishDrawable();

ivFish.setImageDrawable(fishDrawable);

addView(ivFish);

}

记录点击事件发生的坐标,作为波纹圆心:

@Override

public boolean onTouchEvent(MotionEvent event) {

touchX = event.getX();

touchY = event.getY();

makeRippleAnimation();

return super.onTouchEvent(event);

}

private void makeRippleAnimation() {

// 波纹画笔初始透明度,随着动画变浅

mPaint.setAlpha(100);

// 可以没有 ripple 这个属性,但是一定要有 getRipple() 和 setRipple() 方法,反射时要用到

ObjectAnimator rippleAnimator = ObjectAnimator.ofFloat(this, "ripple", 0, 1f)

.setDuration(1000);

rippleAnimator.start();

}

@Override

protected void onDraw(Canvas canvas) {

mPaint.setAlpha(alpha);

canvas.drawCircle(touchX, touchY, ripple * 100, mPaint);

}

rippleAnimator 中的 ripple 属性,必须要提供它的 getter 和 setter 方法,在 setter 方法设置 ripple 时顺便把画笔的透明度也设置了:

public float getRipple() {

return ripple;

}

public void setRipple(float ripple) {

this.ripple = ripple;

alpha = (int) (100 * (1 - ripple));

// 在 ripple 变化时刷新

invalidate();

}

效果如下:

四、鱼向指定位置游动

最后来完成鱼的游动效果。鱼的游动路线可以用一个三阶贝塞尔曲线表示:

image

在上图中我们可以看到,三阶贝塞尔曲线中,起始点的鱼身重心O、控制点1鱼头圆心A我们前面都已经计算过了,终点就是手指点击处B可以通过 onTouchEvent() 获取到,就剩下一个控制点2,即C点需要计算。已知条件是,OC是∠AOB的中分线,OC=OA。

想计算图中∠AOB的角度,需要用到一个公式:cosAOB = (OA*OB)/(|OA|*|OB|),OA*OB是向量积,|OA|表示OA长度,写成代码如下:

/**

* 通过这个公式 cosAOB = (OA*OB)/(|OA|*|OB|) 计算出∠AOB的余弦值,

* 再通过反三角函数求得∠AOB的大小。

* OA=(Ax-Ox,Ay-Oy)

* OB=(Bx-Ox,By-Oy)

* OA*OB=(Ax-Ox)(Bx-Ox)+(Ay-Oy)*(By-Oy)

*/

public float calculateAngle(PointF O, PointF A, PointF B) {

float vectorProduct = (A.x - O.x) * (B.x - O.x) + (A.y - O.y) * (B.y - O.y);

float lengthOA = (float) Math.sqrt((A.x - O.x) * (A.x - O.x) + (A.y - O.y) * (A.y - O.y));

float lengthOB = (float) Math.sqrt((B.x - O.x) * (B.x - O.x) + (B.y - O.y) * (B.y - O.y));

float cosAOB = vectorProduct / (lengthOA * lengthOB);

float angleAOB = (float) Math.toDegrees(Math.acos(cosAOB));

// 使用向量叉乘计算方向,先求出向量OA(Xo-Xa,Yo-Ya)、OB(Xo-Xb,Yo-Yb),

// OA x OB = (Xo-Xa)*(Yo-Yb) - (Yo-Ya)*(Xo-Xb),若结果小于0,则OA在OB的逆时针方向

float direction = (O.x - A.x) * (O.y - B.y) - (O.y - A.y) * (O.x - B.x);

// 另一种计算方式,通过AB和OB与x轴夹角大小判断

// float direction = (A.y - B.y) / (A.x - B.x) - (O.y - B.y) / (O.x - B.x);

if (direction == 0) {

// A、O、B 在同一条直线上的情况,可能同向,也可能反向,

// 要看向量积的正负进一步决定决定鱼的掉头方向。

if (vectorProduct >= 0) {

return 0;

} else {

return 180;

}

} else {

if (direction > 0) {

// B在A的顺时针方向,为负

return -angleAOB;

} else {

return angleAOB;

}

}

}

借助上面的方法可以求出三阶贝塞尔曲线的控制点2的坐标了:

/**

* 绘制鱼游动的三阶贝塞尔曲线

*/

private void makeMovingPath() {

/**

* 1、先求出图中重心点、控制点1和结束点(即点击点)在当前ViewGroup中的绝对坐标备用

*/

// 鱼的重心在 FishDrawable 中的坐标

PointF fishRelativeMiddlePoint = fishDrawable.getMiddlePoint();

// 鱼的重心在当前 ViewGroup 中的绝对坐标——起始点O

PointF fishMiddlePoint = new PointF(ivFish.getX() + fishRelativeMiddlePoint.x,

ivFish.getY() + fishRelativeMiddlePoint.y);

// 鱼头圆心的相对坐标和绝对坐标——控制点1 A

PointF fishRelativeHeadPoint = fishDrawable.getHeadPoint();

PointF fishHeadPoint = new PointF(ivFish.getX() + fishRelativeHeadPoint.x,

ivFish.getY() + fishRelativeHeadPoint.y);

// 点击坐标——结束点B

PointF endPoint = new PointF(touchX, touchY);

/**

* 2、求控制点2——C的坐标。先求OC与x轴的夹角,已知∠AOC是∠AOB的一半,那么所求夹角就是∠AOC-∠AOX,

* 因为在 calculateAngle() 中已经对角度正负做了处理,因此带入时用 angleAOC + angleAOX。

* todo

*/

float angleAOC = calculateAngle(fishMiddlePoint, fishHeadPoint, endPoint) / 2;

float angleAOX = calculateAngle(fishHeadPoint, fishHeadPoint, new PointF(fishMiddlePoint.x + 1, fishMiddlePoint.y));

PointF controlPointC = fishDrawable.calculatePoint(fishMiddlePoint,

FishDrawable.HEAD_RADIUS * 1.6f, angleAOC + angleAOX);

/**

* 3、绘制曲线,注意属性动画只是将 ivFish 这个 ImageView 的 x,y 平移了,并没有实现鱼头

* 角度的转动,并且平移时为了保证是鱼的重心平移到被点击的点,path 中的坐标都要减去鱼的重心

* 相对 ImageView 的坐标(否则平移的点以 ImageView 的左上角为准)。

*/

Path path = new Path();

path.moveTo(fishMiddlePoint.x - fishRelativeMiddlePoint.x, fishMiddlePoint.y - fishRelativeMiddlePoint.y);

path.cubicTo(fishHeadPoint.x - fishRelativeMiddlePoint.x, fishHeadPoint.y - fishRelativeMiddlePoint.y,

controlPointC.x - fishRelativeMiddlePoint.x, controlPointC.y - fishRelativeMiddlePoint.y,

endPoint.x - fishRelativeMiddlePoint.x, endPoint.y - fishRelativeMiddlePoint.y);

ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(ivFish, "x", "y", path);

objectAnimator.setDuration(2000);

objectAnimator.addListener(new AnimatorListenerAdapter() {

// 鱼开始游动时,摆尾频率更快一些。

@Override

public void onAnimationEnd(Animator animation) {

super.onAnimationEnd(animation);

fishDrawable.setFrequency(1f);

}

@Override

public void onAnimationStart(Animator animation) {

super.onAnimationStart(animation);

fishDrawable.setFrequency(3f);

}

});

/**

* 4、鱼头方向与贝塞尔曲线的切线方向保持一致,从而实现鱼的调头

*/

final float[] tan = new float[2];

final PathMeasure pathMeasure = new PathMeasure(path, false);

objectAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {

@Override

public void onAnimationUpdate(ValueAnimator animation) {

// 获取到动画当前执行的百分比

float fraction = animation.getAnimatedFraction();

// 把动画执行的百分比转换成已经走过的路径,再借助 PathMeasure 计算

// 出当前所处的点的位置(用不到传了null)和正切tan值

pathMeasure.getPosTan(pathMeasure.getLength() * fraction, null, tan);

// 利用正切值计算出的角度正是曲线上当前点的切线角度,注意

// 表示纵坐标的tan[1]取了反还是因为数学与屏幕坐标系Y轴相反的缘故。

float angle = (float) Math.toDegrees(Math.atan2(-tan[1], tan[0]));

// 让鱼头方向转向切线方向

fishDrawable.setFishMainAngle(angle);

}

});

objectAnimator.start();

}

实现方式注释已经写的很清楚了,不再过多赘述,只提一下平移动画 objectAnimator 加了一个 AnimatorListenerAdapter 的监听,在动画开始时通过 fishDrawable.setFrequency(3f) 加快了鱼尾的摆动频率,在结束时又将频率设回为1,这个需要在 FishDrawable 中,计算角度的公式加上这个频率:

// 鱼尾摆动的频率控制(鱼尾在开始游动时摆的快一点)

private float frequency = 1f;

private void makeTriangle(Canvas canvas, PointF startPoint, float toEdgeMiddleLength, float edgeLength, float fishAngle) {

float triangleAngle = (float) (fishAngle + Math.sin(Math.toRadians(currentAnimatorValue * frequency * 1.5)) * 25);

}

private PointF makeSegment(Canvas canvas, PointF bigCircleCenterPoint, float bigCircleRadius, float smallCircleRadius,

float circleCenterLength, float fishAngle, boolean hasBigCircle) {

float segmentAngle;

if (hasBigCircle) {

// 节肢1

segmentAngle = (float) (fishAngle + Math.cos(Math.toRadians(currentAnimatorValue * frequency * 1.5)) * 15);

} else {

// 节肢2

segmentAngle = (float) (fishAngle + Math.sin(Math.toRadians(currentAnimatorValue * frequency * 1.5)) * 25);

}

}

五、鱼鳍的摆动

到目前为止,鱼鳍还不能摆动,我们想让鱼在开始游动时随机摆动几下鱼鳍。通过前面的叙述我们应该容易想到,鱼鳍的摆动其实就是通过改变二阶贝塞尔曲线的控制点,让这个控制点在垂直于鱼鳍的那条垂线上移动,就能做出鱼鳍摆动的效果:

之前画静态鱼鳍时,控制点与鱼头方向的夹角为110°,我们现在就规定,这个控制点,就是鱼鳍在摆动过程中,距离鱼鳍最远的那个控制点(即图中蓝色点)。由蓝色控制点向鱼鳍作垂线,与鱼鳍焦点为 controlFishCrossPoint(代码中用的变量名),由于蓝色控制点到鱼鳍起始点的距离已知,那么就能求出 controlFishCrossPoint 的坐标,和蓝色控制点到 controlFishCrossPoint 的距离 lineLength,这个距离也就是所有控制点到 controlFishCrossPoint 最远额距离了。

而后当鱼鳍摆动动画开始时,控制点沿着黑色虚线滑动,可能会变为红色控制点。红色控制点到蓝色控制点的距离 finsValue 会根据动画变化,那么用 lineLength - finsValue 就得到了红色控制点到 controlFishCrossPoint 的距离,进而能求得红色控制点坐标。代码如下:

// 鱼鳍摆动控制

private float finsValue;

/**

* 鱼鳍其实用二阶贝塞尔曲线画出来的,鱼鳍长度是已知的,我们设置 FINS_LENGTH 为 1.3R,

* 另外控制点与起点的距离,以及这二点连线与x轴的夹角,也是根据效果图测量后按比例给出的。

*/

private void makeFin(Canvas canvas, PointF startPoint, float fishAngle, boolean isLeftFin) {

// 鱼鳍的二阶贝塞尔曲线,控制点与起点连线长度是鱼鳍长度的1.8倍,夹角为110°

float controlPointAngle = 110;

// 计算鱼鳍终点坐标,起始点与结束点方向刚好与鱼头方向相反,因此要-180

PointF endPoint = calculatePoint(startPoint, FINS_LENGTH, fishAngle - 180);

// 鱼鳍不动时的控制点,以鱼头方向为准,左侧鱼鳍增加 controlPointAngle,右侧则减。

// PointF controlPoint = calculatePoint(startPoint, FINS_LENGTH * 1.8f,

// isLeftFin ? fishAngle + controlPointAngle : fishAngle - controlPointAngle);

// 开始计算鱼鳍摆动时的控制点

float controlFishCrossLength = (float) (FINS_LENGTH * 1.8f * Math.cos(Math.toRadians(70)));

PointF controlFishCrossPoint = calculatePoint(startPoint, controlFishCrossLength, fishAngle - 180);

// 最远的控制点到 controlFishCrossPoint 的距离,当然 controlFishCrossLength 也可以换成 HEAD_RADIUS

float lineLength = (float) Math.abs(Math.tan(Math.toRadians(controlPointAngle)) * controlFishCrossLength);

float line = lineLength - finsValue;

PointF controlPoint = calculatePoint(controlFishCrossPoint, line,

isLeftFin ? fishAngle + 90 : fishAngle - 90);

// 开始绘制

mPath.reset();

mPath.moveTo(startPoint.x, startPoint.y);

mPath.quadTo(controlPoint.x, controlPoint.y, endPoint.x, endPoint.y);

canvas.drawPath(mPath, mPaint);

}

// finsValue 的 getter、setter

另外还要在平移 ImageView 那个属性动画开始的时候,设置 finsValue:

ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(ivFish, "x", "y", path);

objectAnimator.addListener(new AnimatorListenerAdapter() {

// 鱼开始游动时,摆尾频率更快一些。

@Override

public void onAnimationEnd(Animator animation) {

super.onAnimationEnd(animation);

fishDrawable.setFrequency(1f);

}

@Override

public void onAnimationStart(Animator animation) {

super.onAnimationStart(animation);

fishDrawable.setFrequency(3f);

// 鱼鳍摆动动画,动画时间和重复次数具有随机性

ObjectAnimator finsAnimator = ObjectAnimator.ofFloat(fishDrawable, "finsValue",

0, FishDrawable.HEAD_RADIUS * 2, 0);

finsAnimator.setDuration((new Random().nextInt(1) + 1) * 500);

finsAnimator.setRepeatCount(new Random().nextInt(4));

finsAnimator.start();

}

});

至此,锦鲤绘制基本完成,参考代码:SwimmingKoiDemo

Posted in 简易世界杯
Copyright © 2088 世界杯历年冠军_世界杯央视 - zhwnj.com All Rights Reserved.
友情链接