看文章时无意间发现了一个很有趣的动画效果,于是自己动手实现了一下。点击屏幕上的任意一点,鱼会朝向该点游动,效果图如下:
参考的文章链接如下:
自定义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 的宽高至少为 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();
}
效果如下:
四、鱼向指定位置游动
最后来完成鱼的游动效果。鱼的游动路线可以用一个三阶贝塞尔曲线表示:
在上图中我们可以看到,三阶贝塞尔曲线中,起始点的鱼身重心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