贝塞尔曲线常用动画原理学习--万字长文,全面解析

2,909 阅读23分钟

前言

在Android开发中,有很多动画都用到了贝塞尔曲线,比如下面这几种效果:

购物车.gif

点赞效果 (1).gif

水波纹.gif

QQ小红点效果图.gif

SeekBar效果图.gif

那这篇文章主要就是介绍一下贝塞尔曲线常用动画的实现。

正文

在说动画实现之前,必须要了解啥是贝塞尔曲线,以及其公式,这样才能在后面运用中了解其原理和痛点,快速实现需要的动画。

贝塞尔曲线介绍

关于贝塞尔曲线的介绍文章有很多,我看了不少,觉得有一篇的推导以及解释说的很明白,下面是文章传送门:

# 贝塞尔曲线

我这里再总结一下。

贝塞尔曲线完全由其控制点控制其形状,也就是一个贝塞尔曲线是什么样子完全由其控制点来决定,所以只要根据项目需求合理的放置控制点,就可以绘制出想要的曲线,n个控制点对应着n-1阶贝塞尔曲线。

那控制点是如何来决定贝塞尔曲线的呢

贝塞尔曲线gif图.gif

比如这里是2阶曲线,就是由3个点控制,其中起始点和结束点一般不动,由另一个点决定其曲线的形状,这个点是如何控制呢

2阶贝塞尔曲线就是由2个数据点A、C和一个控制点来完成:

贝塞尔原理1.jpg

这个曲线是如何画出来的呢,这里连接AB和BC,在线上取D、E2个点:

贝塞尔原理2.jpg

使得D、E2点满足

贝塞尔原理3.png

再把D、E连线,在上面取F点,让F点满足:

DF/DE = AD/AB = BE/BC

贝塞尔原理4.jpg

这样就得到了一个点,然后让D从A移动到B,这样就不断地绘制出F点,形成了一条曲线:

贝塞尔原理5.gif

公式推导

既然了解了贝塞尔曲线,那我们可以从另一个方向来推导一下这个贝塞尔曲线的公式。关于公式推导,上面的那篇知乎文章说的最为通俗易懂,下面用几张图来介绍一下。

一阶曲线

对于一阶曲线就很容易,是由2个数据点直接构成,其实也就是一个直线:

一阶曲线.jpg

其中很容易根据P0和P1 2个点来得到曲线点,其中t是进度,范围是0到1,那很容易得到B1点的位置

一阶曲线1.png

稍微转换一下,一阶的公式就出来了

一阶曲线2.png

二阶曲线

在理解二阶贝塞尔曲线就要了解一个思想就是递归,高阶曲线都能递归到一阶曲线,比如这里ABC 3个点,在AB上取点D:

二阶0.jpg

二阶1.jpg

同时在BC上取点E,使得AD/AB = BE/BC,这个和一阶上取点是一样的,

二阶2.jpg

二阶3.jpg

然后连接DE,这时DE又是一条直线了,这时就可以利用递归的思想,设置t = AD / AB,

二阶4.jpg

那来分析一下这种情况的值,就可以推导出公式了:

二阶5.jpg

其中P0‘就是上图直线DE直线左边的坐标

二阶6.png

P1‘就是直线右边的坐标

二阶7.png

那中间点的坐标B2又是P0’和P1‘通过一样的规则而来,所以把P0’和P1‘消掉,就是下面公式

二阶8.jpg

整理一下:

二阶9.png

到这里二阶贝塞尔曲线公式就推导出来了,可以发现它是和3个点都有关系,其实在Android开发中,二阶曲线就够了,很少用到三阶以及以上的。但是还是可以推导出三阶以及更高阶的曲线。

三阶曲线

对于三阶曲线的推导也非常简单,比如下面图中的ABCD 4个点控制就是三阶曲线,按照递归思想,取点DFG 3个点,这3个点其实就是二阶曲线的3个点,而再去点HJI 就是一阶曲线了,所以每次取点都是一个降阶的过程:

三阶0.jpg

再根据之前推导二阶的时候,就很容易推导出三阶曲线的公式:

三阶1.jpg

这里也不细说了,主要思想就是递归和降阶,最后都能降到一阶。

好到这里我们就不继续展开了,其实更高阶的贝塞尔曲线就是一个递归的过程,下面来说一下实际应用。

实际应用

购物车添加物品曲线

先看一下效果图:

购物车.gif

先分析一波,这里从加号到购物车这里小球的运动类似一条抛物线,这时从前面的贝塞尔曲线可知这种二阶曲线即可完成这个效果,也就是使用一个控制点,然后控制点的选择可以查看网址:

在线贝塞尔曲线

来模拟出效果,比如这个购物车的曲线大概就是这种:

购物车曲线.jpg

话不多说,直接开整。

1、首先是Activity界面点击加号时,处理信息:

iv_shop_add.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View v) {
        //获取商品坐标
        int[] goodsPoint = new int[2];
        iv_shop_add.getLocationInWindow(goodsPoint);
        //获取购物车坐标
        int[] shoppingCartPoint = new int[2];
        iv_shop_cart.getLocationInWindow(shoppingCartPoint);
        //生成商品View
        GoodsView goodsView = new GoodsView(ShoppingCartActivity.this);
        goodsView.setCircleStartPoint(goodsPoint[0], goodsPoint[1]);
        goodsView.setCircleEndPoint(shoppingCartPoint[0] + mShoppingCartWidth / 2, shoppingCartPoint[1]);
        //添加View并执行动画
        mViewGroup.addView(goodsView);
        goodsView.startAnimation();

    }
});

这里直接就是每点击一次增加按钮,就在decorView上add一个View,然后在view的动画结束时再remove掉这个动画,简单粗暴,那再看一下动画的具体实现。

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

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    drawCircle(canvas);
}
/**
 * 进行一些初始化操作
 */
private void init(Context context) {
    mCirclePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    mCirclePaint.setStyle(Paint.Style.FILL);
    mCirclePaint.setColor(Color.RED);
}
/**
 * 商品加入购物车的小红点
 */
private void drawCircle(Canvas canvas) {
    canvas.drawCircle(mCircleMovePoint.x, mCircleMovePoint.y, mRadius, mCirclePaint);
}

自定义View直接继承View,然后初始化一个画圆的画笔,然后在onDraw中进行画圆圈即可,所以这里的重点是圆圈的位置以及重绘的频率问题,按照前面的贝塞尔曲线介绍,二阶贝塞尔曲线有2个数据点和一个控制点,以及一个不断地绘制地点,所以定义:

//小红点开始坐标
Point mCircleStartPoint = new Point();
//小红点结束坐标
Point mCircleEndPoint = new Point();
//小红点控制点坐标
Point mCircleConPoint = new Point();
//小红点的移动坐标
Point mCircleMovePoint = new Point();

然后便是开始动画

public void startAnimation() {
    //设置控制点,控制点坐标直接影响曲线地样子
    mCircleConPoint.x = ((mCircleStartPoint.x + mCircleEndPoint.x) / 2);
    mCircleConPoint.y = (420);
    //设置属性动画
    ValueAnimator valueAnimator = ValueAnimator.ofObject(new CirclePointEvaluator(), mCircleStartPoint, mCircleEndPoint);
    //动画执行时间
    valueAnimator.setDuration(600);
    //非线性执行插值器
    valueAnimator.setInterpolator(new AccelerateDecelerateInterpolator());
    valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            //属性动画回调
            Point goodsViewPoint = (Point) animation.getAnimatedValue();
            mCircleMovePoint.x = goodsViewPoint.x;
            mCircleMovePoint.y = goodsViewPoint.y;
            //重绘
            invalidate();
        }
    });
    //动画结束回调,remove掉动画View
    valueAnimator.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            super.onAnimationEnd(animation);
            ViewGroup viewGroup = (ViewGroup) getParent();
            viewGroup.removeView(GoodsView.this);
        }
    });
    valueAnimator.start();

}

这里使用了属性动画来获取重绘地频率以及动画执行地速率,比如这里设置的插值器就可以实现先加速再减速的效果,关于属性动画不是本章的重点,只需要知道通过属性动画能得到一个执行进度,进度控制是通过插值器来实现,那如何根据进度也就是上一节中的t来计算出移动点的坐标呢,这里就是属性动画的求值器了,具体代码如下:

//按照上一节的原理,这里就是提供2个数据点,然后根据进度t根据控制点计算出移动点即可。
public class CirclePointEvaluator implements TypeEvaluator {

    @Override
    public Object evaluate(float t, Object startValue, Object endValue) {

        Point startPoint = (Point) startValue;
        Point endPoint = (Point) endValue;
        //直接套公式即可
        int x = (int) (Math.pow((1-t),2)*startPoint.x+2*(1-t)*t*mCircleConPoint.x+Math.pow(t,2)*endPoint.x);
        int y = (int) (Math.pow((1-t),2)*startPoint.y+2*(1-t)*t*mCircleConPoint.y+Math.pow(t,2)*endPoint.y);
        return new Point(x,y);
    }

}

好了,到这里就把购物车添加物品的曲线说完了。

星星点赞效果

直接看一下效果图,也就是类似下面的效果:

点赞效果 (1).gif

话不多说还是分析一波,这里每点击一下都会有一个星星出来,沿着一条曲线运动,然后消失,其中星星运动的轨迹是符合三阶贝塞尔曲线的,一样去在线生成贝塞尔曲线网址查看一下:

三阶示意图.jpg

比如上图,星星从下面的开始点到上面的结束点结束,曲线由2个控制点来控制。下面来仔细分析一下:

1、星星出现和消失的动画集

这里可以看见每个星星都是从无到有,而且有透明度变化,当透明度为1时,再逐渐消失,所以先定义星星出现的动画时间和总时间:

//默认进入动画时间
private int mEnterDuration = 1500;
//默认贝塞尔曲线动画时间
private int mCurveDuration = 4500;

所以星星出现动画的时间是1500毫秒,消失动画的时间是3000毫秒,代码如下:

//获取进入动画
private AnimatorSet generateEnterAnimation(final View child) {
    AnimatorSet enterAnimation = new AnimatorSet();
    //透明度、x、y3个属性的变化
    enterAnimation.playTogether(
            ObjectAnimator.ofFloat(child, ALPHA, 0f, 1f),
            ObjectAnimator.ofFloat(child, SCALE_X, 0f, 2.0f),
            ObjectAnimator.ofFloat(child, SCALE_Y, 0f, 2.0f)
            )
    ;
    enterAnimation.setInterpolator(new LinearInterpolator());
    //当进入动画结束时,需要播放结束动画
    enterAnimation.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            super.onAnimationEnd(animation);
            //结束动画时间需要注意即可
            Animator diss = ObjectAnimator.ofFloat(child, ALPHA, 1f, 0f);
            diss.setInterpolator(new LinearInterpolator());
            diss.setDuration(mCurveDuration-mEnterDuration);
            diss.start();
        }
    });
    return enterAnimation.setDuration(mEnterDuration);
}

2、贝塞尔路径动画

星星的出现和消失都有了动画,接下来就是星星运动的轨迹,这里采用3阶贝塞尔曲线,根据前面的原理,需要找到2个数据点和2个控制点,其中起点坐标是固定的,终点坐标可以有一定的移动范围:

// 起点坐标是整个View的底部中间位置
PointF pointStart = new PointF((mViewWidth - mPicWidth) / 2, mViewHeight - mPicHeight);
// 终点坐标在View的顶部中间位置的左右随机100和往下随机100的位置
float y = mRandom.nextInt(100);
PointF pointEnd = new PointF(((mViewWidth - mPicWidth) / 2) + ((mRandom.nextBoolean() ? 1 : -1) * mRandom.nextInt(100)), y);

然后是控制点,控制点为了让曲线更好看,第一个点的y坐标要在View的3/4左右位置,第二点的y坐标在View的1/4左右的位置,x坐标都是整个View宽度的随机

//获取第一个控制点的坐标
private PointF getTogglePoint1() {
    PointF pointf = new PointF();
    pointf.x = mViewWidth * mRandom.nextFloat();
    pointf.y = mViewHeight * 3 / 4 * mRandom.nextFloat();
    return pointf;
}
//获取第二个控制点的坐标
private PointF getTogglePoint2() {
    PointF pointf = new PointF();
    pointf.x = mViewWidth * mRandom.nextFloat();
    pointf.y = mViewHeight / 4 * mRandom.nextFloat();
    return pointf;
}

好了,有了数据点和控制点,就和绘制上面购物车曲线一样能计算出移动点的曲线,同样是使用属性动画:

private ValueAnimator generateCurveAnimation(View child) {
    // 起点坐标
    PointF pointStart = new PointF((mViewWidth - mPicWidth) / 2, mViewHeight - mPicHeight);
    // 终点坐标
    float y = mRandom.nextInt(100);
    PointF pointEnd = new PointF(((mViewWidth - mPicWidth) / 2) + ((mRandom.nextBoolean() ? 1 : -1) * mRandom.nextInt(100)), y);
    //2个控制点
    PointF pointF1 = getTogglePoint1();
    PointF pointF2 = getTogglePoint2();
    //属性动画
    ValueAnimator curveAnimator = ValueAnimator.ofObject(mEvaluatorRecord.getCurrentPath(pointF1, pointF2), pointStart, pointEnd);
    curveAnimator.addUpdateListener(new CurveUpdateLister(child));
    curveAnimator.setInterpolator(new LinearInterpolator());
    return curveAnimator.setDuration(mCurveDuration);
}

这里我们就可以看一下求值器,看求值器是如何计算的:

//三阶贝塞尔曲线的求值器
public class ThreeCurveEvaluator implements TypeEvaluator<PointF> {

    private final PointF mControlP1;
    private final PointF mControlP2;
    
    //2个控制点
    public ThreeCurveEvaluator(PointF pointF1, PointF pointF2) {
        this.mControlP1 = pointF1;
        this.mControlP2 = pointF2;
    }
    //套用公式即可算出point坐标
    @Override
    public PointF evaluate(float fraction, PointF startValue, PointF endValue) {
        PointF pointCur = new PointF();
        float leftTime = 1.0f - fraction;
        // 三阶贝赛尔曲线
        pointCur.x = (float) Math.pow(leftTime, 3) * startValue.x
                + 3 * (float) Math.pow(leftTime, 2) * fraction * mControlP1.x
                + 3 * leftTime * (float) Math.pow(fraction, 2) * mControlP2.x
                + (float) Math.pow(fraction, 3) * endValue.x;

        pointCur.y = (float) Math.pow(leftTime, 3) * startValue.y
                + 3 * (float) Math.pow(leftTime, 2) * fraction * mControlP1.y
                + 3 * leftTime * fraction * fraction * mControlP2.y
                + (float) Math.pow(fraction, 3) * endValue.y;
        return pointCur;
    }
}

然后在属性动画更新回调中设置星星的坐标:

protected static class CurveUpdateLister implements ValueAnimator.AnimatorUpdateListener {
        //星星的View
        private final View mChild;
        protected CurveUpdateLister(View child) {
            this.mChild = child;
        }
        //根据计算后点的坐标,绘制星星View的坐标
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            PointF value = (PointF) animation.getAnimatedValue();
            this.mChild.setX(value.x);
            this.mChild.setY(value.y);
        }
    }

3、同时开始2个动画集,并且把星星View add到容器View中:

private void start(View child, LayoutParams layoutParams) {
    // 设置进入 消失动画
    AnimatorSet enterAnimator = generateEnterAnimation(child);
    // 设置路径动画
    ValueAnimator curveAnimator = generateCurveAnimation(child);
    // 执行动画集合
    AnimatorSet animatorSet = new AnimatorSet();
    animatorSet.playTogether(curveAnimator, enterAnimator);
    animatorSet.addListener(new AnimationEndListener(child, animatorSet));
    animatorSet.start();
    // add父布局
    super.addView(child, layoutParams);
}

当然和前面绘制购物车的View一样,在动画结束后,需要remove掉View且停止动画,把View对象置空:

public void onAnimationEnd(Animator animation) {
    super.onAnimationEnd(animation);
    // 移除View
    removeView(mChild);
    // 移除缓存
    mAnimatorSets.remove(mAnimatorSet);
    // 释放View
    this.mChild = null;
}

好了,到这里我们就了解了一个二阶贝塞尔曲线的使用,以我个人理解需要注意以下几点:

  • 什么时候使用二阶,当曲线满足需要2个控制点时。
  • 还是数据点和控制点的选择,其中控制点的位置直接决定动画的样式,要设置好,比如上面view设置的在View的上半部分和下半部分。
  • 对于多个动画的执行要分清楚执行顺序以及动画结束后清除相关的View。

水波纹效果

前面说了一阶和二阶贝塞尔曲线的使用,还有一种常用的效果就是水波纹,比如现在很多手机的充电效果都是水波纹,效果图:

水波纹.gif

其实刚开始看的时候就觉得这个是曲线可能和贝塞尔有关系,但是真的没有思路,但是仔细看过代码就会发现实现的很巧妙,下面来仔细介绍一下原理。

quadTo函数

这里用到了一个重要的函数就是这个quadTo函数,其实这就是二阶贝塞尔曲线函数,之前的例子中我们都是根据控制点和数据点自己计算,这个函数能为我们画出一条贝塞尔曲线,直接看一下函数原型:

public void quadTo(float x1, float y1, float x2, float y2) {
    isSimplePath = false;
    native_quadTo(mNativePath, x1, y1, x2, y2);
}
Add a quadratic bezier from the last point, 
approaching control point (x1,y1), 
and ending at (x2,y2). 
If no moveTo() call has been made for this contour,
the first point is automatically set to (0,0)

这里的(x1,y1)就是控制点,(x2,y2)是结束点,那开始点呢 这就取决于如果上一次moveTo到什么地方,如果上一次没有调用moveTo方法,那默认的就是原点(0,0),这样说可能还不够明确,下面以一个例子来说一下:

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    mPath?.apply {
        lineTo(100f,100f)
        quadTo(150f,50f,200f,100f)
    }
    canvas?.drawPath(mPath!!,mPaint!!)
}

这里先画直线到(100,100),再画一条二阶曲线,终点是(200,100),控制点是(150,50),效果是:

quadTo函数.jpg

看到这里是不是看出一点眉目了,接着再来修改一下把lineTo去掉,再多来几次quadTo:

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    mPath?.apply {
        moveTo(100f,100f)
        quadTo(150f,50f,200f,100f)
        quadTo(250f,150f,300f,100f)
        quadTo(350f,50f,400f,100f)
        quadTo(450f,150f,500f,100f)
    }
    canvas?.drawPath(mPath!!,mPaint!!)
}

效果如下:

quadTo函数1.jpg

终于有点水波的样子了,既然实现的最底层方法有了,接下来就是让其动起来了。

水波纹效果实现思路

让其动起来很简单,其实就是不断地重绘即可,边重绘边移动这个曲线,话不多说,直接开整。

先看一下原理图:

波浪效果图.jpg

然后利用属性动画让曲线右移和不断地绘制

下面是主要代码:

//重绘代码
protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset();
        //移动到屏幕一半
        float startY = mScreenHeight * naviProgress;
        mPath.moveTo(-mScreenWidth + mOffset, startY);
        //绘制4条贝塞尔曲线
        mPath.quadTo(-mScreenWidth * 3 / 4 + mOffset, mScreenHeight * naviProgress - 200, -mScreenWidth / 2 + mOffset, mScreenHeight * naviProgress);
        mPath.quadTo(-mScreenWidth / 4 + mOffset, mScreenHeight * naviProgress + 200, 0 + mOffset, mScreenHeight * naviProgress);
        mPath.quadTo(mScreenWidth / 4 + mOffset, mScreenHeight * naviProgress - 200, mScreenWidth / 2 + mOffset, mScreenHeight * naviProgress);
        mPath.quadTo(mScreenWidth * 3 / 4 + mOffset, mScreenHeight * naviProgress + 200, mScreenWidth + mOffset, mScreenHeight * naviProgress);
        //不断地画图
        canvas.drawPath(mPath, mPaint);
    }
//右移偏量 也就是不断地返回0到ScreenWidth的值 
private void setViewanimator() {
    ValueAnimator valueAnimator = ValueAnimator.ofInt(0, mScreenWidth);
    valueAnimator.setDuration(1200);
    //重复
    valueAnimator.setRepeatCount(ValueAnimator.INFINITE);
    valueAnimator.setInterpolator(new LinearInterpolator());
    valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            //右移量
            mOffset = (int) animation.getAnimatedValue();
            //触发重绘
            invalidate();
        }
    });
    valueAnimator.start();
}

效果图:

波浪效果图1.gif

好像哪里不太对,哈哈,其实到这里已经完成的差不多了,只需要在onDraw时,把下面的部分也包裹住,同时设置画笔是填充:

 protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        mPath.reset()
        float naviProgress = 1 - mProgress;
        float startY = mScreenHeight * naviProgress
        mPath.moveTo(-mScreenWidth + mOffset, startY);
        mPath.quadTo(-mScreenWidth * 3 / 4 + mOffset, mScreenHeight * naviProgress - 200, -mScreenWidth / 2 + mOffset, mScreenHeight * naviProgress);
        mPath.quadTo(-mScreenWidth / 4 + mOffset, mScreenHeight * naviProgress + 200, 0 + mOffset, mScreenHeight * naviProgress);
        mPath.quadTo(mScreenWidth / 4 + mOffset, mScreenHeight * naviProgress - 200, mScreenWidth / 2 + mOffset, mScreenHeight * naviProgress);
        mPath.quadTo(mScreenWidth * 3 / 4 + mOffset, mScreenHeight * naviProgress + 200, mScreenWidth + mOffset, mScreenHeight * naviProgress);

        //空白部分填充
       mPath.lineTo(mScreenWidth, mScreenHeight);
       mPath.lineTo(0, mScreenHeight);
        canvas.drawPath(mPath, mPaint);
    }
mPath = new Path();
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mPaint.setColor(Color.parseColor("#00BFFF"));
//画笔设置为Fill
mPaint.setStyle(Paint.Style.FILL);
mPaint.setStrokeWidth(8);

再看一下效果图:

波浪效果图2.gif

好了,搞定,会了基本原理,那些水波纹的特效都不在话下了,比如充电、下载都是经过一些再加工和美化即可,实现起来也不费事。

对于水波纹动画我个人总结如下:

  • 找到实现方法是关键,其实刚开始看我也联想不到如何实现,但是想到通过不断重绘和位移曲线就可以达到效果,就很容易实现了。
  • quadTo方法的理解和运用很关键,以及画笔的一些属性等等,这个等后面可以再仔细研究一下。

仿QQ未读消息拖拽效果

这个效果可以说是很有名了,就是QQ未读消息的拖拽动画,讲到贝塞尔曲线时必须要说的一个例子,也是贝塞尔曲线的经典用例,直接看效果图:

QQ小红点效果图.gif

有了前面的经验,看到这个效果图时第一反应在中间2个圆的连线使用贝塞尔曲线,因为这个是2条曲线,所以可以实现,但是这个QQ小红点除了这个,还有其他非常多的细节需要考虑,下面来一一细说。

流程分析

这里要分清楚原来View和动画View,就是这个小红点这个View是在原来界面上面,后面的动画View是通过addView加上的View,这个和之前的购物车动画和点赞效果是一样的思路,动画View不影响原来界面展示。

为了区分这里把原来的View定义为QQBezerView,把动画View定义为DragView。下面来分析一下整个拖拽流程。

  • 点击小红点时

原来的QQBezerView消失,同时创建一个固定圆和一个拖拽View,拖拽View和QQBezerView长的一样,固定圆的坐标就是原来QQBezerView的坐标。

QQ效果图1.jpg

  • 拖拽时

当DOWN事件在小红点上时,就可以拦截后面的触摸事件了,这时拖拽View就需要跟着手势的坐标而移动,当在一定范围内时,需要在2个View直接绘制贝塞尔曲线。

QQ效果图2.png

当拖拽时大于一定范围,这2个圆之间的连接处就不需要了。

QQ效果图3.jpg

  • 松开时

当UP事件出发时,这里要分2种情况,如果没有超过阈值,那这个View还会回到原来的地方,并且为了好看,还可以加个回弹动画。

QQ效果图4.gif

当超过阈值,就说明要清除这个View,这时在UP的位置播放一个消息爆炸的动画即可。

QQ效果5.gif

同时松开时如果没有超过阈值就把动画View全部remove掉,原View显示出来;超过阈值,就把原View也隐藏起来。

到这里我们对整个小红点的拖拽流程就比较熟悉了,下面来看看细节地方。

QQBezerView的触摸事件处理

原View的处理主要就是触摸事件的处理,这个已经老生常谈了,直接看:

//处理事件
public boolean onTouchEvent(MotionEvent event) {
    //获得根View 用来addView动画View
    View rootView = getRootView();
    //获得触摸位置在全屏所在位置
    float mRawX = event.getRawX();
    float mRawY = event.getRawY();
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            //请求父View不拦截
            getParent().requestDisallowInterceptTouchEvent(true);
            //获得当前View在屏幕上的位置
            int[] cLocation = new int[2];
            getLocationOnScreen(cLocation);
            if (rootView instanceof ViewGroup) {
                //初始化拖拽时显示的View
                dragView = new DragView(getContext());
                //设置固定圆的圆心坐标 这个就是固定圆的圆心
                dragView.setStickyPoint(cLocation[0] + mWidth / 2, cLocation[1] + mHeight / 2, mRawX, mRawY);
                //获得缓存的bitmap,滑动时直接通过drawBitmap绘制出来
                setDrawingCacheEnabled(true);
                Bitmap bitmap = getDrawingCache();
                if (bitmap != null) {
                    dragView.setCacheBitmap(bitmap);
                    //将DragView添加到RootView中,这样就可以全屏滑动了
                    ((ViewGroup) rootView).addView(dragView);
                    //把原View给隐藏起来
                    setVisibility(INVISIBLE);
                }
            }
            break;
        case MotionEvent.ACTION_MOVE:
            //请求父View不拦截
            getParent().requestDisallowInterceptTouchEvent(true);
            if (dragView != null) {
                //更新DragView的位置
                dragView.setDragViewLocation(mRawX, mRawY);
            }
            break;
        case MotionEvent.ACTION_UP:
            getParent().requestDisallowInterceptTouchEvent(false);
            if (dragView != null) {
                //手抬起时来判断各种情况
                dragView.setDragUp();
            }
            break;
    }
    return true;
}

这里没啥特殊之处,在上面流程已经说过了,概况来说就是DOWN时创建动画View,MOVE时拖拽动画View,UP时看有没有超过阈值再做不同处理。

DragView的状态

这里根据上面流程的分析,可以对DragView做几种状态:

private static final int STATE_INIT = 0;//默认静止状态
private static final int STATE_DRAG = 1;//拖拽状态
private static final int STATE_MOVE = 2;//移动状态
private static final int STATE_DISMISS = 3;//消失状态

其中拖拽状态是指需要绘制连接时没有超过阈值,移动状态就是超过阈值的状态,不用绘制连接。

DragView的绘制

DragView的绘制主要就是连接处,贝塞尔曲线的应用:

@Override
protected void onDraw(Canvas canvas) {
    if (isInsideRange() && mState == STATE_DRAG) {
        mPaint.setColor(Color.RED);
        //绘制固定的小圆
        canvas.drawCircle(stickyPointF.x, stickyPointF.y, stickRadius, mPaint);
        //首先获得两圆心之间的斜率
        Float linK = MathUtil.getLineSlope(dragPointF, stickyPointF);
        //然后通过两个圆心和半径、斜率来获得外切线的切点
        PointF[] stickyPoints = MathUtil.getIntersectionPoints(stickyPointF, stickRadius, linK);
        //移动圆的半径
        dragRadius = (int) Math.min(mWidth, mHeight) / 2;
        PointF[] dragPoints = MathUtil.getIntersectionPoints(dragPointF, dragRadius, linK);
        mPaint.setColor(Color.RED);
        //二阶贝塞尔曲线的控制点取得两圆心的中点
        controlPoint = MathUtil.getMiddlePoint(dragPointF, stickyPointF);
        //绘制贝塞尔曲线
        mPath.reset();
        mPath.moveTo(stickyPoints[0].x, stickyPoints[0].y);
        mPath.quadTo(controlPoint.x, controlPoint.y, dragPoints[0].x, dragPoints[0].y);
        mPath.lineTo(dragPoints[1].x, dragPoints[1].y);
        mPath.quadTo(controlPoint.x, controlPoint.y, stickyPoints[1].x, stickyPoints[1].y);
        mPath.lineTo(stickyPoints[0].x, stickyPoints[0].y);
        canvas.drawPath(mPath, mPaint);
    }
    if (mCacheBitmap != null && mState != STATE_DISMISS) {
        //绘制缓存的Bitmap
        canvas.drawBitmap(mCacheBitmap, dragPointF.x - mWidth / 2,
                dragPointF.y - mHeight / 2, mPaint);
    }
    if (mState == STATE_DISMISS && explodeIndex < explode_res.length) {
        //绘制小红点消失时的爆炸动画
        canvas.drawBitmap(bitmaps[explodeIndex], dragPointF.x - mWidth / 2, dragPointF.y - mHeight / 2, mPaint);
    }
}

看到这里是不是有点迷糊,哈哈,其实也非常简单,就是获取2个圆的切点为数据点,圆心中间为控制点,绘制2条贝塞尔曲线,

QQ效果图6.jpg

再利用上一节中的设置画笔为Fill,再包裹一圈,就形成了红色的曲线连接处,也是非常简单的。

爆炸消失动画

在流程里说了,当拖拽的非常远会有一个爆炸效果,这个没啥说的,就是在最后的位置绘制几张图片即可:

//爆炸动画
private void startExplodeAnim() {
    //几张图片
    ValueAnimator animator = ValueAnimator.ofInt(0, explode_res.length);
    animator.setDuration(300);
    animator.addUpdateListener(animation -> {
        explodeIndex = (int) animation.getAnimatedValue();
        invalidate();
    });
    animator.addListener(new AnimatorListenerAdapter() {
        @Override
        public void onAnimationEnd(Animator animation) {
            if (onDragListener != null) {
                onDragListener.onDismiss();
            }
        }
    });
    animator.start();
}

回弹消失动画

这个动画还是蛮有意思,就是当拖拽没有超过阈值时,有个回弹效果,很有弹性,效果如下:

QQ效果图4.gif

动画的绘制和拖拽一样,也是要绘制连接处,这个上面已经说了,主要是拖拽的View的移动位置,这个是如何控制的,能弹过头再弹回来,仔细分析一下拖拽View也就是从手指释放的点(x1,y1)移动到固定圆的圆心(x2,y2),至于如何绘制非线性动画前面也有提及就是插值器,使用什么插值器能达到这个效果呢,这里有个网站,大家可以查看:

插值器

当在网页中选择movement和spring时,效果是:

QQ效果图8.gif

这个就是我们想要的,直接开整:

//回弹消失动画
 private void startResetAnimator() {
            if (mState == STATE_DRAG) {
                //输入开始和结束坐标点
                ValueAnimator animator = ValueAnimator.ofObject(
                        new PointEvaluator(), new PointF(dragPointF.x, dragPointF.y), new PointF(stickyPointF.x, stickyPointF.y));
                animator.setDuration(500);
                //自定义插值器,实现回弹进度
                animator.setInterpolator(input -> {
                    float f = 0.4f;
                    return (float) (Math.pow(2, -4 * input) * Math.sin((input - f / 4) * (2 * Math.PI) / f) + 1);
                });
                //根据进度绘制View
                animator.addUpdateListener(animation -> {
                    PointF curPoint = (PointF) animation.getAnimatedValue();
                    dragPointF.set(curPoint.x, curPoint.y);
                    invalidate();
                });
                //动画结束,释放一些资源
                animator.addListener(new AnimatorListenerAdapter() {
                    @Override
                    public void onAnimationEnd(Animator animation) {
                        clearDragView();
                        if (onDragListener != null) {
                            onDragListener.onDrag();
                        }
                    }
                });
                animator.start();
            }

        }

这样回弹动画就搞定了,不得不说数学知识还是蛮重要的,其实还有更多的插值器实现,后续如果有类似非线性动画的需求,这个是很不错的借鉴地方。

比如我就想要弹起效果,也就是这样:

QQ效果图7.gif

直接在消失动画里把插值器改成:

animator.setInterpolator(new BounceInterpolator());

那最终效果图就变成了:

QQ效果图10.gif

这种非线性的动画还是很好玩的,后续可以多研究研究。

总结一下QQ小红点,这个稍微比之前的几种要更复杂一点,但是了解原理之后也没有那么难,我个人觉得值得学习的是以下几点:

  • 拖拽动画View和原View进行分离处理,原View拦截事件,拖拽View进行处理,逻辑分离,简单方便。
  • 绘制曲线区域,要找到痛点,也就是数据点和控制点,比如仿QQ小红点里的是2个圆的切点是数据点,圆心中心点是控制点。
  • 非线性动画要会使用,比如回弹动画,可以让View更加好看。

贝塞尔效果的SeekBar

这个效果就是利用贝塞尔曲线进行的突起或者凹陷效果,比如flutter的底部导航栏,还有就是这个SeekBar,看一下效果图:

SeekBar效果图.gif

看了前面那么多贝塞尔曲线的效果图实现,再看到这个效果图,你是不是感觉似曾相识,这熟悉的曲线,这熟悉的滑动重绘,没错我想你心中已经有了一点想法了。

首先是触摸事件处理,这个就比较容易了,主要是当DOWN事件时开始凸起,UP事件开始恢复,MOVE事件进行不断重绘,没啥可说的,

//触摸事件处理
public boolean onTouchEvent(MotionEvent event) {

    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            //拿到手指点击的位置
            fingerX = event.getX();
            if (fingerX < fingerXmin) fingerX = fingerXmin;
            if (fingerX > fingerXMax) fingerX = fingerXMax;
            //在这里执行凸起动画
            this.animatorFingerIn.start();
            break;

        case MotionEvent.ACTION_MOVE:
            robTouchEvent = true;
            fingerX = event.getX();
            if (fingerX < fingerXmin) fingerX = fingerXmin;
            if (fingerX > fingerXMax) fingerX = fingerXMax;
            //不断地重绘
            postInvalidate();
            break;

        case MotionEvent.ACTION_UP:
            robTouchEvent = false;
            //在这里恢复动画
            this.animatorFingerOut.start();
            break;
    }
    valueSelected = Integer.valueOf(decimalFormat.format(valueMin + (valueMax - valueMin) * (fingerX - fingerXmin) / (fingerXMax - fingerXmin)));
    if (selectedListener != null) {
        selectedListener.onSelected(valueSelected);
    }
    return true;
}

对于凸起动画和恢复动画其实也没啥说的,就是一个线性的属性动画,

//凸起动画时间200ms
this.animatorFingerIn = ValueAnimator.ofFloat(0f, 1f);
this.animatorFingerIn.setDuration(200L);
this.animatorFingerIn.setInterpolator(new LinearInterpolator());
this.animatorFingerIn.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
    @Override
    public void onAnimationUpdate(ValueAnimator animation) {
        //凸起进度
        float progress = (float) animation.getAnimatedValue();
        animInFinshed = (progress >= 0.15F);
        txtSelectedBgPaint.setAlpha((int) (255 * (progress - 0.15F)));

        if (progress >= 0.95F) {
            textPaint.setColor(colorValueSelected);
        } else {
            textPaint.setColor(colorValue);
        }
        //计算bezirHight很关键,用来计算控制点的
        bezierHeight = circleRadiusMax * 1.5F * progress;
        circleRadius = circleRadiusMin + (circleRadiusMax - circleRadiusMin) * progress;
        spaceToLine = circleRadiusMin * 2 * (1F - progress);
        Log.i(TAG, "onAnimationUpdate: bezierHight = " + bezierHeight + "  circleRadius = " + circleRadius + " spaceToLine = " + spaceToLine);
        postInvalidate();
    }
});

这里思路就很明确了,通过凸起动画来不断地获取贝塞尔曲线的控制点,来进行重绘,从而达到动画效果,看一下onDraw代码:

//画直线
bezierPath.reset();
bezierPath.moveTo(0, (float) 2 * height / 3);
bezierPath.lineTo(this.fingerX - circleRadiusMax * 2 * 3, (float) 2 * height / 3);

//第一条贝塞尔曲线,包括2个数据点和2个控制点
bezierPath.moveTo(this.fingerX - circleRadiusMax * 2 * 3, (float) 2 * height / 3);
float firstData1x = fingerX - circleRadiusMax * 2 * 3;
float firstData1y = (float) 2 * height / 3;
float firstControl1x = fingerX - circleRadiusMax * 2 * 2;
float firstControl1y = (float) 2 * height / 3;
float firstControl2x = fingerX - circleRadiusMax * 2 * 1;
float firstControl2y = (float) 2 * height / 3 - bezierHeight;
float firstData2x = fingerX;
float firstData2y = (float) 2 * height / 3 - bezierHeight;

bezierPath.cubicTo(firstControl1x, firstControl1y
        , firstControl2x, firstControl2y
        , firstData2x, firstData2y);

//第二条贝塞尔曲线,包括2个数据点和2个控制点
bezierPath.moveTo(this.fingerX, (float) 2 * height / 3 - bezierHeight);
bezierPath.cubicTo(this.fingerX + circleRadiusMax * 2, (float) 2 * height / 3 - bezierHeight
        , this.fingerX + circleRadiusMax * 2 * 2, (float) 2 * height / 3
        , this.fingerX + circleRadiusMax * 2 * 3, (float) 2 * height / 3);

//后面的直线
bezierPath.lineTo(width, (float) 2 * height / 3);
canvas.drawPath(bezierPath, bezierPaint);

这里直接把其他绘制文字的代码就不要了,主要说曲线代码,这里的难点是曲线如何绘制,前面说购物车时说过,想要合理的曲线可以去自己慢慢试,首先考虑二阶的曲线:

二阶曲线0.jpg

会发现左边在加个直线根本不行,和期望图差别很大,期望图是:

二阶期望图.jpg

所以现在考虑三阶曲线,经过不同的调试,下面这种就比较符合:

三阶效果图0.jpg

ok,找到合适的曲线,直接利用cubicTo函数画出曲线即可,具体代码看上面实现。

总结一下,这个SeekBar还是比较简单的,主要就是找到合适的曲线即可。

总结

通过这几个最常见的效果实现,我相信对贝塞尔曲线已经非常熟悉了,无外乎就是找合适的数据点和控制点,本文中的代码都来自开源库,我自己对其中做了一些简单的修改以达到显示的效果,下面是源码的github地址:

仿QQ小红点

购物车和水波纹

点赞效果

SeekBar效果

还是那句话,学习开源库,原理和思路最重要。