贝塞尔曲线(Bezier)之水波纹的手机充电动画效果(一)

博主声明:

转载请在开头附加本文链接及作者信息,并标记为转载。本文由博主 威威喵 原创,请多支持与指教。

本文首发于此 博主威威喵 | 博客主页https://blog.csdn.net/smile_running

博主这几天一直在搞贝塞尔曲线(Bezier)动画的研究,虽然我的数学不太好,但是也勉勉强强能够看懂懂贝塞尔曲线的公式,套用还是很简单的。前几次搞了几个贝塞尔曲线动画效果,感觉那个效果还是非常赞的,今天兴致又来了,于是去搜索了一下 Android 相关的贝塞尔曲线的动画实例,偶然看到一个 Android 充电进度的贝塞尔曲线动画,它的效果图如下:

image

看到这个效果呢,我首先是想到用三阶贝塞尔曲线公式来做,于是就屁颠屁颠的开始了,套了三阶贝塞尔曲线的公式,发现效果没出来,卧槽。害我白高兴一场,以为我的数学还是可以的,结果。。。

我最先的想法是通过点位去计算波形路径,不过最后放弃了。哈哈,喜出望外,结果我发现了一个更简单的做法,用 Path 类下面的一个三阶贝塞尔曲线的封装方法,很简单就实现了波浪的效果,这是我写这个效果时所收获到的意外惊喜,之前还没字母使用过,接下来我们进行分析这个效果的实现,然后再讲解一下 Path 类三阶贝塞尔的简单用法。

多的就不扯淡了,我们直接开始吧。国际惯例,先来看看最终的实现效果图:

image

这个充电进度的动画效果还行吧,上面我搜索到的是一张静态图,我就是依照这那张图的样式做的,可能颜色又一点点缺陷,这个自己再美化美化就好啦。

来吧,拿到这个效果图,首先就是分析一波。来看一下草图

image

看上面那张图,首先我们要把圆绘制到中心点吧,这没什么问题。因为三阶贝塞尔曲线需要 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 给封闭起来,形成密闭的效果。接着,再来看一张草图

image

用 Path 类制作一条曲线,并且我们要把 p0 ~ p5 这几个点给封闭起来,形成海浪的效果。想法是不错,但是你会发现,这个形成的区域已经超出了圆的范围了吧,那样子就非常丑,犹如这个样子:

image

圆圈外面多出了两个蓝色部分区域,丑的不行啊。 像这个样子的情况,我最先想到的是 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);
    }

只有这样,当结束是才会显示不同的效果,否则不做处理的话,就是空空如也啦。

image

那么至此,我们对这个效果的分析也就完成了,并且手动进实现了一下,感觉收获了不少,哈哈。最后呢,给出本效果的完整代码,如下:

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 动画效果哦。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容