从0开始搞一个锦鲤游动——动起来

上一篇文章主要讲了如何绘制一条锦鲤,而今天这篇文章,就讲如何让我们的锦鲤动起来。这次需要写一些比较复杂的算法,与数学关系很大,个别地方可能难以理解。不过大家可以多看两遍仔细斟酌,如果还是不理解的话可以来评论区交流。

效果展示

在这里插入图片描述

一.属性动画(ValueAnimator)

1.属性动画ValueAnimator介绍

ValueAnimator没有重绘,所以需要自己调用addUpdateListener方法,需要结合AnimatorUpdateListener使用
②操作的对象的属性不一定要有gettersetter方法
③默认插值器为AccelerateDecelerateInterpolator

2.常用方法使用

在这里插入图片描述

3.使用属性动画让我们的鱼先动起来

	//属性动画当前的值
    private float currentValue = 0;
    
	//初始化
    private void init() {  
        //属性动画
        ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, 1);
        valueAnimator.setDuration(1000);
        valueAnimator.setStartDelay(1000);
        //系统默认的插值器是先快后慢的,我们在这里改成匀速的插值器
        valueAnimator.setInterpolator(new LinearInterpolator());
        valueAnimator.setRepeatMode(ValueAnimator.RESTART);
        valueAnimator.setRepeatCount(ValueAnimator.INFINITE);
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                currentValue = (float) animation.getAnimatedValue();

                //别忘了在值进行改变了之后要重绘
                invalidateSelf();
            }
        });

        //别忘了开启动画
        valueAnimator.start();
    }
    
	@Override
    public void draw(@NonNull Canvas canvas) {
        //鱼的朝向
        float fishAngle = fishMainAngle + currentValue*5;
    }

效果展示
在这里插入图片描述

二.让鱼真正动起来

1.鱼身和鱼节肢摆动频率不应相同

在正常的情况下,鱼身的摆动频率应该低于鱼尾的摆动频率,这样效果才能更逼真。但是属性动画只能设置一个Duration,也就是说如果想实现摆动频率不同,我们可以使用两个属性动画。OK,这样是可以的,但是,如果我们就只想使用一个属性动画呢?那该怎么办呢?
这就需要用到三角函数的周期了。
在这里插入图片描述
比如这里正弦函数的图像。它的变化范围始终是-11,如果我们把sin的参数设置为Math.toRadians(currentValue)(即属性动画改变的量,toRadians方法是将角度值转换为弧度制),让它在1s的周期(也就是Duration为1000)内,从0变化到20pai,那它的频率就是10个来回。假如上面这个是设置的身体,那我设置我的节肢的参数为Math.toRadians(currentValue * 2),那就是20个来回,这就达到了改变频率的效果。
ok,我们设置属性动画的变化为

ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, 720);

然后将鱼的角度赋值

 //鱼的朝向
 float fishAngle = (float) (fishMainAngle + Math.sin(Math.toRadians(currentValue))*10);

然后改变鱼肢的角度

float segmentAngle = (float) (fishAngle + Math.sin(Math.toRadians(currentValue * 1.5)) * 10);

解释一下,这里乘以的10,是改变的转动幅度大小,也就是左右都变化10度,而toRadians里面的参数,才是改变的频率。
在这里插入图片描述

2.两个鱼节肢的摆动幅度不应该相同

两个鱼节肢的表现应该是,节肢1类似于拉着节肢2的那种感觉。就比如下面这个图
在这里插入图片描述
节肢1和节肢2差着三角函数的一个相位。正好是cossin的区别。而且应该节肢1是cos,因为它先动,具体到三角函数就是当参数为0时,cos为1,而sin为0
所以在画节肢的方法里,应该加上

float segmentAngle;

if(hasBigCircle){
    //节肢1
    segmentAngle = (float) (fishAngle + Math.cos(Math.toRadians(currentValue * 1.5)) * 15);
}else {
    //节肢2
    segmentAngle = (float) (fishAngle + Math.sin(Math.toRadians(currentValue * 1.5)) * 30);
}

两个节肢频率相同,但是幅度不同
效果图
在这里插入图片描述
当然我们发现尾巴(即三角形)没有和节肢2同步,所以我们在制作尾巴的方法中添加

float triangle = (float) (fishAngle + Math.sin(Math.toRadians(currentValue * 1.5)) * 30);

然后应用上就可以了
在这里插入图片描述

3.鱼尾部三角形发生伸缩变化

也是和上面类似的做法,值得一提的是Math.abs是绝对值,因为三角形的边不可能为负数

float halfEdgeLength = (float) Math.abs(Math.sin(Math.toRadians(currentValue * 1.5)) * BIG_CIRCLE_RADIUS);
//画底边三角形
makeTriangle(canvas,middleCirclePoint,FIND_TRIANGLE_LENGTH,halfEdgeLength,fishAngle);
makeTriangle(canvas,middleCirclePoint,FIND_TRIANGLE_LENGTH-20
       ,halfEdgeLength - 10,fishAngle);

在这里插入图片描述

三.让鱼游起来

上面我们讲了如何让鱼真正动起来,还没有涉及一些比较复杂的算法,但值得一提的就是如何使用一个属性动画完成不同频率的改变,以及如何用相位体现两个节肢摆动不同。下面让鱼动起来的算法中,有的就难以理解了。

1.首先,自定义一个ViewGroup

我们要让鱼在我们的自定义ViewGroup中游
①新建FishRelativeLayout 类,使其成为我们的自定义ViewGroup

public class FishRelativeLayout extends RelativeLayout {
    public FishRelativeLayout(Context context) {
        this(context,null);
    }

    public FishRelativeLayout(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public FishRelativeLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    private void init(Context context) {
    }
}

②修改MainActivity的布局文件,将我们的自定义ViewGroup添加进去

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

<!--    <ImageView-->
<!--        android:id="@+id/iv_fish"-->
<!--        android:layout_width="wrap_content"-->
<!--        android:layout_height="wrap_content"-->
<!--        android:layout_centerInParent="true"/>-->
    
	<com.example.a2021323.FishRelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>


</RelativeLayout>

③把MainActivity中的两句话去掉

public class MainActivity extends AppCompatActivity {
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
//        ImageView fishView = findViewById(R.id.iv_fish);
//        fishView.setImageDrawable(new fishDrawable());
    }
}

2.进行一些前期必要的初始化操作

public class FishRelativeLayout extends RelativeLayout {
    private Paint mPaint;
    ImageView iv_Fish;
    fishDrawable fishDrawable;
    
    public FishRelativeLayout(Context context) {
        this(context,null);
    }

    public FishRelativeLayout(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public FishRelativeLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    private void init(Context context) {
        //ViewGroup默认不执行onDraw,所以要设置一下,因为后面我们要用到它的onDraw
        setWillNotDraw(false);
        
        //这个画笔是画水波纹的
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setDither(true);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(8);
        
        //将鱼加入到自定义ViewGroup中
        iv_Fish = new ImageView(context);
        LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        iv_Fish.setLayoutParams(layoutParams);

        fishDrawable = new fishDrawable();
        iv_Fish.setImageDrawable(fishDrawable);
        addView(iv_Fish);
    }
}

3.ObjectAnimator实现水波纹效果

ObjectAnimator继承自ValueAnimator,相对于ValueAnimatior,它可以直接操作控件。
原理:通过改变 View 的属性值来改变控件的形态,说白了就是通过反射技术来获取控件的一些属性如alphascaleY等的 getset 方法,从而实现所谓的动画效果。所以,这就需要我们的 View (如自定义 View 中)具有 setget 方法,如果没有则会导致程序的 Clash
在这里插入图片描述
代码

	float touchX;
    float touchY;
    //波纹
    float ripple;
    float alpha;
	@Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        mPaint.setAlpha((int) alpha);
        canvas.drawCircle(touchX,touchY,ripple * 150,mPaint);
        invalidate();
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //首先得到触摸点的坐标
        touchX = event.getX();
        touchY = event.getY();

        //利用ObjectAnimator设置波纹半径变化
        //注意:这里ripple一定要有set和get方法
        ObjectAnimator ripple = ObjectAnimator.ofFloat(this, "ripple", 0, 1).setDuration(1000);
        ripple.start();
        return super.onTouchEvent(event);
    }


    public float getRipple() {
        return ripple;
    }

    public void setRipple(float ripple) {
        alpha = 100 * (1 - ripple);
        this.ripple = ripple;
    }

注意这里,如果要更改alpha的值,就要求你要对ObjectAnimator有个比较深的理解。我们在开始的时候说了,它要求被更改的属性有set方法,换句话说,它一直在调用被更改属性的set方法,所以我们可以在set方法里面更改alpha的值。
效果
在这里插入图片描述
可以看到水波纹的效果出来了

4.利用三阶贝塞尔曲线使得鱼能够按照相应轨迹游动

我们规定三阶贝塞尔曲线的起点是鱼的中心控制点1是鱼的头部圆的圆心终点是点击位置。而控制点二选哪比较合适呢?这个其实可以自己规定。我这边就规定在AOT的角平分线上,看下图
在这里插入图片描述
控制点2就是AOT的角平分线上的某个点(这个点是自己规定的,不一定非要这样)。而控制点二就决定了这个鱼要往哪个方向游。比如你点击鱼的右侧,那么鱼肯定是直接往右边游而不是先往左拐绕一圈再走到你的点击位置(除非你想玩花的)。所以如何计算控制点二的位置成了问题的关键
ok,先不急,让我们先把其他点的坐标搞出来

@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
private void makeTrail() {
   //鱼的中心的相对坐标
   PointF fishRelativePoint = fishDrawable.getMiddlePoint();
   //鱼的中心的绝对坐标(相对于整个屏幕的)----起始点
   PointF fishMiddle = new PointF(iv_Fish.getX() + fishRelativePoint.x, iv_Fish.getY() + fishRelativePoint.y);
   //鱼头的圆心的绝对坐标------------------控制点1
   PointF fishHeadPoint = new PointF(iv_Fish.getX() + fishDrawable.getHeadPoint().x, iv_Fish.getY() + fishDrawable.getHeadPoint().y);
   //触摸点的绝对坐标---------------------结束点
   PointF touchPoint = new PointF(touchX, touchY);

   //因为图片控件是根据路径一直在变的,所以我们这里要根据路径,对其坐标进行更新
   Path mPath = new Path();
   //注意这里,第一个参数是填图片控件,第二个参数和第三个参数都是填变量名。
   //这里也是通过get方法去得到的变量
   ObjectAnimator.ofFloat(iv_Fish,"x","y",mPath);
}

5.难点来了:核心算法求两线夹角

public float includeAngle(PointF O, PointF A, PointF B) {
        // cosAOB
        // OA*OB=(Ax-Ox)(Bx-Ox)+(Ay-Oy)*(By-Oy)
        float AOB = (A.x - O.x) * (B.x - O.x) + (A.y - O.y) * (B.y - O.y);
        float OALength = (float) Math.sqrt((A.x - O.x) * (A.x - O.x) + (A.y - O.y) * (A.y - O.y));
        // OB 的长度
        float OBLength = (float) Math.sqrt((B.x - O.x) * (B.x - O.x) + (B.y - O.y) * (B.y - O.y));
        float cosAOB = AOB / (OALength * OBLength);

        // 反余弦
        float angleAOB = (float) Math.toDegrees(Math.acos(cosAOB));

        // AB连线与X的夹角的tan值 - OB与x轴的夹角的tan值
        float direction = (A.y - B.y) / (A.x - B.x) - (O.y - B.y) / (O.x - B.x);

        if (direction == 0) {
            if (AOB >= 0) {
                return 0;
            } else {
                return 180;
            }
        } else {
            if (direction > 0) {
                return -angleAOB;
            } else {
                return angleAOB;
            }
        }
}

是不是看的一脸懵逼?没关系,首先,我们的总体思路是求出两条直线夹角的余弦值然后再arcos得到夹角大小。我们现在是知道两条直线的长度。这时我们就要用到高中的一个知识点:余弦定理

向量的夹角公式:计算夹角cosAOB = (OA*OB)/(|OA|*|OB|)其中OA*OB向量的数量积
同时
OA=(Ax-Ox,Ay-Oy)
OB=(Bx-Ox,By-Oy)
OA*OB=(Ax-Ox)(Bx-Ox)+(Ay-Oy)*(By-Oy)
|OA|表示线段OA的模即OA的长度
然后再来分析上面算法的前几行

//求向量的点乘
float AOB = (A.x - O.x) * (B.x - O.x) + (A.y - O.y) * (B.y - O.y);
//求线段的长度
float OALength = (float) Math.sqrt((A.x - O.x) * (A.x - O.x) + (A.y - O.y) * (A.y - O.y));
// OB 的长度
float OBLength = (float) Math.sqrt((B.x - O.x) * (B.x - O.x) + (B.y - O.y) * (B.y - O.y));
//求余弦
float cosAOB = AOB / (OALength * OBLength);
// 反余弦
float angleAOB = (float) Math.toDegrees(Math.acos(cosAOB));

Math.sqrt是求开方。这里用到了勾股定理
OK,接下来到重头戏了,下面这个是什么意思呢?

// AB连线与X的夹角的tan值 - OB与x轴的夹角的tan值
float direction = (A.y - B.y) / (A.x - B.x) - (O.y - B.y) / (O.x - B.x);

我用图来解释
在这里插入图片描述
①我们以AO中点作为原点,将图片分为右上角,左上角,左下角,右下角。比如上面这幅图就是BAO中线的右上角,不管BA的上面还是下面,tanα都小于tanβ,所以direction 小于0.
②当BAO中线的右下方时,如下图,α还是大于β,可以得出,不管BO的上面还是下面,α都大于β,所以tanα-tanβ都大于0。其他情况都类似
在这里插入图片描述

所以我归纳一下

在AO中线哪direction
右上方α<β,direction<0
右下方α>β,direction<0
左上方α<β,direction>0
左下方α>β,direction>0

我们再把最后那部分代码拿过来分析一下

		if (direction == 0) {
            if (AOB >= 0) {
                return 0;
            } else {
                return 180;
            }
        } else {
            if (direction > 0) {
                return angleAOB;
            } else {
                return -angleAOB;
            }
        }

前面direction == 0的情况,就是在鱼的正上方或者正下方点击。如果是正上方,那么AOB夹角为0.如果是正下方,那么AOB夹角为180.
在这里插入图片描述
上面是在正上方的情况
下面是在正下方的情况
在这里插入图片描述
如果direction>0,就是在左侧,返回AOB角大小
如果direction<0,就是在右侧,返回AOB角大小的相反数
问题来了,为什么要取反?
我们求B点的坐标的时候,现在知道了AOB夹角的大小。然后我们利用上一篇博客的核心算法calculatePoint,应该知道β的大小并作为angle传进去。作图的话就是这样
在这里插入图片描述
β=AOX-AOB。我们这里把得到angle之后,如何处理的代码贴出来

float angle = includeAngle(fishMiddle, fishHeadPoint, touchPoint) / 2;
float delta = includeAngle(fishMiddle, new PointF(fishMiddle.x + 1, fishMiddle.y), fishHeadPoint);
// 控制点2 的坐标
PointF controlPoint = fishDrawable.calculatePoint(fishMiddle,
      fishDrawable.getHEAD_RADIUS() * 1.6f, angle + delta);

各个角是什么,可以看下面这个图
在这里插入图片描述
我们可以看到,在计算controlPoint 的时候,最后一个参数,传入了angle + delta,当点击位置B在右侧的时候,angle应该是负值,这样效果就是delta-|angle|,也就是计算出了这个β值。当B在左边的时候,angle应该是正的。OK。这就解决了为什么取反的问题。
OK,通过以上努力,我们求出了控制点的坐标

6.利用三阶贝塞尔曲线,绘制路径

然后绘制曲线

@RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private void makeTrail() {
        Path mPath = new Path();
        mPath.moveTo(fishMiddle.x - fishRelativePoint.x, fishMiddle.y - fishRelativePoint.y);
        //画三阶贝塞尔曲线
        mPath.cubicTo(fishHeadPoint.x - fishRelativePoint.x, fishHeadPoint.y - fishRelativePoint.y,
                controlPoint.x - fishRelativePoint.x, controlPoint.y - fishRelativePoint.y,
                touchX - fishRelativePoint.x, touchY - fishRelativePoint.y);
        //注意这里,第一个参数是填图片控件,第二个参数和第三个参数都是填变量名。
        //这里也是通过get方法去得到的变量
        ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(iv_Fish, "x", "y", mPath);

        objectAnimator.setDuration(2000);
        objectAnimator.start();
    }

效果
在这里插入图片描述
减去相对位置
这里值得一提的是,我这边确定路径的坐标的时候,都有减一个值。这个值是干嘛的呢?
假如我们不减,比如下面这个moveTo我就没减,

mPath.moveTo(fishMiddle.x, fishMiddle.y);
//画三阶贝塞尔曲线
mPath.cubicTo(fishHeadPoint.x - fishRelativePoint.x, fishHeadPoint.y - fishRelativePoint.y,
        controlPoint.x - fishRelativePoint.x, controlPoint.y - fishRelativePoint.y,
        touchX - fishRelativePoint.x, touchY - fishRelativePoint.y);

那么我们推测一下小鱼的行驶路线
在这里插入图片描述

当我们点击屏幕的TouchPoint点的时候,小鱼应该立马到蓝色框的位置,因为path的坐标是图片控件即ImageView的坐标,而具体位置就是图片控件的左上角。OK,让我们验证一下,看看是不是

在这里插入图片描述

发现果然是这样,所以我们path的坐标从一开始应该精准到图片控件的左上角,而不是图片控件的中心。所以正确情况下,应该是

mPath.moveTo(fishMiddle.x - fishRelativePoint.x, fishMiddle.y - fishRelativePoint.y);
//画三阶贝塞尔曲线
mPath.cubicTo(fishHeadPoint.x - fishRelativePoint.x, fishHeadPoint.y - fishRelativePoint.y,
        controlPoint.x - fishRelativePoint.x, controlPoint.y - fishRelativePoint.y,
        touchX - fishRelativePoint.x, touchY - fishRelativePoint.y);

包括后面的三阶贝塞尔曲线的控制点的坐标以及结束点的坐标都要减去,以达到一直以图片控件左上角为标准的效果

7.游动时摆动速度变快

鱼在游动的时候,身体摆动的速度应该是加快的

    objectAnimator.addListener(new AnimatorListenerAdapter(){
            @Override
            public void onAnimationStart(Animator animation) {
                super.onAnimationStart(animation);
                fishDrawable.setFrequence(2f);
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                fishDrawable.setFrequence(1f);
            }
     });

8.使鱼头跟着转向

上面的演示中大家可以发现,鱼身是到了我点的哪个地方,但是鱼头没有跟着转。而这个鱼头,应该是沿着路径的切线动。也就是说它的方向应该一直与路径切线的方向保持一致。而求路径的角度就是求每一个切线的角度,也就是求每一个切线的tan
在这里插入图片描述

Math.toDegrees是把弧度转换成角度
-tan[1]前面有负号的原因是安卓坐标系和数学坐标系,y轴是反的

final PathMeasure pathMeasure = new PathMeasure(mPath, false);
final float[] tan = new float[2];
objectAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override
public void onAnimationUpdate(ValueAnimator animation) {
//                animation.getAnimatedValue();
    // 执行了整个周期的百分之多少
    float fraction = animation.getAnimatedFraction();
    pathMeasure.getPosTan(pathMeasure.getLength() * fraction, null, tan);//tan的意义在下文有解释
    float angle = (float) Math.toDegrees(Math.atan2(-tan[1], tan[0]));
    fishDrawable.setFishMainAngle(angle);
}
});

关于getPosTan方法的第三个参数
其中tan[0]是邻边边长,tan[1]是对边边长,可以利用Mathatan2 方法:根据正切数值计算出该角度的大小,得到的单位是弧度,再根据Math.toDegrees可以得到角度
在这里插入图片描述

完整代码

一共三个类
先看XML

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

<!--    <ImageView-->
<!--        android:id="@+id/iv_fish"-->
<!--        android:layout_width="wrap_content"-->
<!--        android:layout_height="wrap_content"-->
<!--        android:layout_centerInParent="true"/>-->
    <com.example.a2021323.FishRelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"/>


</RelativeLayout>

MainActivity

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
//        ImageView fishView = findViewById(R.id.iv_fish);
//        fishView.setImageDrawable(new fishDrawable());
    }
}

fishDrawable

public class fishDrawable extends Drawable {
    //创建路径和画笔
    private Path mPath;
    private Paint mPaint;

    //设置画笔的透明度
    private int OTHER_ALPHA = 110;

    //保存鱼的重心
    private PointF middlePoint;
    private PointF headPoint;

    //设置鱼的主要角度,与X轴的夹角
    private float fishMainAngle = 90;

    /**
     * 与鱼的长度有关的所有值
     */
    //设置鱼头的圆的大小
    private float HEAD_RADIUS = 100f;
    //设置鱼身的大小
    private float BODY_LENGTH = 3.2f*HEAD_RADIUS;
    //设置鱼鳍边界点和鱼头中心点的距离
    private float FIND_FINS_LENGTH = 0.9f * HEAD_RADIUS;
    //鱼鳍长度
    private float FINS_LENGTH = 1.3f * HEAD_RADIUS;
    //大圆的半径
    private float BIG_CIRCLE_RADIUS = 0.7f * HEAD_RADIUS;
    //中圆的半径
    private float MIDDLE_CIRCLE_RADIUS = 0.6f * BIG_CIRCLE_RADIUS;
    //小圆的半径
    private float SMALL_CIRCLE_RADIUS = 0.4f * MIDDLE_CIRCLE_RADIUS;
    //寻找尾部中圆圆心的线长
    private final float FIND_MIDDLE_CIRCLE_LENGTH = BIG_CIRCLE_RADIUS * (0.6f + 1);
    //寻找尾部小圆圆心的线长
    private final float FIND_SMALL_CIRCLE_LENGTH = MIDDLE_CIRCLE_RADIUS * (0.4f + 2.7f);
    //寻找大三角形底边中心点的线长
    private final float FIND_TRIANGLE_LENGTH = MIDDLE_CIRCLE_RADIUS * 2.7f;

    //属性动画当前的值
    private float currentValue = 0;
    fishDrawable(){
        init();
    }

    //初始化
    private void init() {
        mPath = new Path();
        mPaint = new Paint();

        mPaint.setStyle(Paint.Style.FILL);
        //抗锯齿
        mPaint.setAntiAlias(true);
        //防抖动
        mPaint.setDither(true);
        //设置颜色
        mPaint.setARGB(OTHER_ALPHA,244,92,71);

        middlePoint = new PointF(4.19f * HEAD_RADIUS,4.19F * HEAD_RADIUS);

        //属性动画
        ValueAnimator valueAnimator = ValueAnimator.ofFloat(0, 720f);
        valueAnimator.setDuration(2000);
        valueAnimator.setStartDelay(1000);
        //系统默认的插值器是先快后慢的,我们在这里改成匀速的插值器
        valueAnimator.setInterpolator(new LinearInterpolator());
        valueAnimator.setRepeatMode(ValueAnimator.RESTART);
        valueAnimator.setRepeatCount(ValueAnimator.INFINITE);
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                currentValue = (float) animation.getAnimatedValue();

                //别忘了在值进行改变了之后要重绘
                invalidateSelf();
            }
        });

        //别忘了开启动画
        valueAnimator.start();
    }


    float frequence = 1;
    @Override
    public void draw(@NonNull Canvas canvas) {
        //鱼的朝向
        float fishAngle = (float) (fishMainAngle + Math.sin(Math.toRadians(currentValue * frequence))*10);

        //计算鱼头的圆心坐标
        headPoint = calculatePoint(middlePoint,BODY_LENGTH/2,fishAngle);
        //画鱼头的圆
        canvas.drawCircle(headPoint.x,headPoint.y,HEAD_RADIUS,mPaint);

        //画右鱼鳍
        PointF rightFinsPoint = calculatePoint(headPoint,FIND_FINS_LENGTH,fishAngle - 100);
        makeFins(canvas,rightFinsPoint,fishAngle,true);

        //画左鱼鳍
        PointF leftFinsPoint = calculatePoint(headPoint,FIND_FINS_LENGTH,fishAngle + 100);
        makeFins(canvas,leftFinsPoint,fishAngle,false);

        //画节肢1
        //首先找到鱼身体底部的中心点
        PointF bodyBottomCenterPoint = calculatePoint(headPoint,BODY_LENGTH,fishAngle-180);
        PointF middleCirclePoint = makeSegment(canvas,bodyBottomCenterPoint,MIDDLE_CIRCLE_RADIUS,
                BIG_CIRCLE_RADIUS,FIND_MIDDLE_CIRCLE_LENGTH,fishAngle,true);
        //画节肢2
        makeSegment(canvas,middleCirclePoint,SMALL_CIRCLE_RADIUS,MIDDLE_CIRCLE_RADIUS,
                FIND_SMALL_CIRCLE_LENGTH,fishAngle,false);

        float halfEdgeLength = (float) Math.abs(Math.sin(Math.toRadians(currentValue * 1.5  * frequence)) * BIG_CIRCLE_RADIUS);
        //画底边三角形
        makeTriangle(canvas,middleCirclePoint,FIND_TRIANGLE_LENGTH,halfEdgeLength,fishAngle);
        makeTriangle(canvas,middleCirclePoint,FIND_TRIANGLE_LENGTH-20
                ,halfEdgeLength - 10,fishAngle);

        //画身体
        makeBody(canvas,headPoint,bodyBottomCenterPoint,fishAngle);
    }

    private void makeBody(Canvas canvas, PointF headPoint, PointF bodyBottomCenterPoint, float fishAngle) {
        //首先得到身体的四个顶点
        PointF topLeftPoint = calculatePoint(headPoint,HEAD_RADIUS,fishAngle + 90);
        PointF topRightPoint = calculatePoint(headPoint,HEAD_RADIUS,fishAngle - 90);
        PointF bottomLeftPoint = calculatePoint(bodyBottomCenterPoint,BIG_CIRCLE_RADIUS,fishAngle + 90);
        PointF bottomRightPoint = calculatePoint(bodyBottomCenterPoint,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(topLeftPoint.x,topLeftPoint.y);
        mPath.lineTo(topRightPoint.x,topRightPoint.y);
        mPath.quadTo(controlRight.x,controlRight.y,bottomRightPoint.x,bottomRightPoint.y);
        mPath.lineTo(bottomLeftPoint.x,bottomLeftPoint.y);
        mPath.quadTo(controlLeft.x,controlLeft.y,topLeftPoint.x,topLeftPoint.y);
        canvas.drawPath(mPath,mPaint);
    }

    private void makeTriangle(Canvas canvas, PointF middleCirclePoint, float findCenterLength,
                              float halfFdge,float fishAngle) {
        float triangle = (float) (fishAngle + Math.sin(Math.toRadians(currentValue * 1.5  * frequence)) * 30);
        //首先得到三角形底边中点坐标
        PointF triangleBottomPoint = calculatePoint(middleCirclePoint,findCenterLength,triangle + 180);

        //然后计算三角形各个顶点的坐标
        PointF leftPoint = calculatePoint(triangleBottomPoint,halfFdge,triangle + 90);
        PointF rightPoint = calculatePoint(triangleBottomPoint,halfFdge,triangle - 90);

        //画线
        mPath.reset();
        mPath.moveTo(middleCirclePoint.x,middleCirclePoint.y);
        mPath.lineTo(leftPoint.x,leftPoint.y);
        mPath.lineTo(rightPoint.x,rightPoint.y);

        canvas.drawPath(mPath,mPaint);
    }

    private PointF makeSegment(Canvas canvas, PointF bottomCenterPoint,float smallRadius
            ,float bigRadius,float findSmallCircleLength, float fishAngle,boolean hasBigCircle) {
        float segmentAngle;

        if(hasBigCircle){
            //节肢1
            segmentAngle = (float) (fishAngle + Math.cos(Math.toRadians(currentValue * 1.5  * frequence)) * 15);
        }else {
            //节肢2
            segmentAngle = (float) (fishAngle + Math.sin(Math.toRadians(currentValue * 1.5  * frequence)) * 30);
        }
        //根据梯形下底中心点的坐标,求出上底中心点的坐标
        PointF upperCenterPoint = calculatePoint(bottomCenterPoint,findSmallCircleLength,segmentAngle-180);

        //求出梯形四个顶点的坐标
        PointF bottomLeftPoint = calculatePoint(bottomCenterPoint,bigRadius,segmentAngle + 90);
        PointF bottomRightPoint = calculatePoint(bottomCenterPoint,bigRadius,segmentAngle - 90);
        PointF upperLeftPoint = calculatePoint(upperCenterPoint,smallRadius,segmentAngle + 90);
        PointF upperRightPoint = calculatePoint(upperCenterPoint,smallRadius,segmentAngle - 90);

        //画大圆和中圆
        //大圆只有在节肢1的时候才会画
        if(hasBigCircle){
            canvas.drawCircle(bottomCenterPoint.x,bottomCenterPoint.y, bigRadius,mPaint);
        }

        canvas.drawCircle(upperCenterPoint.x,upperCenterPoint.y,smallRadius,mPaint);

        //画梯形
        mPath.reset();
        mPath.moveTo(bottomLeftPoint.x,bottomLeftPoint.y);
        mPath.lineTo(bottomRightPoint.x,bottomRightPoint.y);
        mPath.lineTo(upperRightPoint.x,upperRightPoint.y);
        mPath.lineTo(upperLeftPoint.x,upperLeftPoint.y);
        canvas.drawPath(mPath,mPaint);

        //将中圆的圆心坐标返回
        return upperCenterPoint;
    }

    private void makeFins(Canvas canvas, PointF startFinsPoint, float fishAngle,boolean isRight) {
        //设置二阶贝塞尔曲线控制点的角度
        float controlAngle = 110;//通过看示意图得知,它要比上面那个110要大

        //求出右鱼鳍终点的坐标点
        PointF endFinsPoint = calculatePoint(startFinsPoint,FINS_LENGTH,fishAngle - 180);

        //求出控制点的坐标
        PointF controlPoint = calculatePoint(startFinsPoint,FINS_LENGTH * 1.8f
                ,isRight ? fishAngle - controlAngle : fishAngle + controlAngle);

        //画线
        mPath.reset();//首先别忘了reset
        mPath.moveTo(startFinsPoint.x,startFinsPoint.y);
        mPath.quadTo(controlPoint.x,controlPoint.y,endFinsPoint.x,endFinsPoint.y);

        canvas.drawPath(mPath,mPaint);
    }

    PointF calculatePoint(PointF startPoint, float length, float angle){
        //x坐标的一部分
        float deltaX = (float) (Math.cos(Math.toRadians(angle))*length);

        //y坐标的一部分
        float deltaY = (float) Math.sin(Math.toRadians(angle - 180))*length;

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

    //下面三个方法一般为固定写法
    @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 int getIntrinsicWidth() {
        return (int) (8.38 * HEAD_RADIUS);
    }

    @Override
    public int getIntrinsicHeight() {
        return (int) (8.38 * HEAD_RADIUS);
    }

    public PointF getMiddlePoint() {
        return middlePoint;
    }

    public void setMiddlePoint(PointF middlePoint) {
        this.middlePoint = middlePoint;
    }

    public PointF getHeadPoint() {
        return headPoint;
    }

    public float getHEAD_RADIUS() {
        return HEAD_RADIUS;
    }

    public void setFrequence(float frequence) {
        this.frequence = frequence;
    }

    public void setFishMainAngle(float fishMainAngle) {
        this.fishMainAngle = fishMainAngle;
    }
}

FishRelativeLayout

public class FishRelativeLayout extends RelativeLayout {
    private Paint mPaint;
    ImageView iv_Fish;
    fishDrawable fishDrawable;
    float touchX;
    float touchY;
    //波纹
    float ripple;
    float alpha;


    public FishRelativeLayout(Context context) {
        this(context,null);
    }

    public FishRelativeLayout(Context context, AttributeSet attrs) {
        this(context, attrs,0);
    }

    public FishRelativeLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init(context);
    }

    private void init(Context context) {
        //ViewGroup默认不执行onDraw,所以要设置一下,因为后面我们要用到它的onDraw
        setWillNotDraw(false);

        //这个画笔是画水波纹的
        mPaint = new Paint();
        mPaint.setAntiAlias(true);
        mPaint.setDither(true);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(8);

        //将鱼加入到自定义ViewGroup中
        iv_Fish = new ImageView(context);
        LayoutParams layoutParams = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        iv_Fish.setLayoutParams(layoutParams);

        fishDrawable = new fishDrawable();
        iv_Fish.setImageDrawable(fishDrawable);
        addView(iv_Fish);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

        mPaint.setAlpha((int) alpha);
        canvas.drawCircle(touchX,touchY,ripple * 150,mPaint);
        invalidate();
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        //首先得到触摸点的坐标
        touchX = event.getX();
        touchY = event.getY();

        //利用ObjectAnimator设置波纹半径变化
        //注意:这里ripple一定要有set和get方法
        ObjectAnimator ripple = ObjectAnimator.ofFloat(this, "ripple", 0, 1).setDuration(1000);
        ripple.start();
        
        makeTrail();
        return super.onTouchEvent(event);
    }

    @RequiresApi(api = Build.VERSION_CODES.LOLLIPOP)
    private void makeTrail() {
        //鱼的中心的相对坐标
        PointF fishRelativePoint = fishDrawable.getMiddlePoint();
        //鱼的中心的绝对坐标(相对于整个屏幕的)----起始点
        PointF fishMiddle = new PointF(iv_Fish.getX() + fishRelativePoint.x, iv_Fish.getY() + fishRelativePoint.y);
        //鱼头的圆心的绝对坐标------------------控制点1
        PointF fishHeadPoint = new PointF(iv_Fish.getX() + fishDrawable.getHeadPoint().x, iv_Fish.getY() + fishDrawable.getHeadPoint().y);
        //触摸点的绝对坐标---------------------结束点
        PointF touchPoint = new PointF(touchX, touchY);

        float angle = includeAngle(fishMiddle, fishHeadPoint, touchPoint) / 2;
        float delta = includeAngle(fishMiddle, new PointF(fishMiddle.x + 1, fishMiddle.y), fishHeadPoint);
        // 控制点2 的坐标
        PointF controlPoint = fishDrawable.calculatePoint(fishMiddle,
                fishDrawable.getHEAD_RADIUS() * 1.6f, angle + delta);

        Path mPath = new Path();
        mPath.moveTo(fishMiddle.x - fishRelativePoint.x, fishMiddle.y - fishRelativePoint.y);
        //画三阶贝塞尔曲线
        mPath.cubicTo(fishHeadPoint.x - fishRelativePoint.x, fishHeadPoint.y - fishRelativePoint.y,
                controlPoint.x - fishRelativePoint.x, controlPoint.y - fishRelativePoint.y,
                touchX - fishRelativePoint.x, touchY - fishRelativePoint.y);
        //注意这里,第一个参数是填图片控件,第二个参数和第三个参数都是填变量名。
        //这里也是通过get方法去得到的变量
        ObjectAnimator objectAnimator = ObjectAnimator.ofFloat(iv_Fish, "x", "y", mPath);

        objectAnimator.setDuration(2000);
        objectAnimator.addListener(new AnimatorListenerAdapter(){
            @Override
            public void onAnimationStart(Animator animation) {
                super.onAnimationStart(animation);
                fishDrawable.setFrequence(2f);
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                fishDrawable.setFrequence(1f);
            }
        });

        final PathMeasure pathMeasure = new PathMeasure(mPath, false);
        final float[] tan = new float[2];
        objectAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
//                animation.getAnimatedValue();
                // 执行了整个周期的百分之多少
                float fraction = animation.getAnimatedFraction();
                pathMeasure.getPosTan(pathMeasure.getLength() * fraction, null, tan);
                float angle = (float) Math.toDegrees(Math.atan2(-tan[1], tan[0]));
                fishDrawable.setFishMainAngle(angle);
            }
        });
        objectAnimator.start();
    }

    public float includeAngle(PointF O, PointF A, PointF B) {
        // cosAOB
        // OA*OB=(Ax-Ox)(Bx-Ox)+(Ay-Oy)*(By-Oy)
        float AOB = (A.x - O.x) * (B.x - O.x) + (A.y - O.y) * (B.y - O.y);
        float OALength = (float) Math.sqrt((A.x - O.x) * (A.x - O.x) + (A.y - O.y) * (A.y - O.y));
        // OB 的长度
        float OBLength = (float) Math.sqrt((B.x - O.x) * (B.x - O.x) + (B.y - O.y) * (B.y - O.y));
        float cosAOB = AOB / (OALength * OBLength);

        // 反余弦
        float angleAOB = (float) Math.toDegrees(Math.acos(cosAOB));

        // AB连线与X的夹角的tan值 - OB与x轴的夹角的tan值
        float direction = (A.y - B.y) / (A.x - B.x) - (O.y - B.y) / (O.x - B.x);

        if (direction == 0) {
            if (AOB >= 0) {
                return 0;
            } else {
                return 180;
            }
        } else {
            if (direction > 0) {
                return -angleAOB;
            } else {
                return angleAOB;
            }
        }

    }
    public float getRipple() {
        return ripple;
    }

    public void setRipple(float ripple) {
        alpha = 100 * (1 - ripple);
        this.ripple = ripple;
    }
}

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值