您的当前位置:首页正文

Android 高级UI解密 (四) :花式玩转贝塞尔曲线(波浪、轨迹变换动画)

2024-11-24 来源:个人技术集锦

讲解此UI系列必然少不了一个奇妙数学曲线—–贝塞尔曲线,它目前运用于App的范围是在太广了,最初的QQ气泡拖拽,到个人界面的波浪效果、Loading波浪效果,甚至于轨迹变化的动画都可以依赖贝塞尔曲线完成,多么完美的曲线,妙也!

此篇文章并不自己造轮子实现贝塞尔曲线,而是站在巨人的肩膀上,即Android原生为开发者封装好的相关方法:Path类的quadTo二阶贝塞尔曲线绘制方法和cubicTo三阶贝塞尔曲线绘制方法。咦,就这么两个方法足够吗?按理说简单的绘制足以,但是涉及到复杂的UI效果,例如想要获取到曲线上的点?只提供二、三阶贝塞尔曲线绘制方法,更高阶该如何?阅读完此篇文章,即可揭晓。来领略Path类的王牌之一:贝塞尔的传说吧~

(此系列文章知识点相对独立,可分开阅读,不过笔者建议按照顺序阅读,理解更加深入清晰)



此篇涉及到的知识点如下:

  • 贝塞尔曲线概念、构造、运用
  • 二阶、三阶贝塞尔曲线代码绘制
  • 使用贝塞尔实现波浪、轨迹变换动画

一. 初识贝塞尔

概念

贝塞尔曲线(在法语中发音为[bezje])是经常用于计算机图形学和相关领域的参数曲线。 Bézier曲线对更高维的推广称为Bézier曲面,其中Bézier三角形是一种特殊情况。简单来说,贝塞尔曲线就是可以用精确的数学公式来描述的一条曲线。例如上图所示的曲线,无法用“点”去形容,因为一条曲线上有无数个点。因此这也是贝塞尔曲线的秘密,用数学公式来描述一条曲线。

1. 贝塞尔曲线构造

(1)一阶贝塞尔曲线 Linear curves

线性贝塞尔曲线函数中的t可以被认为是描述 B(t) 从P0到P1的距离。

线性Bézier曲线是由两个点进行控制的,即描述出来的是简单的直线。例如,当t = 0.25时,B(t) 是从点P0到P1的四分之一。 当t从0变化到1时,B(t) 描述从P0到P1的直线。

(2)二阶贝塞尔曲线 Quadratic curves

在二阶贝塞尔曲线中除了起点P0和终点P2,还使用到了控制点P1。重点是构造了中间点Q0 和 Q1,描述贝塞尔曲线的B(t)函数随着Q0 和 Q1而变化。

t从0变化到1时:

  • 点Q0(t) 从P0变化到P1并描述线性Bézier曲线。
  • 点Q1(t) 从P1变化到P2并描述线性Bézier曲线。
  • B(t) 在Q0(t) 到Q1(t) 之间线性插值并描述二次Bézier曲线。

(3)高阶贝塞尔曲线 Higher-order curves

对于高阶曲线,则需要相应更多的中间点。

对于三阶曲线,除了起始点P0和终点P3,有2个控制点P1、P2,构造描述线性Bézier曲线的还有中间点Q0,Q1和Q2,以及描述二次Bézier曲线的点R0和R1。

三阶曲线相较于二阶曲线,控制点多了一个,中间点多了3个,稍显复杂,但是曲线的形状更加丰富。

对于四阶曲线,可以构造描述线性Bézier曲线的中间点Q0,Q1,Q2和Q3,描述二次Bézier曲线的点R0,R1和R2以及描述三次Bézier曲线的点S0和S1:


2. 贝塞尔曲线模拟生成

以上贝塞尔曲线阶层越高,其图像明显越复杂,但是都可以用数学公式来描述,可见数学之美。接下来研究重点放在常用的二阶、三阶贝塞尔曲线,一阶就是直线无需深究,而高于三阶的贝塞尔曲线稍复杂,代码实现其公式难度很大,普通需求一般不涉及,即使涉及到建议进行降阶操作,分解成多个二阶曲线。

根据,可在线模拟贝塞尔曲线的生成,即在下图中指定要绘制几个点,就可以确定绘制的是二阶曲线还是三阶,或者高阶。

例如下图中的二阶贝塞尔曲线,确定绘制3个点,分别在绘画板上指定出起点、控制点、终点,即可模拟生成二阶贝塞尔曲线:

例如下图中的三阶贝塞尔曲线,确定绘制4个点,分别在绘画板上指定出起点、控制点1、控制点2、终点,即可模拟生成三阶贝塞尔曲线:


3. 贝塞尔曲线在Android中的用处

  • 所有涉及曲线的图像都可以借助贝塞尔曲线来实现。例如可以代替VectorDrawable的PathWorking,因为它在兼容性上有些问题。
  • 可以将生硬的点到点之间的连接替换成圆滑的连接。
  • 可以模拟更加真实的动画效果。例如我们常见的波浪、轨迹变换动画等等。



二. 贝塞尔曲线代码实现

1. 二阶贝塞尔曲线实现

之前介绍的时候已经讲解过二阶贝塞尔曲线的构造重点:需要绘制3个点,起点P0、终点P2和控制点P1。代码实现步骤如下:

Android已提供绘制二阶贝塞尔曲线的方法quadTo(x,y,x2,y2),因此代码实现非常简单基础,如下:

public class SecondBezierView extends View {
    //起点
    private float mStartPointX;
    private float mStartPointY;
    //终点
    private float mEndPointX;
    private float mEndPointY;
    //控制点
    private float mFlagPointX;
    private float mFlagPointY;

    private Path mPath;
    private Paint mPaintBezier;
    private Paint mPaintFlag;
    private Paint mPaintFlagText;

    public SecondBezierView(Context context) {
        super(context);
    }

    public SecondBezierView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPaintBezier = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintBezier.setStrokeWidth(8);
        mPaintBezier.setStyle(Paint.Style.STROKE);

        mPaintFlag = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintFlag.setStrokeWidth(3);
        mPaintFlag.setStyle(Paint.Style.STROKE);

        mPaintFlagText = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintFlagText.setStyle(Paint.Style.STROKE);
        mPaintFlagText.setTextSize(20);
    }

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

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        //初始时确定起点、终点和控制点的坐标
        mStartPointX = w / 4;
        mStartPointY = h / 2 - 200;

        mEndPointX = w * 3 / 4;
        mEndPointY = h / 2 - 200;

        mFlagPointX = w / 2;
        mFlagPointY = h / 2 - 300;

        mPath = new Path();
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset();
        mPath.moveTo(mStartPointX, mStartPointY);
        mPath.quadTo(mFlagPointX, mFlagPointY, mEndPointX, mEndPointY);

        canvas.drawPoint(mStartPointX, mStartPointY, mPaintFlag);
        canvas.drawText("起点", mStartPointX, mStartPointY, mPaintFlagText);
        canvas.drawPoint(mEndPointX, mEndPointY, mPaintFlag);
        canvas.drawText("终点", mEndPointX, mEndPointY, mPaintFlagText);
        canvas.drawPoint(mFlagPointX, mFlagPointY, mPaintFlag);
        canvas.drawText("控制点", mFlagPointX, mFlagPointY, mPaintFlagText);
        canvas.drawLine(mStartPointX, mStartPointY, mFlagPointX, mFlagPointY, mPaintFlag);
        canvas.drawLine(mEndPointX, mEndPointY, mFlagPointX, mFlagPointY, mPaintFlag);

        canvas.drawPath(mPath, mPaintBezier);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_MOVE:
                mFlagPointX = event.getX();
                mFlagPointY = event.getY();
                invalidate();
                break;
        }
        return true;
    }
}

演示效果如下:


2. 三阶贝塞尔曲线实现

在第一大点中的分析可知,三阶贝塞尔曲线相较于二阶贝塞尔曲线需要绘制的起点、终点、一个控制点外,还多了一个控制点。因此在以上代码中需要增加一个控制点,仍旧是在void onSizeChanged(int w, int h, int oldw, int oldh)方法中初始化,后续步骤类似。

其重点有两个:

  • onDraw方法中调用Path指定贝塞尔曲线轨迹,此处不再是quadTo方法,而是cubicTo方法,即需要传入2个控制点和终点的坐标。
  • 重新修改boolean onTouchEvent(MotionEvent event)方法,在二阶曲线中只需要修改一个控制点的坐标,但此处是2个控制点,我们将它制定为多点触控,即检测同时MotionEvent.ACTION_MASK)。在MotionEvent.ACTION_MOVE的状态下修改控制点的坐标时,用户可能没有多点触控,即只使用一根手指在操作,因此需要判断多点触控下的第二个控制点的坐标修改,此处用一个标识符来标志是否出发多点触控。

(注意多点触控下调用获取坐标方法event.getX(1)需要添加参数)

重点代码如下,完整代码末尾提供。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction() & MotionEvent.ACTION_MASK) {
            case MotionEvent.ACTION_POINTER_DOWN:
                isSecondPoint = true;
                break;
            case MotionEvent.ACTION_POINTER_UP:
                isSecondPoint = false;
                break;
            case MotionEvent.ACTION_MOVE:
                mFlagPointOneX = event.getX(0);
                mFlagPointOneY = event.getY(0);
                if (isSecondPoint) {
                    mFlagPointTwoX = event.getX(1);
                    mFlagPointTwoY = event.getY(1);
                }
                invalidate();
                break;
        }
        return true;
    }

效果如下,由于模拟器上无法出发多点触碰,因此此处演示只有控制点1移动。




三. 玩转贝塞尔曲线

1. 波浪效果

波浪效果的实现关键就是借助波浪的周期性规律和ValueAnimator位移偏量0到波长间重复的变化,通过不断的位移变化,达到波浪流动的效果。

波浪效果比较常见使用,例如个人界面的头像展示处。实现该效果有两种方式,第一种就是运用三角函数公式 sin(x),第二种就是本篇讲解的贝塞尔曲线,2个二阶的贝塞尔可以实现一个完整波浪,即sin(x)的一个周期,在绘制周期性的波形就不在话下了。

(1)实现一个完整波浪

首先实现2个二阶贝塞尔曲线,即一个周期的波浪,此处设定为半波长距离为400,连续调用两次quadTo方法即可绘制出,重点代码如下:

mPath.moveTo(0, mStartPointY);
mPath.quadTo(200, mStartPointY-300, 400, mStartPointY);
mPath.quadTo(600, mStartPointY+300, 800, mStartPointY);

效果图如下:

接下来的任务是让波浪填满屏幕宽度,且流动起来。因此需要弄清两个问题:屏幕宽度可以容纳几个周期波浪,填满屏幕后每次波浪位移量多少?通过改变波浪的横坐标来达到让波浪流动的效果。

  • 前者获取屏幕宽度计算即可。
  • 后者只需增加二阶贝塞尔曲线的起点、终点、控制点横坐标位移量即可,使其呈现出流动的动画效果。但需要注意当波浪向右侧流动时,屏幕左侧之外应当也有波形,使得波形准备向右侧移动一个弦长的距离时,屏幕左侧之外也有一个弦长的波形准备移动进来,不然波浪之间则会断开。

(2)将波浪填满屏幕

  1. 首先在自定义View的构造方法中定义一个完整的波浪长度为800,即包括上圆拱和下圆拱部分。
  2. 在初始定义View大小的void onSizeChanged(int w, int h, int oldw, int oldh)方法中获取屏幕宽度计算填满屏幕需要几个完整波长。注意此处在计算时不可直接简单为mScreenWidth / mWaveLength,首先考虑到除法操作后结果为Double类型,再赋值给int类型count,因此避免舍位带来的误差,需要在除法过后加上0.5,而且之前一直在强调,屏幕左侧之外也应当有一个弦长的波形准备移动进来,因此再加上1,最后公式为mWaveCount = (int) Math.round(mScreenWidth / mWaveLength + 1.5);
  3. 因此绘制时第一个波形的起点是屏幕外侧的 -波形长坐标,调用mPath。move确定好起点后,循环mWaveCount数量绘制波形,每次循环绘制一个波形,即之前讲过的调用两次quadTo方法。
    1. 绘制第一个半个波形即一个二阶贝塞尔曲线时,起点已确定了,控制点的X坐标就是-3/4弦长处,Y坐标之前自定义为屏幕长度一半,此处还需要加上偏移量60(这里笔者自定义设置为60,即波浪移动的距离);而终点的X坐标就是-1/2弦长处,,Y坐标之前自定义为屏幕长度一半。注意以上两个点的X横坐标还要加上i * mWaveLength,因为在循环里绘制,呈周期变化的波浪。
    2. 绘制第二个 半波形时则更加简单,第一个控制点的X坐标为-1/4弦长处,Y坐标则是之前自定义为屏幕长度一半,注意此处是减去偏移量60;而终点X坐标为0,Y坐标为之前自定义为屏幕长度一半。注意以上两个点的X横坐标也要加上i * mWaveLength
    3. 循环绘制波形完后,最后完善一下绘制两条直线,如下图所示的绿线,将这个波浪图形封闭起来,给此封闭图形填充色彩,使效果更加明显。

(3)动画将波浪动起来

在实现了以上效果后,只剩下一个需求,就是使用动画ValueAnimation将波浪动起来~并设置一个插值器控制坐标点的偏移,实现动画效果。

  1. 设置成员变量offset,当我们点击自定义View时,产生偏移量触发动画效果。
  2. onClick事件中创建属性动画,首先创建ValueAnimator对象的位置移动属性ValueAnimator.ofInt(0, mWaveLength);,再设置该对象的属性setRepeatCount(ValueAnimator.INFINITE)重复显示动画、setInterpolator(new LinearInterpolator())设置线性插值器,最后添加addUpdateListener变化的监听事件,这也是控制动画变化的基本方式:在实现的onAnimationUpdate(ValueAnimator valueAnimator)方法中通过valueAnimator.getAnimatedValue()获取offset的值,调用invalidate()刷新使得自定义View可以产生offset的偏移。

借助波形图的周期性和动画ValueAnimator,设置offset从0到mWaveLength发生变化,使得波形图一直在向右移动。当它移动完一个波长,即一个周期后,恢复到初始状态重复移动,从而达到波浪的动画效果。

注意这个关键的offset变量,它的数值时不断的从0到mWaveLength发生变化,也是波形图移动的重点,因此将它运用到onDraw绘制方法中,即循环里绘制贝塞尔曲线时到控制点和终点,这两个点到横坐标都加上offset即可!

(4)完整代码与效果展示

以下就是实现波浪效果的完整代码,注意实现关键就是借助波浪的周期性规律和ValueAnimator位移偏量0到波长不断的变化。实现并不复杂,如下:

public class WaveBezierView extends View implements View.OnClickListener {
    private Path mPath;

    private Paint mPaintBezier;

    private int mWaveLength;
    private int mScreenHeight;
    private int mScreenWidth;
    private int mCenterY;
    private int mWaveCount;

    private ValueAnimator mValueAnimator;
    //波浪流动X轴偏移量
    private int mOffsetX;
    //波浪升起Y轴偏移量
    private int mOffsetY;
    private int count = 0;

    public WaveBezierView(Context context) {
        super(context);
    }

    public WaveBezierView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPaintBezier = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintBezier.setColor(Color.LTGRAY);
        mPaintBezier.setStrokeWidth(8);
        mPaintBezier.setStyle(Paint.Style.FILL_AND_STROKE);

        mWaveLength = 800;
    }

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

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mPath = new Path();
        setOnClickListener(this);

        mScreenHeight = h;
        mScreenWidth = w;
        mCenterY = h / 2;//设定波浪在屏幕中央处显示

        //此处多加1,是为了预先加载屏幕外的一个波浪,持续报廊移动时的连续性
        mWaveCount = (int) Math.round(mScreenWidth / mWaveLength + 1.5);
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset();
        //Y坐标每次绘制时减去偏移量,即波浪升高
        mPath.moveTo(-mWaveLength + mOffsetX, mCenterY);
        //每次循环绘制两个二阶贝塞尔曲线形成一个完整波形(含有一个上拱圆,一个下拱圆)
        for (int i = 0; i < mWaveCount; i++) {
            //此处的60是指波浪起伏的偏移量,自定义为60
           /*
            mPath.quadTo(-mWaveLength * 3 / 4 + i * mWaveLength + mOffsetX, mCenterY + 60, -mWaveLength / 2 + i * mWaveLength + mOffset, mCenterY);
            mPath.quadTo(-mWaveLength / 4 + i * mWaveLength + mOffsetX, mCenterY - 60, i * mWaveLength + mOffset, mCenterY);
            */
            //第二种写法:相对位移
            mPath.rQuadTo(mWaveLength / 4, -60, mWaveLength / 2, 0);
            mPath.rQuadTo(mWaveLength / 4, +60, mWaveLength / 2, 0);

        }
        mPath.lineTo(mScreenWidth, mScreenHeight);
        mPath.lineTo(0, mScreenHeight);
        mPath.close();
        canvas.drawPath(mPath, mPaintBezier);
    }

    @Override
    public void onClick(View view) {
        //设置动画运动距离
        mValueAnimator = ValueAnimator.ofInt(0, mWaveLength);
        mValueAnimator.setDuration(1000);
        //设置播放数量无限循环
        mValueAnimator.setRepeatCount(ValueAnimator.INFINITE);
//        mValueAnimator.setRepeatCount(1);
        //设置线性运动的插值器
        mValueAnimator.setInterpolator(new LinearInterpolator());
        mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                //获取偏移量,绘制波浪曲线的X横坐标加上此偏移量,产生移动效果
                mOffsetX = (int) valueAnimator.getAnimatedValue();
                count++;

                invalidate();
            }
        });
        mValueAnimator.start();
    }

显示效果如下:

(5)波浪效果拓展—–波浪升起

在实现以上波浪效果后,突发奇想其实有的Loading动画也是利用波浪,根据百分比计算波浪升起的高度,以波浪占满Loading图形的比例来判断加载的进度。至于Loading图形使用Canvas即可解决,类似圆形头像的实现,有裁剪等多种方式。此处的重点就是使波浪升起,也就是随着动画动态修改Path的moveTo方法中的Y坐标,方法如下:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset();
        //判断波浪升起偏移量
        if(mOffsetY<mCenterY+60){
            mOffsetY += 10;
        }
        //Y坐标每次绘制时减去偏移量,即波浪升高
        mPath.moveTo(-mWaveLength + mOffsetX, mCenterY-mOffsetY);

细心提示:笔者在简单修改以上代码后,Genymotion模拟器实现了理想中的效果,正得意洋洋喝口水,随便浏览浏览知乎segmentFault掘金时,笔记本的小电扇开始疯狂的旋转,电脑升温,颇有三星Note7的feel~笔者忽然发现程序还在运行,虽然屏幕显示的是全灰色,即波浪盖过的效果,按理说不应该哎,是电脑日常抽疯?

细细一想~不对!程序看似已经演示完毕,其实不然!之前设定的动画播放数次是无限循环,虽然屏幕已经全灰色,即mOffsetY的值经过判断不再增长,但是它依然在无限次绘制最后一次波浪盖过的全屏灰色!这意味着还在重复渲染相同的UI界面,这种不必要的操作在大量消耗内存。笔者恍然大悟,如此愚蠢的问题不被得测试的怼死,哦不,也不一定,他们的KPI因为你而有保障了:)

好了,不废话了,作为一个合格的猿,波浪慢慢盖过屏幕这是一个有限的过程,修改也很简单,在判断波浪升起偏移量不再增加时,即已经绘制满屏了就取消动画!

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset();
        //判断波浪升起偏移量
        if(mOffsetY<mCenterY+60){
            mOffsetY += 10;
        }else {
            //绘制满屏时,即升起的波浪沾满屏幕时,取消动画重复绘制
            mValueAnimator.cancel();
        }

以上即可减少不必要的UI绘制,哼哼,增加自己的KPI才是硬道理~

(此处只是起一个抛砖引玉的效果,各位想要实现理想的UI效果,还要在提供演示的简单Demo做许多细节性的修改)



2. 轨迹变换动画

贝塞尔曲线的另一大优势就是模拟运动轨迹,再搭配动画中的插值器对于速度的控制,可以达到更加真实的效果,例如加入购物车时的抛物轨迹动画效果。

实现此效果最先面临的问题是Android只为开发者提供了有关贝塞尔曲线绘制的quadTocubicTo方法,但是并没有任何获取曲线中点的相关方法,这意味着无法通过贝塞尔曲线去设置物体的运动轨迹,或动画的运动轨迹!

幸运的是前人已经研究出来获取贝塞尔曲线上的点,算法比较复杂,公式如下。后续又有人根据公式推理出简化版,通过这些公式可以获取贝塞尔曲线上任何一个点的坐标。

()

(1)代码实现De Casteljau算法工具类

以上得知二阶贝塞尔曲线公式后,用代码来实现工具类,以二阶贝塞尔曲线为例,传入方法的参数为起始点p0、控制点p1、终止点p2之外,还需要传入曲线长度比例t,即可获得t 对应的点坐标。代码如下:

public class BezierUtil {

    /**
     * 二阶贝塞尔曲线B(t) = (1 - t)^2 * P0 + 2t * (1 - t) * P1 + t^2 * P2, t ∈ [0,1]
     *
     * @param t  曲线长度比例
     * @param p0 起始点
     * @param p1 控制点
     * @param p2 终止点
     * @return t对应的点
     */
    public static PointF CalculateBezierPointForQuadratic(float t, PointF p0, PointF p1, PointF p2) {
        PointF point = new PointF();
        float temp = 1 - t;
        point.x = temp * temp * p0.x + 2 * t * temp * p1.x + t * t * p2.x;
        point.y = temp * temp * p0.y + 2 * t * temp * p1.y + t * t * p2.y;
        return point;
    }

    /**
     * 三阶贝塞尔曲线B(t) = P0 * (1-t)^3 + 3 * P1 * t * (1-t)^2 + 3 * P2 * t^2 * (1-t) + P3 * t^3, t ∈ [0,1]
     *
     * @param t  曲线长度比例
     * @param p0 起始点
     * @param p1 控制点1
     * @param p2 控制点2
     * @param p3 终止点
     * @return t对应的点
     */
    public static PointF CalculateBezierPointForCubic(float t, PointF p0, PointF p1, PointF p2, PointF p3) {
        PointF point = new PointF();
        float temp = 1 - t;
        point.x = p0.x * temp * temp * temp + 3 * p1.x * t * temp * temp + 3 * p2.x * t * t * temp + p3.x * t * t * t;
        point.y = p0.y * temp * temp * temp + 3 * p1.y * t * temp * temp + 3 * p2.y * t * t * temp + p3.y * t * t * t;
        return point;
    }
}

(2)绘制二阶贝塞尔路径曲线

在后续实现中可以通过Android的API绘制出的贝塞尔曲线和该公式作对比,若此公式正确,则获取到的点坐标都应该落在曲线上。

首先通过Android的API绘制出贝塞尔曲线路径,此实现异常简单,即绘制一条二阶贝塞尔曲线,效果如下:

(3)绘制轨迹变换

如上效果图,绘制出路径曲线后,接下来要实现的关键是模拟小球从曲线起点滑倒曲线终点处的动画效果。

1 . 这里模拟的小球就用Circle实现,为了完善效果,贝塞尔曲线的起、终点坐标都绘制一个小圆圈。
2 . 实现ValueAnimator动画的BezierEvaluator

创建BezierEvaluator类实现TypeEvaluator方法,传入PointF。创建一个构造方法用来接收控制点PointF,在实现的evaluate(float v, PointF pointF, PointF t1)中查看一个长度比例参数t、2个Point,正好加上构造方法中的Point,就可以调用工具类中的二阶贝塞尔函数公式方法

public class BezierEvaluator implements TypeEvaluator<PointF> {

    private PointF mFlagPoint;

    public BezierEvaluator(PointF flagPoint) {
        mFlagPoint = flagPoint;
    }

    @Override
    public PointF evaluate(float v, PointF pointF, PointF t1) {
        return BezierUtil.CalculateBezierPointForQuadratic(v, pointF, mFlagPoint, t1);
    }
}

3 . 一切准备工作完毕,接下来就是设置动画了:

  • 创建BezierEvaluator并传入控制点。
  • 调用ValueAnimator对象的ofObject方法,传入BezierEvaluator对象和起点PointF、终点PointF,从而创建ValueAnimator对象。样我们就自定义好了小球动画运动的轨迹
  • 接下来的工作就更加简单了,调用ValueAnimator对象的addUpdateListener控制动画过程中小球的运动轨迹,在创建ValueAnimator的时候已设置好PointF的从起点到终点运动轨迹,再通过(PointF) valueAnimator.getAnimatedValue()获取运动时的PointF,赋值给模拟小球的坐标,调用invalidate()刷新界面。
  • 设置一个AccelerateDecelerateInterpolator加速的插值器。(可自行选择适合的插值器)
    4 . 最后在onDraw方法中Canvas绘制出移动小球的Circle。

(4)完整代码和效果展示

BezierUtil类和BezierEvaluator的代码以上已展示,以下展示的是自定义PathBezierView的完整代码:

public class PathBezierView extends View implements View.OnClickListener{
    //起点
    private int mStartPointX;
    private int mStartPointY;
    //终点
    private int mEndPointX;
    private int mEndPointY;
    //控制点
    private int mFlagPointX;
    private int mFlagPointY;
    //移动小球
    private int mMovePointX;
    private int mMovePointY;

    private Path mPath;
    private Paint mPaintPath;
    private Paint mPaintCircle;

    public PathBezierView(Context context) {
        super(context);
    }

    public PathBezierView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPath = new Path();
        mPaintPath = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintPath.setStyle(Paint.Style.STROKE);
        mPaintPath.setStrokeWidth(8);
        mPaintCircle = new Paint(Paint.ANTI_ALIAS_FLAG);

        mStartPointX = 100;
        mStartPointY = 100;

        //小球刚开始位置在起点
        mMovePointX = mStartPointX;
        mMovePointY = mStartPointY;

        mEndPointX = 600;
        mEndPointY = 600;

        mFlagPointX = 500;
        mFlagPointY = 0;

        setOnClickListener(this);
    }

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

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawCircle(mStartPointX, mStartPointY, 20, mPaintCircle);
        canvas.drawCircle(mEndPointX, mEndPointY, 20, mPaintCircle);
        canvas.drawCircle(mMovePointX, mMovePointY, 20, mPaintCircle);

        mPath.reset();
        //绘制贝塞尔曲线,即运动路径
        mPath.moveTo(mStartPointX, mStartPointY);
        mPath.quadTo(mFlagPointX, mFlagPointY, mEndPointX, mEndPointY);
        canvas.drawPath(mPath, mPaintPath);
    }

    @Override
    public void onClick(View view) {
        //创建贝塞尔曲线坐标的换算类
        BezierEvaluator evaluator = new BezierEvaluator(new PointF(mFlagPointX, mFlagPointY));
        //指定动画移动轨迹
        ValueAnimator animator = ValueAnimator.ofObject(evaluator,
                new PointF(mStartPointX, mStartPointY),
                new PointF(mEndPointX, mEndPointY));
        animator.setDuration(600);
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                //改变小球坐标,产生运动效果
                PointF pointF = (PointF) valueAnimator.getAnimatedValue();
                mMovePointX = (int) pointF.x;
                mMovePointY = (int) pointF.y;
                //刷新UI
                invalidate();
            }
        });
        //添加加速插值器,模拟真实物理效果
        animator.setInterpolator(new AccelerateDecelerateInterpolator());
        animator.start();
    }
}

效果展示如下,可以发现小球动画通过BezierEvaluator计算出的运动轨迹和刚开始绘制的二阶贝塞尔曲线完全符合,因此可以证明前人推理出的公式definitely ok~ 效果完成,可以愉快的和pm交代了,hhhh


3. 小结

(1)波浪动画

将波浪动画拆分成“波浪”、“动画”来依次实现:

  • 实现波浪图形可借助波浪的周期性规律,首先完成一个周期的波浪绘制,可采用sin(x)函数或者贝塞尔曲线。此处选择后者,2个二阶贝塞尔曲线就是一个周期形波浪。注意在将波浪绘制填满屏幕计算波浪数量时,需要多绘制屏幕外的一个,这是为了后续实现波浪流动动画时,避免出现不连续的情况。
  • 实现让波浪流动的效果,实质上就是改变其X横坐标位移量。因此采用ValueAnimator的线形位移即可,在调用ofInt确定移动距离时仍利用到波浪的周期性规律,距离就是一个完整波长。最终再设置动画重复播放,这样在动画监听事件中获取到不断从[0, 波长]变换位移量,将此位移量offset相加至onDraw方法中绘制贝塞尔曲线的X横坐标上,调用invalidate()方法刷新界面,即可实现波浪流动的效果。

(2)轨迹变换动画

此效果的实现关键在于动画,轨迹图形绘制一个简单的二阶贝塞尔曲线即可实现,可是在使用ValueAnimator动画时无法像波浪效果一样线性地去移动贝塞尔曲线此处需要按照贝塞尔曲线的轨迹移动一个模拟Circle,因此重难点就在于如何获取曲线上的各个点,即描述该曲线的公式。

后续发现前人已经推理出二阶、三阶贝塞尔曲线公式,此处使用代码实现并封装了一个BezierEvaluator转换类,调用ValueAnimator的ofObject传入,即可实现我们自定义的动画运动轨迹,再再监听动画改变的事件中修改模拟小球的XY坐标,调用invalidate()方法刷新界面,即可实现轨迹运动效果。




若有错误,虚心指教~

显示全文