貝塞爾曲線(Bezier)之水波紋的手機充電動畫效果(一)

博主聲明:

轉載請在開頭附加本文鏈接及作者信息,並標記爲轉載。本文由博主 威威喵 原創,請多支持與指教。

本文首發於此   博主威威喵  |  博客主頁https://blog.csdn.net/smile_running

    博主這幾天一直在搞貝塞爾曲線(Bezier)動畫的研究,雖然我的數學不太好,但是也勉勉強強能夠看懂懂貝塞爾曲線的公式,套用還是很簡單的。前幾次搞了幾個貝塞爾曲線動畫效果,感覺那個效果還是非常讚的,今天興致又來了,於是去搜索了一下 Android 相關的貝塞爾曲線的動畫實例,偶然看到一個 Android 充電進度的貝塞爾曲線動畫,它的效果圖如下:

     看到這個效果呢,我首先是想到用三階貝塞爾曲線公式來做,於是就屁顛屁顛的開始了,套了三階貝塞爾曲線的公式,發現效果沒出來,臥槽。害我白高興一場,以爲我的數學還是可以的,結果。。。

    我最先的想法是通過點位去計算波形路徑,不過最後放棄了。哈哈,喜出望外,結果我發現了一個更簡單的做法,用 Path 類下面的一個三階貝塞爾曲線的封裝方法,很簡單就實現了波浪的效果,這是我寫這個效果時所收穫到的意外驚喜,之前還沒字母使用過,接下來我們進行分析這個效果的實現,然後再講解一下 Path 類三階貝塞爾的簡單用法。

    多的就不扯淡了,我們直接開始吧。國際慣例,先來看看最終的實現效果圖:

    這個充電進度的動畫效果還行吧,上面我搜索到的是一張靜態圖,我就是依照這那張圖的樣式做的,可能顏色又一點點缺陷,這個自己再美化美化就好啦。

    來吧,拿到這個效果圖,首先就是分析一波。來看一下草圖

    看上面那張圖,首先我們要把圓繪製到中心點吧,這沒什麼問題。因爲三階貝塞爾曲線需要 2 個控制點,從圖中我們知道 p1 和 p2 就是那條曲線的控制點, 而且上圖 p1 p2 p3 p4 四個點獲取座標都很容易。

        //內部
        pIn0 = new PointF(rippleX - mDefCircleRadius, rippleY);
        pIn1 = new PointF(rippleX - mRandom.nextInt((int) mDefCircleRadius), rippleY - mRandom.nextInt((int) (mDefCircleRadius / 4)));
        pIn2 = new PointF(rippleX + mRandom.nextInt((int) mDefCircleRadius), rippleY + mRandom.nextInt((int) (mDefCircleRadius / 4)));
        pIn3 = new PointF(rippleX + mDefCircleRadius, rippleY);

    因爲海浪波紋有兩條曲線組成,這兩條曲線是交錯的,所以我們需要再來 4 個點

        // 外部
        pExt0 = new PointF(rippleX - mDefCircleRadius, rippleY);
        pExt1 = new PointF(rippleX - mRandom.nextInt((int) mDefCircleRadius), rippleY + mRandom.nextInt((int) (mDefCircleRadius / 3)));
        pExt2 = new PointF(rippleX + mRandom.nextInt((int) mDefCircleRadius), rippleY + mRandom.nextInt((int) (mDefCircleRadius / 3)));
        pExt3 = new PointF(rippleX + mDefCircleRadius, rippleY);

    得到曲線的點之後呢,我們就可以開始用 Path 類的一個方法去形成曲線的路徑了,因爲波浪是有顏色的,所以需要把 Path 給封閉起來,形成密閉的效果。接着,再來看一張草圖

    用 Path 類製作一條曲線,並且我們要把 p0 ~ p5 這幾個點給封閉起來,形成海浪的效果。想法是不錯,但是你會發現,這個形成的區域已經超出了圓的範圍了吧,那樣子就非常醜,猶如這個樣子:

    圓圈外面多出了兩個藍色部分區域,醜的不行啊。 像這個樣子的情況,我最先想到的是 canvas 有沒有畫剪切區域的,後來找了一下,好像沒找到。陷入深思,後來靈機一動,想到我上一次實現的一種效果,是畫一個圓,從內到外擴散的,感興趣的可以點擊鏈接,去看看我的文章:Android 視差動畫 — 雅虎新聞內容揭示效果

    這個圓效果呢,就是從小變到大,逐漸的把內容呈現出來。這就給我一個很好的啓示,我可以繪製一個這樣的圓,把外面藍色部分遮住不久好了嘛,也就相當於除了綠色包含的圓以外全部給遮住,這樣顯示的效果只能看到這個綠色的圓了,我們的目的也就達到了。這個就需要對畫筆的寬度進行計算,代碼如下:

    private void drawMasked(Canvas canvas) {
        //繪製一個遮罩層,屏蔽 Path Close 以外的區域
        mMaskPaint.setStrokeWidth(mDiagonal + mDefCircleRadius * 2 - mPaintSize * 1.5f);
        canvas.drawCircle(mCircleX, mCircleY, mDiagonal, mMaskPaint);
    }

    這樣就把露出來的藍色區域給遮擋住了,接下來還有一個難點,就是如何根據進度值把海浪也給升高,總不能在固定位置浪啊浪吧。這就要考慮一個問題,我們需要根據圓的直徑和進度值的一個比例關係,計算出當前海平面的高度,通過不斷的增加 progress(進度),海平面會隨着進度升高,而且這個期間波浪一直在流動的。這部分關鍵代碼如下:

        // 直徑與進度的比例
        rippleScale = 2 * mDefCircleRadius / 100;

    // 繪製海浪的波紋效果,分內部和外部兩條
    private void drawExternalRipple(Canvas canvas) {

        // 計算進度的 x , y 位置
        y = mCircleY - mDefCircleRadius + (100 - mProgress) * rippleScale;
        x = caculateX(y);

        float rippleY = y;
        float rippleX = mCircleX;

        //內部
        pIn0 = new PointF(rippleX - mDefCircleRadius, rippleY);
        pIn1 = new PointF(rippleX - mRandom.nextInt((int) mDefCircleRadius), rippleY - mRandom.nextInt((int) (mDefCircleRadius / 4)));
        pIn2 = new PointF(rippleX + mRandom.nextInt((int) mDefCircleRadius), rippleY + mRandom.nextInt((int) (mDefCircleRadius / 4)));
        pIn3 = new PointF(rippleX + mDefCircleRadius, rippleY);
        Path inPath = new Path();
        inPath.moveTo(pIn0.x, pIn0.y);
        inPath.cubicTo(pIn1.x, pIn1.y, pIn2.x, pIn2.y, pIn3.x, pIn3.y);
        inPath.lineTo(mCircleX + mDefCircleRadius, mCircleY + mDefCircleRadius);
        inPath.lineTo(mCircleX - mDefCircleRadius, mCircleY + mDefCircleRadius);
        inPath.close();
        canvas.drawPath(inPath, mInnerPaint);

        // 外部
        pExt0 = new PointF(rippleX - mDefCircleRadius, rippleY);
        pExt1 = new PointF(rippleX - mRandom.nextInt((int) mDefCircleRadius), rippleY + mRandom.nextInt((int) (mDefCircleRadius / 3)));
        pExt2 = new PointF(rippleX + mRandom.nextInt((int) mDefCircleRadius), rippleY + mRandom.nextInt((int) (mDefCircleRadius / 3)));
        pExt3 = new PointF(rippleX + mDefCircleRadius, rippleY);
        Path extPath = new Path();
        extPath.moveTo(pExt0.x, pExt0.y);
        extPath.cubicTo(pExt1.x, pExt1.y, pExt2.x, pExt2.y, pExt3.x, pExt3.y);
        extPath.lineTo(mCircleX + mDefCircleRadius, mCircleY + mDefCircleRadius);
        extPath.lineTo(mCircleX - mDefCircleRadius, mCircleY + mDefCircleRadius);
        extPath.close();
        canvas.drawPath(extPath, mExternalPaint);
    }

    上面代碼是計算進度條和圓的直徑的比例,通過這個比例,我們可以拿到 path 中波浪逐漸上升的 y 座標,通過不斷的繪製 path 然後形成波浪的動畫效果,直到進度條爲 100 時,我們就進行判斷處理

    public void setProgress(int progress) {
        this.mProgress = progress;
        this.mArcProgress = mProgress * 3.6f;
        if (mProgress <= 100) {
            isFinished = false;
        } else {
            isFinished = true;
        }
        invalidate();
    }

    如果進度達到 100,我們就開始繪製完成時候的動畫,代碼如下

    private void drawFinished(Canvas canvas) {
        canvas.drawCircle(mCircleX, mCircleY, mDefCircleRadius, mArcPaint);
        canvas.drawCircle(mCircleX, mCircleY, mDefCircleRadius, mInnerPaint);
        canvas.drawText("充電完成", mCircleX - mTextPaint.getTextSize() * 2f, mCircleY + mTextPaint.getTextSize() / 2, mTextPaint);
    }

    只有這樣,當結束是纔會顯示不同的效果,否則不做處理的話,就是空空如也啦。

     那麼至此,我們對這個效果的分析也就完成了,並且手動進實現了一下,感覺收穫了不少,哈哈。最後呢,給出本效果的完整代碼,如下:

package nd.no.xww.qqmessagedragview;

import android.animation.ValueAnimator;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PointF;
import android.graphics.RectF;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.View;

import java.util.Random;

/**
 * @author xww
 * @desciption :
 * @date 2019/8/6
 * @time 12:11
 * 博主:威威喵
 * 博客:https://blog.csdn.net/smile_Running
 */
public class ChargeBezierView extends View {

    private Paint mExternalPaint;
    private Paint mInnerPaint;
    private Paint mArcPaint;
    private Paint mCirclePaint;
    private Paint mTextPaint;

    private Paint mMaskPaint;

    private int mWidth;
    private int mHeight;
    // 充電進度值百分制
    private int mProgress;
    private float mArcProgress;
    private float mPaintSize;

    //水波紋於進度條的高度比
    private float rippleScale;
    //用於畫進度
    private RectF mRect;

    private Random mRandom;

    private float mCircleX;
    private float mCircleY;
    private float mDefCircleRadius;

    // 對角線的長度
    private float mDiagonal;

    private boolean isFinished = false;

    //水波紋高度座標
    private float x;
    private float y;

    private void init() {
        mExternalPaint = getPaint(Color.parseColor("#554F94CD"));
        mInnerPaint = getPaint(Color.parseColor("#66B8FF"));
        mArcPaint = getPaint(Color.parseColor("#7FFF00"));
        mArcPaint.setStyle(Paint.Style.STROKE);//空心
        mCirclePaint = getPaint(Color.parseColor("#F8F8FF"));
        mCirclePaint.setStyle(Paint.Style.STROKE);//空心
        mTextPaint = getPaint(Color.parseColor("#FF00ff"));
        mMaskPaint = getPaint(Color.parseColor("#FFFFFF"));
        mMaskPaint.setStyle(Paint.Style.STROKE);

        mRandom = new Random();

        mPaintSize = mTextPaint.getTextSize();
    }

    private Paint getPaint(int color) {
        Paint paint = new Paint();
        paint.setDither(true);
        paint.setAntiAlias(true);
        paint.setStrokeWidth(18f);
        paint.setTextSize(60f);
        paint.setColor(color);
        return paint;
    }

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

    public ChargeBezierView(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public ChargeBezierView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    @SuppressLint("DrawAllocation")
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        mWidth = MeasureSpec.getSize(widthMeasureSpec);
        mHeight = MeasureSpec.getSize(heightMeasureSpec);

        mCircleX = mWidth / 2;
        mCircleY = mHeight / 2;

        mDefCircleRadius = mWidth / 4;
        mRect = new RectF(mCircleX - mDefCircleRadius, mCircleY - mDefCircleRadius,
                mCircleX + mDefCircleRadius, mCircleY + mDefCircleRadius);

        mDiagonal = (float) Math.sqrt(Math.pow(mCircleX, 2) + Math.pow(mCircleY, 2));

        rippleScale = 2 * mDefCircleRadius / 100;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        if (isFinished) {
            drawMasked(canvas);
            drawFinished(canvas);
        } else {
            drawExternalRipple(canvas);
            drawMasked(canvas);
            drawProgressText(canvas);
            drawCircle(canvas);
            drawProgress(canvas);
        }
    }

    // 繪製電量圓形軌道
    private void drawCircle(Canvas canvas) {
        canvas.drawCircle(mCircleX, mCircleY, mDefCircleRadius, mCirclePaint);
    }

    private void drawProgress(Canvas canvas) {
        // -90 表示從上半軸 x=0 開始
        canvas.drawArc(mRect, -90, mArcProgress, false, mArcPaint);
    }

    private void drawProgressText(Canvas canvas) {
        canvas.drawText(mProgress + "%", mCircleX - mPaintSize, mCircleY + mTextPaint.getTextSize() / 2, mTextPaint);
    }

    private void drawMasked(Canvas canvas) {
        //繪製一個遮罩層,屏蔽 Path Close 以外的區域
        mMaskPaint.setStrokeWidth(mDiagonal + mDefCircleRadius * 2 - mPaintSize * 1.5f);
        canvas.drawCircle(mCircleX, mCircleY, mDiagonal, mMaskPaint);
    }

    private void drawFinished(Canvas canvas) {
        canvas.drawCircle(mCircleX, mCircleY, mDefCircleRadius, mArcPaint);
        canvas.drawCircle(mCircleX, mCircleY, mDefCircleRadius, mInnerPaint);
        canvas.drawText("充電完成", mCircleX - mTextPaint.getTextSize() * 2f, mCircleY + mTextPaint.getTextSize() / 2, mTextPaint);
    }

    private PointF pExt0;
    private PointF pExt1;
    private PointF pExt2;
    private PointF pExt3;

    private PointF pIn0;
    private PointF pIn1;
    private PointF pIn2;
    private PointF pIn3;

    ValueAnimator externalAnimator;

    // 繪製海浪的波紋效果,分內部和外部兩條
    private void drawExternalRipple(Canvas canvas) {

        // 計算進度的 x , y 位置
        y = mCircleY - mDefCircleRadius + (100 - mProgress) * rippleScale;
        x = caculateX(y);

        float rippleY = y;
        float rippleX = mCircleX;

        //內部
        pIn0 = new PointF(rippleX - mDefCircleRadius, rippleY);
        pIn1 = new PointF(rippleX - mRandom.nextInt((int) mDefCircleRadius), rippleY - mRandom.nextInt((int) (mDefCircleRadius / 4)));
        pIn2 = new PointF(rippleX + mRandom.nextInt((int) mDefCircleRadius), rippleY + mRandom.nextInt((int) (mDefCircleRadius / 4)));
        pIn3 = new PointF(rippleX + mDefCircleRadius, rippleY);
        Path inPath = new Path();
        inPath.moveTo(pIn0.x, pIn0.y);
        inPath.cubicTo(pIn1.x, pIn1.y, pIn2.x, pIn2.y, pIn3.x, pIn3.y);
        inPath.lineTo(mCircleX + mDefCircleRadius, mCircleY + mDefCircleRadius);
        inPath.lineTo(mCircleX - mDefCircleRadius, mCircleY + mDefCircleRadius);
        inPath.close();
        canvas.drawPath(inPath, mInnerPaint);

        // 外部
        pExt0 = new PointF(rippleX - mDefCircleRadius, rippleY);
        pExt1 = new PointF(rippleX - mRandom.nextInt((int) mDefCircleRadius), rippleY + mRandom.nextInt((int) (mDefCircleRadius / 3)));
        pExt2 = new PointF(rippleX + mRandom.nextInt((int) mDefCircleRadius), rippleY + mRandom.nextInt((int) (mDefCircleRadius / 3)));
        pExt3 = new PointF(rippleX + mDefCircleRadius, rippleY);
        Path extPath = new Path();
        extPath.moveTo(pExt0.x, pExt0.y);
        extPath.cubicTo(pExt1.x, pExt1.y, pExt2.x, pExt2.y, pExt3.x, pExt3.y);
        extPath.lineTo(mCircleX + mDefCircleRadius, mCircleY + mDefCircleRadius);
        extPath.lineTo(mCircleX - mDefCircleRadius, mCircleY + mDefCircleRadius);
        extPath.close();
        canvas.drawPath(extPath, mExternalPaint);

    }

    public void setProgress(int progress) {
        this.mProgress = progress;
        this.mArcProgress = mProgress * 3.6f;
        if (mProgress <= 100) {
            isFinished = false;
        } else {
            isFinished = true;
        }
        invalidate();
    }

    // 圓的方程式 a2 = b2 + c2
    private float caculateX(float y) {
        x = (float) Math.sqrt(Math.pow(mDefCircleRadius, 2) - y * y);
        return x;
    }
}

    還有一個是進行進度值設置的,這個很簡單,在 MainActivity 裏面開一個子線程,然後設置一下進度值就可以了

        chargeView = findViewById(R.id.chargeView);
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (true) {
                    progress++;
                    if (progress > 100) {
                        progress = 101;
                    }
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            chargeView.setProgress(progress);
                        }
                    });
                }
            }
        }).start();

    使用起來就是這麼簡單,不過還有一些與貝塞爾曲線相關的知識沒有介紹,感興趣的話,可以去看我之前寫的幾篇文章,裏面有關於貝塞爾的介紹,還有一些比較炫酷的 Android 動畫效果哦。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章