Android UI实践 —— 游动的锦鲤

11 篇文章 0 订阅

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

请添加图片描述

参考的原文章链接如下:
自定义Drawable实现灵动的红鲤鱼动画(上篇)
自定义Drawable实现灵动的红鲤鱼动画(下篇)

我的实现步骤为:

  1. 画出静态的鱼
  2. 鱼自身从头到尾摆动
  3. 手指点击屏幕时的水波纹效果
  4. 鱼朝着被点击位置游动

一、绘制静态鱼

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 的特殊情况),说一下三个点是怎么求的:

  1. 鱼头圆心到起始点的距离为 0.9R,二者连线与鱼头方向夹角为 110°,转换成与 x 轴的夹角就为 fishAngle - 110(左鱼鳍为 fishAngle + 110,顺时针旋转是减,逆时针加)在前面已经求出了鱼头圆心的情况下,直接带入 calculatePoint() 即可求出起始点。
  2. 起始点到结束点的长度就是鱼鳍的长度(已知),二者连线方向与鱼头方向刚好相反,那么与 x 轴夹角就为 fishAngle - 180,上一步刚求出起始点,同样带入 calculatePoint() 可以计算出结束点。
  3. 起始点到控制点长度已知,二者连线与鱼头方向夹角为 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);
    }

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

二、鱼自身的摆动效果

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

  1. 鱼头与 x 轴夹角角度
  2. 节肢 1 与 x 轴角度
  3. 节肢 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();
            }
        });

至此,锦鲤绘制基本完成。Java 与 Kotlin 版本的 Demo 代码均已上传 GitHub

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值