貝塞爾曲線開發(fā)的藝術(shù)

貝塞爾曲線開發(fā)的藝術(shù)

一句話概括貝塞爾曲線:將任意一條曲線轉(zhuǎn)化為精確的數(shù)學(xué)公式。

很多繪圖工具中的鋼筆工具,就是典型的貝塞爾曲線的應(yīng)用,這里的一個網(wǎng)站可以在線模擬鋼筆工具的使用:

http://bezier.method.ac/

2.png

貝塞爾曲線中有一些比較關(guān)鍵的名詞,解釋如下:

  • 數(shù)據(jù)點(diǎn):通常指一條路徑的起始點(diǎn)和終止點(diǎn)
  • 控制點(diǎn):控制點(diǎn)決定了一條路徑的彎曲軌跡,根據(jù)控制點(diǎn)的個數(shù),貝塞爾曲線被分為一階貝塞爾曲線(0個控制點(diǎn))、二階貝塞爾曲線(1個控制點(diǎn))、三階貝塞爾曲線(2個控制點(diǎn))等等。

要想對貝塞爾曲線有一個比較好的認(rèn)識,可以參考WIKI上的鏈接:

https://en.wikipedia.org/wiki/B%C3%A9zier_curve

1.png

貝塞爾曲線模擬

在Android中,一般來說,開發(fā)者只考慮二階貝塞爾曲線和三階貝塞爾曲線,SDK也只提供了二階和三階的API調(diào)用。對于再高階的貝塞爾曲線,通??梢詫⑶€拆分成多個低階的貝塞爾曲線,也就是所謂的降階操作。下面將通過代碼來模擬二階和三階的貝塞爾曲線是如何繪制和控制的。

貝塞爾曲線的一個比較好的動態(tài)演示如下所示:

http://myst729.github.io/bezier-curve/

20.png

二階模擬

二階貝塞爾曲線在Android中的API為:quadTo()和rQuadTo(),這兩個API在原理上是可以互相轉(zhuǎn)換的——quadTo是基于絕對坐標(biāo),而rQuadTo是基于相對坐標(biāo),所以后面我都只以其中一個來進(jìn)行講解。

先來看下最終的效果:

3.gif

從前面的介紹可以知道,二階貝塞爾曲線有兩個數(shù)據(jù)點(diǎn)和一個控制點(diǎn),只需要在代碼中繪制出這些輔助點(diǎn)和輔助線即可,同時,控制點(diǎn)可以通過onTouchEvent來進(jìn)行傳遞。

package com.xys.animationart.views;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;

/**
 * 二階貝塞爾曲線
 * <p/>
 * Created by xuyisheng on 16/7/11.
 */
public class SecondOrderBezier extends View {

    private Paint mPaintBezier;
    private Paint mPaintAuxiliary;
    private Paint mPaintAuxiliaryText;

    private float mAuxiliaryX;
    private float mAuxiliaryY;

    private float mStartPointX;
    private float mStartPointY;

    private float mEndPointX;
    private float mEndPointY;

    private Path mPath = new Path();

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

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

        mPaintAuxiliary = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintAuxiliary.setStyle(Paint.Style.STROKE);
        mPaintAuxiliary.setStrokeWidth(2);

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

    public SecondOrderBezier(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 / 4 * 3;
        mEndPointY = h / 2 - 200;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset();
        mPath.moveTo(mStartPointX, mStartPointY);
        // 輔助點(diǎn)
        canvas.drawPoint(mAuxiliaryX, mAuxiliaryY, mPaintAuxiliary);
        canvas.drawText("控制點(diǎn)", mAuxiliaryX, mAuxiliaryY, mPaintAuxiliaryText);
        canvas.drawText("起始點(diǎn)", mStartPointX, mStartPointY, mPaintAuxiliaryText);
        canvas.drawText("終止點(diǎn)", mEndPointX, mEndPointY, mPaintAuxiliaryText);
        // 輔助線
        canvas.drawLine(mStartPointX, mStartPointY, mAuxiliaryX, mAuxiliaryY, mPaintAuxiliary);
        canvas.drawLine(mEndPointX, mEndPointY, mAuxiliaryX, mAuxiliaryY, mPaintAuxiliary);
        // 二階貝塞爾曲線
        mPath.quadTo(mAuxiliaryX, mAuxiliaryY, mEndPointX, mEndPointY);
        canvas.drawPath(mPath, mPaintBezier);
    }

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

三階模擬

三階貝塞爾曲線在Android中的API為:cubicTo()和rCubicTo(),這兩個API在原理上是可以互相轉(zhuǎn)換的——quadTo是基于絕對坐標(biāo),而rCubicTo是基于相對坐標(biāo),所以后面我都只以其中一個來進(jìn)行講解。

有了二階的基礎(chǔ),再來模擬三階就非常簡單了,無非是增加了一個控制點(diǎn)而已,先看下效果圖:

4.gif

代碼只需要在二階的基礎(chǔ)上添加一些輔助點(diǎn)即可,下面只給出一些關(guān)鍵代碼,詳細(xì)代碼請參考Github:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset();
        mPath.moveTo(mStartPointX, mStartPointY);
        // 輔助點(diǎn)
        canvas.drawPoint(mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliary);
        canvas.drawText("控制點(diǎn)1", mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliaryText);
        canvas.drawText("控制點(diǎn)2", mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliaryText);
        canvas.drawText("起始點(diǎn)", mStartPointX, mStartPointY, mPaintAuxiliaryText);
        canvas.drawText("終止點(diǎn)", mEndPointX, mEndPointY, mPaintAuxiliaryText);
        // 輔助線
        canvas.drawLine(mStartPointX, mStartPointY, mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliary);
        canvas.drawLine(mEndPointX, mEndPointY, mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliary);
        canvas.drawLine(mAuxiliaryOneX, mAuxiliaryOneY, mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliary);
        // 三階貝塞爾曲線
        mPath.cubicTo(mAuxiliaryOneX, mAuxiliaryOneY, mAuxiliaryTwoX, mAuxiliaryTwoY, mEndPointX, mEndPointY);
        canvas.drawPath(mPath, mPaintBezier);
    }

模擬網(wǎng)頁

如下所示的網(wǎng)頁,模擬了三階貝塞爾曲線的繪制,可以通過拖動曲線來獲取兩個控制點(diǎn)的坐標(biāo),而起始點(diǎn)分別是(0,0)和(1,1)。

http://cubic-bezier.com/

16.png

通過這個網(wǎng)頁,也可以比較方便的獲取三階貝塞爾曲線的控制點(diǎn)坐標(biāo)。

貝塞爾曲線應(yīng)用

圓滑繪圖

當(dāng)在屏幕上繪制路徑時,例如手寫板,最基本的方法是通過Path.lineTo將各個觸點(diǎn)連接起來,而這種方式在很多時候會發(fā)現(xiàn),兩個點(diǎn)的連接是非常生硬的,因為它畢竟是通過直線來連接的,如果通過二階貝塞爾曲線來將各個觸點(diǎn)連接,就會圓滑的多,不會出現(xiàn)太多的生硬連接。

先來看下代碼,非常簡單的繪制路徑代碼:

package com.xys.animationart.views;

import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;

/**
 * 圓滑路徑
 * <p/>
 * Created by xuyisheng on 16/7/19.
 */
public class DrawPadBezier extends View {

    private float mX;
    private float mY;
    private float offset = ViewConfiguration.get(getContext()).getScaledTouchSlop();

    private Paint mPaint;
    private Path mPath;

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

    public DrawPadBezier(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPath = new Path();
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setStyle(Paint.Style.STROKE);
        mPaint.setStrokeWidth(5);
        mPaint.setColor(Color.RED);
    }

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

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mPath.reset();
                float x = event.getX();
                float y = event.getY();
                mX = x;
                mY = y;
                mPath.moveTo(x, y);
                break;
            case MotionEvent.ACTION_MOVE:
                float x1 = event.getX();
                float y1 = event.getY();
                float preX = mX;
                float preY = mY;
                float dx = Math.abs(x1 - preX);
                float dy = Math.abs(y1 - preY);
                if (dx >= offset || dy >= offset) {
                    // 貝塞爾曲線的控制點(diǎn)為起點(diǎn)和終點(diǎn)的中點(diǎn)
                    float cX = (x1 + preX) / 2;
                    float cY = (y1 + preY) / 2;
//                    mPath.quadTo(preX, preY, cX, cY);
                    mPath.lineTo(x1, y1);
                    mX = x1;
                    mY = y1;
                }
        }
        invalidate();
        return true;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawPath(mPath, mPaint);
    }
}

先來看下通過mPath.lineTo來實現(xiàn)的繪圖,效果如下所示:

18.png

圖片中的拐點(diǎn)有明顯的鋸齒效果,即通過直線的連接,再來看下通過貝塞爾曲線來連接的效果,通常情況下,貝塞爾曲線的控制點(diǎn)取兩個連續(xù)點(diǎn)的中點(diǎn):

mPath.quadTo(preX, preY, cX, cY);

通過二階貝塞爾曲線的連接效果如圖所示:

19.png

可以明顯的發(fā)現(xiàn),曲線變得更加圓滑了。

曲線變形

通過控制貝塞爾曲線的控制點(diǎn),就可以實現(xiàn)對一條路徑的修改。所以,利用貝塞爾曲線,可以實現(xiàn)很多的路徑動畫,例如:

5.gif
package com.xys.animationart;

import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.BounceInterpolator;

/**
 * 曲線變形
 * <p/>
 * Created by xuyisheng on 16/7/11.
 */
public class PathMorphBezier extends View implements View.OnClickListener{

    private Paint mPaintBezier;
    private Paint mPaintAuxiliary;
    private Paint mPaintAuxiliaryText;

    private float mAuxiliaryOneX;
    private float mAuxiliaryOneY;
    private float mAuxiliaryTwoX;
    private float mAuxiliaryTwoY;

    private float mStartPointX;
    private float mStartPointY;

    private float mEndPointX;
    private float mEndPointY;

    private Path mPath = new Path();
    private ValueAnimator mAnimator;

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

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

        mPaintAuxiliary = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintAuxiliary.setStyle(Paint.Style.STROKE);
        mPaintAuxiliary.setStrokeWidth(2);

        mPaintAuxiliaryText = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaintAuxiliaryText.setStyle(Paint.Style.STROKE);
        mPaintAuxiliaryText.setTextSize(20);
        setOnClickListener(this);
    }

    public PathMorphBezier(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 / 4 * 3;
        mEndPointY = h / 2 - 200;

        mAuxiliaryOneX = mStartPointX;
        mAuxiliaryOneY = mStartPointY;
        mAuxiliaryTwoX = mEndPointX;
        mAuxiliaryTwoY = mEndPointY;

        mAnimator = ValueAnimator.ofFloat(mStartPointY, (float) h);
        mAnimator.setInterpolator(new BounceInterpolator());
        mAnimator.setDuration(1000);
        mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                mAuxiliaryOneY = (float) valueAnimator.getAnimatedValue();
                mAuxiliaryTwoY = (float) valueAnimator.getAnimatedValue();
                invalidate();
            }
        });
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset();
        mPath.moveTo(mStartPointX, mStartPointY);
        // 輔助點(diǎn)
        canvas.drawPoint(mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliary);
        canvas.drawText("輔助點(diǎn)1", mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliaryText);
        canvas.drawText("輔助點(diǎn)2", mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliaryText);
        canvas.drawText("起始點(diǎn)", mStartPointX, mStartPointY, mPaintAuxiliaryText);
        canvas.drawText("終止點(diǎn)", mEndPointX, mEndPointY, mPaintAuxiliaryText);
        // 輔助線
        canvas.drawLine(mStartPointX, mStartPointY, mAuxiliaryOneX, mAuxiliaryOneY, mPaintAuxiliary);
        canvas.drawLine(mEndPointX, mEndPointY, mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliary);
        canvas.drawLine(mAuxiliaryOneX, mAuxiliaryOneY, mAuxiliaryTwoX, mAuxiliaryTwoY, mPaintAuxiliary);
        // 三階貝塞爾曲線
        mPath.cubicTo(mAuxiliaryOneX, mAuxiliaryOneY, mAuxiliaryTwoX, mAuxiliaryTwoY, mEndPointX, mEndPointY);
        canvas.drawPath(mPath, mPaintBezier);
    }

    @Override
    public void onClick(View view) {
        mAnimator.start();
    }
}

這里就是簡單的改變二階貝塞爾曲線的控制點(diǎn)來實現(xiàn)曲線的變形。

網(wǎng)上一些比較復(fù)雜的變形動畫效果,也是基于這種實現(xiàn)方式,其原理都是通過改變控制點(diǎn)的位置,從而達(dá)到對圖形的變換,例如圓形到心形的變化、圓形到五角星的變換,等等。

波浪效果

波浪的繪制是貝塞爾曲線一個非常簡單的應(yīng)用,而讓波浪進(jìn)行波動,其實并不需要對控制點(diǎn)進(jìn)行改變,而是可以通過位移來實現(xiàn),這里我們是借助貝塞爾曲線來實現(xiàn)波浪的繪制效果,效果如圖所示:

6.gif
package com.xys.animationart.views;

import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.LinearInterpolator;

/**
 * 波浪圖形
 * <p/>
 * Created by xuyisheng on 16/7/11.
 */
public class WaveBezier extends View implements View.OnClickListener {

    private Paint mPaint;
    private Path mPath;
    private int mWaveLength = 1000;
    private int mOffset;
    private int mScreenHeight;
    private int mScreenWidth;
    private int mWaveCount;
    private int mCenterY;

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

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

    public WaveBezier(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPath = new Path();
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPaint.setColor(Color.LTGRAY);
        mPaint.setStyle(Paint.Style.FILL_AND_STROKE);
        setOnClickListener(this);
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mScreenHeight = h;
        mScreenWidth = w;
        mWaveCount = (int) Math.round(mScreenWidth / mWaveLength + 1.5);
        mCenterY = mScreenHeight / 2;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset();
        mPath.moveTo(-mWaveLength + mOffset, mCenterY);
        for (int i = 0; i < mWaveCount; i++) {
            // + (i * mWaveLength)
            // + mOffset
            mPath.quadTo((-mWaveLength * 3 / 4) + (i * mWaveLength) + mOffset, mCenterY + 60, (-mWaveLength / 2) + (i * mWaveLength) + mOffset, mCenterY);
            mPath.quadTo((-mWaveLength / 4) + (i * mWaveLength) + mOffset, mCenterY - 60, i * mWaveLength + mOffset, mCenterY);
        }
        mPath.lineTo(mScreenWidth, mScreenHeight);
        mPath.lineTo(0, mScreenHeight);
        mPath.close();
        canvas.drawPath(mPath, mPaint);
    }

    @Override
    public void onClick(View view) {
        ValueAnimator animator = ValueAnimator.ofInt(0, mWaveLength);
        animator.setDuration(1000);
        animator.setRepeatCount(ValueAnimator.INFINITE);
        animator.setInterpolator(new LinearInterpolator());
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mOffset = (int) animation.getAnimatedValue();
                postInvalidate();
            }
        });
        animator.start();
    }
}

波浪動畫實際上并不復(fù)雜,但三角函數(shù)確實對一些開發(fā)者比較困難,開發(fā)者可以通過下面的這個網(wǎng)站來模擬三角函數(shù)圖像的繪制:

https://www.desmos.com/calculator

17.png

路徑動畫

貝塞爾曲線的另一個非常常用的功能,就是作為動畫的運(yùn)動軌跡,讓動畫目標(biāo)能夠沿曲線平滑的實現(xiàn)移動動畫,也就是讓物體沿著貝塞爾曲線運(yùn)動,而不是機(jī)械的直線,本例實現(xiàn)效果如下所示:

7.gif
package com.xys.animationart.views;

import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PointF;
import android.util.AttributeSet;
import android.view.View;
import android.view.animation.AccelerateDecelerateInterpolator;

import com.xys.animationart.evaluator.BezierEvaluator;

/**
 * 貝塞爾路徑動畫
 * <p/>
 * Created by xuyisheng on 16/7/12.
 */
public class PathBezier extends View implements View.OnClickListener {

    private Paint mPathPaint;
    private Paint mCirclePaint;

    private int mStartPointX;
    private int mStartPointY;
    private int mEndPointX;
    private int mEndPointY;

    private int mMovePointX;
    private int mMovePointY;

    private int mControlPointX;
    private int mControlPointY;

    private Path mPath;

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

    public PathBezier(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPathPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mPathPaint.setStyle(Paint.Style.STROKE);
        mPathPaint.setStrokeWidth(5);
        mCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG);

        mStartPointX = 100;
        mStartPointY = 100;
        mEndPointX = 600;
        mEndPointY = 600;
        mMovePointX = mStartPointX;
        mMovePointY = mStartPointY;
        mControlPointX = 500;
        mControlPointY = 0;
        mPath = new Path();
        setOnClickListener(this);
    }

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

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset();
        canvas.drawCircle(mStartPointX, mStartPointY, 30, mCirclePaint);
        canvas.drawCircle(mEndPointX, mEndPointY, 30, mCirclePaint);
        mPath.moveTo(mStartPointX, mStartPointY);
        mPath.quadTo(mControlPointX, mControlPointY, mEndPointX, mEndPointY);
        canvas.drawPath(mPath, mPathPaint);
        canvas.drawCircle(mMovePointX, mMovePointY, 30, mCirclePaint);
    }

    @Override
    public void onClick(View view) {
        BezierEvaluator bezierEvaluator = new BezierEvaluator(new PointF(mControlPointX, mControlPointY));
        ValueAnimator anim = ValueAnimator.ofObject(bezierEvaluator,
                new PointF(mStartPointX, mStartPointY),
                new PointF(mEndPointX, mEndPointY));
        anim.setDuration(600);
        anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                PointF point = (PointF) valueAnimator.getAnimatedValue();
                mMovePointX = (int) point.x;
                mMovePointY = (int) point.y;
                invalidate();
            }
        });
        anim.setInterpolator(new AccelerateDecelerateInterpolator());
        anim.start();
    }
}

其中,用于改變運(yùn)動點(diǎn)坐標(biāo)的關(guān)鍵evaluator如下所示:

package com.xys.animationart.evaluator;

import android.animation.TypeEvaluator;
import android.graphics.PointF;

import com.xys.animationart.util.BezierUtil;

public class BezierEvaluator implements TypeEvaluator<PointF> {

    private PointF mControlPoint;

    public BezierEvaluator(PointF controlPoint) {
        this.mControlPoint = controlPoint;
    }

    @Override
    public PointF evaluate(float t, PointF startValue, PointF endValue) {
        return BezierUtil.CalculateBezierPointForQuadratic(t, startValue, mControlPoint, endValue);
    }
}

這里的TypeEvaluator計算用到了計算貝塞爾曲線上點(diǎn)的計算算法,這個會在后面繼續(xù)講解。

貝塞爾曲線進(jìn)階

求貝塞爾曲線上任意一點(diǎn)的坐標(biāo)

求貝塞爾曲線上任意一點(diǎn)的坐標(biāo),這一過程,就是利用了De Casteljau算法。

http://www.cs.mtu.edu/~shene/COURSES/cs3621/NOTES/spline/Bezier/de-casteljau.html

7.png

利用這一算法,有開發(fā)者開發(fā)了一個演示多階貝塞爾曲線的效果的App,其原理就是通過繪制貝塞爾曲線上的點(diǎn)來進(jìn)行繪制的,地址如下所示:

https://github.com/venshine/BezierMaker

下面這篇文章就詳細(xì)的講解了該算法的應(yīng)用,我的代碼也從這里提取而來:

http://devmag.org.za/2011/04/05/bzier-curves-a-tutorial/

計算

有了公式,只需要代碼實現(xiàn)就OK了,我們先寫兩個公式:

package com.xys.animationart.util;

import android.graphics.PointF;

/**
 * 計算貝塞爾曲線上的點(diǎn)坐標(biāo)
 * <p/>
 * Created by xuyisheng on 16/7/13.
 */
public class BezierUtil {

    /**
     * B(t) = (1 - t)^2 * P0 + 2t * (1 - t) * P1 + t^2 * P2, t ∈ [0,1]
     *
     * @param t  曲線長度比例
     * @param p0 起始點(diǎn)
     * @param p1 控制點(diǎn)
     * @param p2 終止點(diǎn)
     * @return t對應(yīng)的點(diǎn)
     */
    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 起始點(diǎn)
     * @param p1 控制點(diǎn)1
     * @param p2 控制點(diǎn)2
     * @param p3 終止點(diǎn)
     * @return t對應(yīng)的點(diǎn)
     */
    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;
    }
}

我們來將路徑繪制到View中,看是否正確:

package com.xys.animationart.views;

import android.animation.AnimatorSet;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.PointF;
import android.util.AttributeSet;
import android.view.View;

import com.xys.animationart.util.BezierUtil;

/**
 * 通過計算模擬二階、三階貝塞爾曲線
 * <p/>
 * Created by xuyisheng on 16/7/13.
 */
public class CalculateBezierPointView extends View implements View.OnClickListener {

    private Paint mPaint;
    private ValueAnimator mAnimatorQuadratic;
    private ValueAnimator mAnimatorCubic;
    private PointF mPointQuadratic;
    private PointF mPointCubic;

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

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

    public CalculateBezierPointView(Context context, AttributeSet attrs) {
        super(context, attrs);
        mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        mAnimatorQuadratic = ValueAnimator.ofFloat(0, 1);
        mAnimatorQuadratic.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                PointF point = BezierUtil.CalculateBezierPointForQuadratic(valueAnimator.getAnimatedFraction(),
                        new PointF(100, 100), new PointF(500, 100), new PointF(500, 500));
                mPointQuadratic.x = point.x;
                mPointQuadratic.y = point.y;
                invalidate();
            }
        });

        mAnimatorCubic = ValueAnimator.ofFloat(0, 1);
        mAnimatorCubic.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                PointF point = BezierUtil.CalculateBezierPointForCubic(valueAnimator.getAnimatedFraction(),
                        new PointF(100, 600), new PointF(100, 1100), new PointF(500, 1000), new PointF(500, 600));
                mPointCubic.x = point.x;
                mPointCubic.y = point.y;
                invalidate();
            }
        });

        mPointQuadratic = new PointF();
        mPointQuadratic.x = 100;
        mPointQuadratic.y = 100;

        mPointCubic = new PointF();
        mPointCubic.x = 100;
        mPointCubic.y = 600;

        setOnClickListener(this);
    }

    @Override
    protected void onDraw(final Canvas canvas) {
        super.onDraw(canvas);
        canvas.drawCircle(mPointQuadratic.x, mPointQuadratic.y, 10, mPaint);
        canvas.drawCircle(mPointCubic.x, mPointCubic.y, 10, mPaint);
    }

    @Override
    public void onClick(View view) {
        AnimatorSet set = new AnimatorSet();
        set.playTogether(mAnimatorQuadratic, mAnimatorCubic);
        set.setDuration(2000);
        set.start();
    }
}

這次我們并沒有通過API提供的貝塞爾曲線繪制方法來繪制二階、三階貝塞爾曲線,而是通過時間t和起始點(diǎn)來計算一條貝塞爾曲線上的所有點(diǎn),可以發(fā)現(xiàn),通過算法計算出來的點(diǎn),與通過API所繪制出來的點(diǎn),是完全吻合的。

貝塞爾曲線擬合計算

貝塞爾曲線有一個非常常用的動畫效果——MetaBall算法。相信很多開發(fā)者都見過類似的動畫,例如QQ的小紅點(diǎn)消除,UC瀏覽器的下拉刷新loading等等。要做好這個動畫,實際上最重要的就是通過貝塞爾曲線來擬合兩個圖形。

效果如圖所示:

8.png

矩形擬合

我們來看一下擬合的原理,實際上就是通過貝塞爾曲線來連接兩個圓上的四個點(diǎn),當(dāng)我們調(diào)整下畫筆的填充方式,并繪制一些輔助線,我們來看具體是如何進(jìn)行擬合的,如圖所示:

9.png

可以發(fā)現(xiàn),控制點(diǎn)為兩圓圓心連線的中點(diǎn),連接線為圖中的這樣一個矩形,當(dāng)圓比較小時,這種通過矩形來擬合的方式幾乎是沒有問題的,但我們把圓放大,再來看下這種擬合,如圖所示:

10.png

當(dāng)圓的半徑擴(kuò)大之后,就可以非常明顯的發(fā)現(xiàn)擬合的連接點(diǎn)與圓有一定相交的區(qū)域,這樣的擬合效果就不好了,我們將畫筆模式調(diào)整回來,如圖所示:

11.png

所以,簡單的矩形擬合,在圓半徑小的時候,是可以的,但當(dāng)圓半徑變大之后,就需要更加嚴(yán)格的擬合了。

這里我們先來講解下,如何計算矩形擬合的幾個關(guān)鍵點(diǎn)。

從前面那張線圖可以看出,標(biāo)紅的兩個角是相等的,而這個角可以通過兩個圓心的坐標(biāo)來算出,有了這樣一個角度,通過R x cos和 R x sin來計算矩形的一個頂點(diǎn)的坐標(biāo),類似的,其它坐標(biāo)可求,關(guān)鍵代碼如下所示:

private void metaBallVersion1(Canvas canvas) {
        float x = mCircleTwoX;
        float y = mCircleTwoY;
        float startX = mCircleOneX;
        float startY = mCircleOneY;

        float dx = x - startX;
        float dy = y - startY;
        double a = Math.atan(dx / dy);
        float offsetX = (float) (mCircleOneRadius * Math.cos(a));
        float offsetY = (float) (mCircleOneRadius * Math.sin(a));

        float x1 = startX + offsetX;
        float y1 = startY - offsetY;

        float x2 = x + offsetX;
        float y2 = y - offsetY;

        float x3 = x - offsetX;
        float y3 = y + offsetY;

        float x4 = startX - offsetX;
        float y4 = startY + offsetY;

        float controlX = (startX + x) / 2;
        float controlY = (startY + y) / 2;

        mPath.reset();
        mPath.moveTo(x1, y1);
        mPath.quadTo(controlX, controlY, x2, y2);
        mPath.lineTo(x3, y3);
        mPath.quadTo(controlX, controlY, x4, y4);
        mPath.lineTo(x1, y1);

        // 輔助線
        canvas.drawLine(mCircleOneX, mCircleOneY, mCircleTwoX, mCircleTwoY, mPaint);
        canvas.drawLine(0, mCircleOneY, mCircleOneX + mRadiusNormal + 400, mCircleOneY, mPaint);
        canvas.drawLine(mCircleOneX, 0, mCircleOneX, mCircleOneY + mRadiusNormal + 50, mPaint);
        canvas.drawLine(x1, y1, x2, y2, mPaint);
        canvas.drawLine(x3, y3, x4, y4, mPaint);
        canvas.drawCircle(controlX, controlY, 5, mPaint);
        canvas.drawLine(mCircleTwoX, mCircleTwoY, mCircleTwoX, 0, mPaint);
        canvas.drawLine(x1, y1, x1, mCircleOneY, mPaint);

        canvas.drawPath(mPath, mPaint);
    }

切線擬合

如前面所說,矩形擬合在半徑較小的情況下,是可以實現(xiàn)完美擬合的,而當(dāng)半徑變大后,就會出現(xiàn)貝塞爾曲線與圓相交的情況,導(dǎo)致擬合失敗。

那么如何來實現(xiàn)完美的擬合呢?實際上,也就是說貝塞爾曲線與圓的連接點(diǎn)到貝塞爾曲線的控制點(diǎn)的連線,一定是圓的切線,這樣的話,無論圓的半徑如何變化,貝塞爾曲線一定是與圓擬合的,具體效果如圖所示:

12.png

這時候我們把畫筆模式調(diào)整回來看下填充效果,如圖所示:

13.png

這樣擬合是非常完美的。那么要如何來計算這些擬合的關(guān)鍵點(diǎn)呢?在前面的線圖中,我標(biāo)記出了兩個角,這兩個角分別可以求出,相減,就可以獲取切點(diǎn)與圓心的夾角了,這樣,通過R x cos和R x sin就可以求出切點(diǎn)的坐標(biāo)了。

其中,小的角可以通過兩個圓心的坐標(biāo)來求出,而大的角,可以通過直角三角形(圓心、切點(diǎn)、控制點(diǎn))來求出,即控制點(diǎn)到圓心的距離/半徑。

關(guān)鍵代碼如下所示:

private void metaBallVersion2(Canvas canvas) {
        float x = mCircleTwoX;
        float y = mCircleTwoY;
        float startX = mCircleOneX;
        float startY = mCircleOneY;
        float controlX = (startX + x) / 2;
        float controlY = (startY + y) / 2;

        float distance = (float) Math.sqrt((controlX - startX) * (controlX - startX) + (controlY - startY) * (controlY - startY));
        double a = Math.acos(mRadiusNormal / distance);

        double b = Math.acos((controlX - startX) / distance);
        float offsetX1 = (float) (mRadiusNormal * Math.cos(a - b));
        float offsetY1 = (float) (mRadiusNormal * Math.sin(a - b));
        float tanX1 = startX + offsetX1;
        float tanY1 = startY - offsetY1;

        double c = Math.acos((controlY - startY) / distance);
        float offsetX2 = (float) (mRadiusNormal * Math.sin(a - c));
        float offsetY2 = (float) (mRadiusNormal * Math.cos(a - c));
        float tanX2 = startX - offsetX2;
        float tanY2 = startY + offsetY2;

        double d = Math.acos((y - controlY) / distance);
        float offsetX3 = (float) (mRadiusNormal * Math.sin(a - d));
        float offsetY3 = (float) (mRadiusNormal * Math.cos(a - d));
        float tanX3 = x + offsetX3;
        float tanY3 = y - offsetY3;

        double e = Math.acos((x - controlX) / distance);
        float offsetX4 = (float) (mRadiusNormal * Math.cos(a - e));
        float offsetY4 = (float) (mRadiusNormal * Math.sin(a - e));
        float tanX4 = x - offsetX4;
        float tanY4 = y + offsetY4;

        mPath.reset();
        mPath.moveTo(tanX1, tanY1);
        mPath.quadTo(controlX, controlY, tanX3, tanY3);
        mPath.lineTo(tanX4, tanY4);
        mPath.quadTo(controlX, controlY, tanX2, tanY2);
        canvas.drawPath(mPath, mPaint);

        // 輔助線
        canvas.drawCircle(tanX1, tanY1, 5, mPaint);
        canvas.drawCircle(tanX2, tanY2, 5, mPaint);
        canvas.drawCircle(tanX3, tanY3, 5, mPaint);
        canvas.drawCircle(tanX4, tanY4, 5, mPaint);
        canvas.drawLine(mCircleOneX, mCircleOneY, mCircleTwoX, mCircleTwoY, mPaint);
        canvas.drawLine(0, mCircleOneY, mCircleOneX + mRadiusNormal + 400, mCircleOneY, mPaint);
        canvas.drawLine(mCircleOneX, 0, mCircleOneX, mCircleOneY + mRadiusNormal + 50, mPaint);
        canvas.drawLine(mCircleTwoX, mCircleTwoY, mCircleTwoX, 0, mPaint);
        canvas.drawCircle(controlX, controlY, 5, mPaint);
        canvas.drawLine(startX, startY, tanX1, tanY1, mPaint);
        canvas.drawLine(tanX1, tanY1, controlX, controlY, mPaint);
    }

圓的擬合

貝塞爾曲線做動畫,很多時候都需要使用到圓的特效,而通過二階、三階貝塞爾曲線來擬合圓,也不是一個非常簡單的事情,所以,我直接把結(jié)論拿出來了,具體的算法地址如下所示:

http://spencermortensen.com/articles/bezier-circle/

http://stackoverflow.com/questions/1734745/how-to-create-circle-with-b%C3%A9zier-curves

14.png
15.png

有了貝塞爾曲線的控制點(diǎn),再對其實現(xiàn)動畫,就非常簡單了,與之前的動畫沒有太大的區(qū)別。

源代碼

本次的講解代碼已經(jīng)全部上傳到Github :

https://github.com/xuyisheng/BezierArt

歡迎大家提issue。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容