本文主要是介绍贝塞尔曲线(Bezier)之 QQ 消息拖拽动画效果,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!
博主声明:
转载请在开头附加本文链接及作者信息,并标记为转载。本文由博主 威威喵 原创,请多支持与指教。
本文首发于此 博主:威威喵 | 博客主页:https://blog.csdn.net/smile_running
这几天突然发现 QQ 的消息拖拽动画效果还挺不错的,以前都没去留意它,这几看了一点关于贝塞尔曲线的知识,这不刚好沙场练兵。于是从昨天开始呢,我就已经开始补点高数的知识了。虽然我现在已经准大四了,眨眼间就快毕业了,高数的知识还是从大一开始学习的,现在基本忘了差不多了。
扯了一点关于我的学习经历,回到本篇问题的关键,QQ 消息拖拽效果是怎样的呢?于是,我在模拟器装了一个 QQ 应用,特地找了一下小号,记得这个号好像是我初中申请的账号,以前那会儿 cf、飞车、dnf 特别流行,搞了几个小号搬砖,哈哈。我们来看看消息拖拽的效果吧:
这样的效果做起来并不简单,尤其是曲线的计算方面,如果你也像我一样忘了高数的知识点的话,建议你去翻翻三角函数那部分的知识, 本文不会教你这些基本公式,也不会教你自定义 view 的基本流程,本篇目的:计算和实现拖拽的粘性效果。如果这些基本知识不具备的话,推荐你去看下我的自定义 view 相关文章。
有了上一篇(点击这里:贝塞尔曲线(Bezier)之爱心点赞曲线动画效果)对贝塞尔曲线的基本了解和写了一个小案例的铺垫,在这次写这个 QQ 消息拖拽效果的时候,显然轻松了许多。好了,废话就说这么多,下面进入重点内容。
首先,看上面的效果显示情况,可以看成两个小圆,一个比较大一点,可以拖拽出去,另一个小一点,但会随着两个圆的距离改变大小。我们的步骤:在 onDraw 里面绘制两个圆,用手指可以拖动一个大圆,并且小圆的大小会随着两圆的距离更改。这部分代码非常简单,我就不做多的介绍了,如果你对下面代码有不解之处,还请自己补充知识。直接贴代码:
package nd.no.xww.qqmessagedragview;import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;/*** @author xww* @desciption : 仿 QQ 消息拖拽消失的效果(大圆:不会消失,且大小一致。小圆:与大圆的距离协调改变大小)* @date 2019/8/2* @time 8:54*/
public class QQMessageDragView extends View {private Paint mPaint;//大圆private float mBigCircleX;private float mBigCircleY;private final int BIG_CIRCLE_RADUIS = 50;//小圆private float mSmallCircleX;private float mSmallCircleY;private int mSmallDefRaduis = 40;private int mSmallHideRaduis = 15;private int mSmallCircleRaduis = mSmallDefRaduis;private Bitmap mMessageBitmap;private void init() {mPaint = new Paint();mPaint.setDither(true);mPaint.setAntiAlias(true);mPaint.setColor(getResources().getColor(android.R.color.holo_red_dark));mMessageBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.message);mMessageBitmap = Bitmap.createScaledBitmap(mMessageBitmap, 150, 150, false);}@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {setMeasuredDimension(MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY ? MeasureSpec.getSize(widthMeasureSpec) : 200, MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY ? MeasureSpec.getSize(heightMeasureSpec) : 200);}public QQMessageDragView(Context context) {this(context, null);}public QQMessageDragView(Context context, @Nullable AttributeSet attrs) {this(context, attrs, 0);}public QQMessageDragView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {super(context, attrs, defStyleAttr);init();}@Overrideprotected void onDraw(Canvas canvas) {if (mSmallCircleRaduis > mSmallHideRaduis) {canvas.drawCircle(mSmallCircleX, mSmallCircleY, mSmallCircleRaduis, mPaint);}canvas.drawCircle(mBigCircleX, mBigCircleY, BIG_CIRCLE_RADUIS, mPaint);
// canvas.drawBitmap(mMessageBitmap, mBigCircleX, mBigCircleY, mPaint);}@Overridepublic boolean onTouchEvent(MotionEvent event) {float downX = event.getX();float downY = event.getY();switch (event.getAction()) {case MotionEvent.ACTION_DOWN:mSmallCircleRaduis = mSmallDefRaduis;mSmallCircleX = mBigCircleX = downX;mSmallCircleY = mBigCircleY = downY;break;case MotionEvent.ACTION_MOVE:mBigCircleX = event.getX();mBigCircleY = event.getY();int disCircle = calculateDisCircle(mSmallCircleX, mSmallCircleY, mBigCircleX, mBigCircleY);mSmallCircleRaduis = mSmallDefRaduis - disCircle / mSmallHideRaduis;break;case MotionEvent.ACTION_UP:mSmallCircleRaduis = 0;break;}invalidate();return true;}// 两点之间的距离公式 √(x2-x1)²+(y2-y1)²private int calculateDisCircle(float mSmallCircleX, float mSmallCircleY, float mBigCircleX, float mBigCircleY) {return (int) Math.sqrt(Math.pow((mSmallCircleX - mBigCircleX), 2) + Math.pow((mSmallCircleY - mBigCircleY), 2));}}
运行上面的代码,你就会看到和我一样的效果:
好了,上面的代码只是做了一个铺垫,也是必须实现的第一步。接下来重头戏开始,我们讲讲一些数学相关知识吧,本人高数也不怎么样,大学除了基本必修高数,也没去深入学习,不过这也不影响我们下面的操作。
首先,扔出一张草图,画的就这样,将就看吧:
这上面应该不难看懂吧,两个红色圆就相当于我们拖拽的圆一样,从上面的草图中,我们目前已知的有 c1 c2 r1 r2 这四个属性值,c1 c2 代表圆心坐标,r1 r2 是半径。
当用手指去拖拽大圆的时候,它们之间的联系就用那两根蓝色的曲线来表示,两曲线对应的在两圆上的坐标点就是 p1 p2 p3 p4 四个点,这四个点会伴随这两圆的距离发生改变,你可以想象一下效果。
那么,从上图中,我们就要去计算 p1 p2 p3 p4 这四个点的坐标,然后将四点封闭起来绘制成路径即可。可是,说的比较轻巧,从目前我们已知的条件当中,能用得上的就 c1 c2 r1 r2 四个了,如何去求呢?看接下来的这张图:
从这张图的计算过程中,我们可以求得绿色三角形的角 a 的相关方程式。因为我们已知 c1 圆心的坐标值,就可以得出 p1 点的坐标值,如上图 p1x p1y 的值。
这样的话,我们可以利用三角函数公式得出 b 边和 c 边的值,如上图,最终得到的一个方程式中,仅存在一个角 a 是我们未知的,接下来我们就要去计算角 a 的值,看下图:
来到第一张图,看上面的黄色辅助线,假设它形成的是直角。我们就可以得到这两条辅助线的边长 dy 和 dx 。又根据三角形的补角和两平行线之间的夹角相等的定理,我们得出图中的三个角 a 都是一样的大小。
这样我们可以得到一个等式: tanA = dy / dx ,最终,角 a = arctan( tanA )
这时候我们就取到了 a 相关的等式了,而 dx dy 都是可以计算出来的,所以一连串下来,相关的等式都成立了,从而就可以计算出一个点 p1,获得 p1 点后,p2 p3 p4 不就手到擒来嘛。
最后要想形成贝塞尔曲线的效果,除了 p1 p2 p3 p4 以外,我们还需要一个控制点,如图上的点 M,它是形成曲线的控制点,也是至关重要的一个点,它的坐标就是 M点 ( (c1x+c2x) / 2 , (c1y+c2y) / 2 )
那么本篇数学相关的计算部分就已经结束了,你还以为程序员不需要数学知识嘛,哈哈。下面就是该怎么写程序了,把数学公式化为程序代码,这就得看你的编程水平啦。
我写了好一会儿,都是那个坐标值正负的问题卡了我挺久的,不过最终还是把代码给搞出来了,四个点的计算方法如下:
private float p1X;private float p1Y;private float p2X;private float p2Y;private float p3X;private float p3Y;private float p4X;private float p4Y;//控制点private float controlX;private float controlY;private float dx, dy;private double angleA;private double tanA;private Path bezierPath;private Path mBezierPath;/*** 贝塞尔 p1 p2 p3 p4 四个点坐标的计算** @return*/private Path drawDragBezier() {if (mSmallCircleRaduis < mSmallHideRaduis) {return null;}dx = mBigCircleX - mSmallCircleX;dy = mBigCircleY - mSmallCircleY;tanA = dy / dx;angleA = Math.atan(tanA);//控制点的计算controlX = (mSmallCircleX + mBigCircleX) / 2;controlY = (mSmallCircleY + mBigCircleY) / 2;p1X = (float) (mSmallCircleX + Math.sin(angleA) * mSmallCircleRaduis);p1Y = (float) (mSmallCircleY - Math.cos(angleA) * mSmallCircleRaduis);p2X = (float) (mBigCircleX + Math.sin(angleA) * BIG_CIRCLE_RADUIS);p2Y = (float) (mBigCircleY - Math.cos(angleA) * BIG_CIRCLE_RADUIS);p3X = (float) (mBigCircleX - Math.sin(angleA) * BIG_CIRCLE_RADUIS);p3Y = (float) (mBigCircleY + Math.cos(angleA) * BIG_CIRCLE_RADUIS);p4X = (float) (mSmallCircleX - Math.sin(angleA) * mSmallCircleRaduis);p4Y = (float) (mSmallCircleY + Math.cos(angleA) * mSmallCircleRaduis);//绘制路径bezierPath = new Path();bezierPath.moveTo(p1X, p1Y);bezierPath.quadTo(controlX, controlY, p2X, p2Y);bezierPath.lineTo(p3X, p3Y);bezierPath.quadTo(controlX, controlY, p4X, p4Y);bezierPath.close();return bezierPath;}
然后呢,使用就很简单了。返回一个路径,我们只要画出来就好了,修改 onDraw 代码如下:
@Overrideprotected void onDraw(Canvas canvas) {mBezierPath = drawDragBezier();if (mBezierPath != null) {canvas.drawCircle(mSmallCircleX, mSmallCircleY, mSmallCircleRaduis, mPaint);canvas.drawPath(mBezierPath, mPaint);}canvas.drawCircle(mBigCircleX, mBigCircleY, BIG_CIRCLE_RADUIS, mPaint);}
好了吧,点击运行,你将会看到如下的效果:
最后做了一点点小优化,拖拽时没有超出范围可以回到原来的位置,若超出拖拽的极限方法,导致两个圆失去关联时,代表要摧毁那个大圆,手指松开那一刹那,要将它隐藏掉,效果如下:
那么至此,我们的QQ消息的粘性动画已经实现了,代码倒是不难,难的是通过数学公式来计算出 p1 p2 p3 p4 点的坐标值,这可能会卡住很多人,主要还是因为数学功底不足,还是抽时间补补数学,它可是个很有魅力的机灵鬼。
补充:(对上面的特效进行优化处理)
今天,8 月 8 日,早上 5 点半左右,台湾不幸遭到了地震,连我在福建中北部地带都能偶感晃动,我好像迷迷糊糊中感觉床在摇晃,是 6 点多级的地震,在此祝愿台湾人民安好。而且,受台风的影响,家里下了好大的雨,不过倒是清凉了许多。
好了,让我们来优化一下这个效果吧,博主之前还没有处理的一些细节问题,比如这个 QQ 消息拖动,如果我们没有将它拖断掉,也就是线还连着,上次的做法是将它的坐标赋值给初始按下的坐标,这导致的效果是一瞬间就回去了,动画太过生硬,体验不是特别好,接下来我们来优化一下,让它慢慢的回去,有一个过渡时间。
上次的代码是这样做的,直接回到手指起始按下的那一个点位置:
case MotionEvent.ACTION_UP:if (!isAttached) {//被扯断了isShowed = false;} else if (mSmallCircleRaduis >= mSmallHideRaduis) {//小圆的半径如果大于显示的半径,意味着没有拖段线isShowed = true;//大圆要显示//回到原来手指按下的位置mBigCircleX = mSmallCircleX;mBigCircleY = mSmallCircleY;}mSmallCircleRaduis = 0;//每次手松开,小圆半径规 0break;
这个肯定不行,要对它的值进行修改,我们的思想是这个样子的,看图
我们需要慢慢的改变大圆的半径,就相当于改变被我们拉出来的那个圆的 x 坐标和 y 坐标,我们给它定一个时间段,让它们一起开始变化,这个就得使用到属性动画来处理了,我们把上部分的代码做如下修改即可
case MotionEvent.ACTION_UP:if (!isAttached) {//被扯断了isShowed = false;} else if (mSmallCircleRaduis >= mSmallHideRaduis) {//小圆的半径如果大于显示的半径,意味着没有拖段线。松开手,弹回去isShowed = true;//大圆要显示animatorSet = new AnimatorSet();xAnimator = ObjectAnimator.ofFloat(mBigCircleX, mSmallCircleX);xAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {@Overridepublic void onAnimationUpdate(ValueAnimator animation) {mBigCircleX = (float) animation.getAnimatedValue();int disCircle = calculateDisCircle(mSmallCircleX, mSmallCircleY, mBigCircleX, mBigCircleY);mSmallCircleRaduis = mSmallDefRaduis - disCircle / mSmallHideRaduis;//在拖动过程中,小圆半径一直在缩小}});yAnimator = ObjectAnimator.ofFloat(mBigCircleY, mSmallCircleY);yAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {@Overridepublic void onAnimationUpdate(ValueAnimator animation) {mBigCircleY = (float) animation.getAnimatedValue();invalidate();}});animatorSet.playTogether(xAnimator, yAnimator);animatorSet.setInterpolator(new OvershootInterpolator(3f));animatorSet.setDuration(10000);animatorSet.start();animatorSet.addListener(new AnimatorListenerAdapter() {@Overridepublic void onAnimationEnd(Animator animation) {//动画结束时,隐藏小圆mSmallCircleRaduis = 0;//每次手松开,小圆半径规 0}});}break;
那么,绘制那个粘性的贝塞尔曲线也要一直绘制了,不能松开就没了吧,所以要把 onDraw 的里面的代码改为如下:
@Overrideprotected void onDraw(Canvas canvas) {mBezierPath = drawDragBezier();//两个圆还有联系if (mBezierPath != null) {canvas.drawPath(mBezierPath, mPaint);}if (isAttached) {canvas.drawCircle(mSmallCircleX, mSmallCircleY, mSmallCircleRaduis, mPaint);}//如果是显示的if (isShowed) {canvas.drawCircle(mBigCircleX, mBigCircleY, BIG_CIRCLE_RADUIS, mPaint);}}
好了,一起来看看效果吧。为了使效果更加明显,我特地把缩回来的动画改为 10S,足够你看清楚了吧
我给它加了一个插值器,回来的时候有一个反弹的效果!弹弹弹,弹走鱼尾纹。。。
不过呢,还有一个地方需要优化的,就是拖断掉的时候,再松开会有一个消失的效果,我就搞的简单一点,让它慢慢的消失就好了。不过也可以学那个爆炸效果,会比较炫酷一点,我找了一下那个爆炸的图片,懒得图改成透明颜色了,需要的自己去查一查帧动画就好了。
下面是放快的效果
最后的完整代码
package nd.no.xww.qqmessagedragview;import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.animation.ValueAnimator;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.support.annotation.Nullable;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.animation.OvershootInterpolator;/*** @author xww* @desciption : 仿 QQ 消息拖拽消失的效果(大圆:不会消失,且大小一致。小圆:与大圆的距离协调改变大小)* @date 2019/8/2* @time 8:54* @博主:威威喵*/
public class QQMessageDragView extends View {private Paint mPaint;//大圆private float mBigCircleX;private float mBigCircleY;private float mBigCircleRaduis = 50;//小圆private float mSmallCircleX;private float mSmallCircleY;private int mSmallDefRaduis = 40;private int mSmallHideRaduis = 15;//扯断的距离private int mSmallCircleRaduis = mSmallDefRaduis;private Bitmap mMessageBitmap;private boolean isAttached;//代表两个关联private boolean isFirst = true;//显示大圆private void init() {mPaint = new Paint();mPaint.setDither(true);mPaint.setAntiAlias(true);mPaint.setColor(getResources().getColor(android.R.color.holo_red_dark));mPaint.setTextSize(30f);mMessageBitmap = BitmapFactory.decodeResource(getResources(), R.drawable.message);mMessageBitmap = Bitmap.createScaledBitmap(mMessageBitmap, 150, 150, false);}@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {setMeasuredDimension(MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY ? MeasureSpec.getSize(widthMeasureSpec) : 200, MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY ? MeasureSpec.getSize(heightMeasureSpec) : 200);}public QQMessageDragView(Context context) {this(context, null);}public QQMessageDragView(Context context, @Nullable AttributeSet attrs) {this(context, attrs, 0);}public QQMessageDragView(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {super(context, attrs, defStyleAttr);init();}@Overrideprotected void onDraw(Canvas canvas) {mBezierPath = drawDragBezier();//两个圆还有联系if (mBezierPath != null) {canvas.drawPath(mBezierPath, mPaint);}if (isAttached) {canvas.drawCircle(mSmallCircleX, mSmallCircleY, mSmallCircleRaduis, mPaint);}//如果第一次,不绘制圆if (isFirst) {return;}canvas.drawCircle(mBigCircleX, mBigCircleY, mBigCircleRaduis, mPaint);}private float raduis;AnimatorSet animatorSet;ValueAnimator xAnimator;ValueAnimator yAnimator;@Overridepublic boolean onTouchEvent(MotionEvent event) {float downX = event.getX();float downY = event.getY();switch (event.getAction()) {case MotionEvent.ACTION_DOWN:// 两个圆关联了mBigCircleRaduis = 50; // 大圆的初始值isFirst = false;isAttached = true;mSmallCircleRaduis = mSmallDefRaduis;mSmallCircleX = mBigCircleX = downX;mSmallCircleY = mBigCircleY = downY;break;case MotionEvent.ACTION_MOVE:mBigCircleX = event.getX();mBigCircleY = event.getY();int disCircle = calculateDisCircle(mSmallCircleX, mSmallCircleY, mBigCircleX, mBigCircleY);mSmallCircleRaduis = mSmallDefRaduis - disCircle / mSmallHideRaduis;//在拖动过程中,小圆半径一直在缩小if (mSmallCircleRaduis < mSmallHideRaduis) {//小圆的半径如果太小了,不显示了。isAttached = false;//表示两个圆没有关联了,意味这线被拖断了}break;case MotionEvent.ACTION_UP:if (!isAttached) { // 被扯断了,两圆没有联系了ValueAnimator raduisAnimator = ObjectAnimator.ofFloat(mBigCircleRaduis, 0);raduisAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {@Overridepublic void onAnimationUpdate(ValueAnimator animation) {mBigCircleRaduis = (float) animation.getAnimatedValue();invalidate();}});raduisAnimator.setDuration(500);raduisAnimator.start();} else if (mSmallCircleRaduis >= mSmallHideRaduis) {//小圆的半径如果大于显示的半径,意味着没有拖段线。松开手,弹回去animatorSet = new AnimatorSet();xAnimator = ObjectAnimator.ofFloat(mBigCircleX, mSmallCircleX);xAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {@Overridepublic void onAnimationUpdate(ValueAnimator animation) {mBigCircleX = (float) animation.getAnimatedValue();int disCircle = calculateDisCircle(mSmallCircleX, mSmallCircleY, mBigCircleX, mBigCircleY);mSmallCircleRaduis = mSmallDefRaduis - disCircle / mSmallHideRaduis;//在拖动过程中,小圆半径一直在缩小}});yAnimator = ObjectAnimator.ofFloat(mBigCircleY, mSmallCircleY);yAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {@Overridepublic void onAnimationUpdate(ValueAnimator animation) {mBigCircleY = (float) animation.getAnimatedValue();invalidate();}});animatorSet.playTogether(xAnimator, yAnimator);animatorSet.setInterpolator(new OvershootInterpolator(2.5f));animatorSet.setDuration(500);animatorSet.start();animatorSet.addListener(new AnimatorListenerAdapter() {@Overridepublic void onAnimationEnd(Animator animation) {//动画结束时,隐藏小圆mSmallCircleRaduis = 0;//每次手松开,小圆半径规 0}});}break;}invalidate();return true;}// 两点之间的距离公式 √(x2-x1)²+(y2-y1)²private int calculateDisCircle(float mSmallCircleX, float mSmallCircleY, float mBigCircleX, float mBigCircleY) {return (int) Math.sqrt(Math.pow((mSmallCircleX - mBigCircleX), 2) + Math.pow((mSmallCircleY - mBigCircleY), 2));}private float p1X;private float p1Y;private float p2X;private float p2Y;private float p3X;private float p3Y;private float p4X;private float p4Y;//控制点private float controlX;private float controlY;private float dx, dy;private double angleA;private double tanA;private Path bezierPath;private Path mBezierPath;/*** 贝塞尔 p1 p2 p3 p4 四个点坐标的计算** @return*/private Path drawDragBezier() {if (mSmallCircleRaduis < mSmallHideRaduis || !isAttached) {return null;}dx = mBigCircleX - mSmallCircleX;dy = mBigCircleY - mSmallCircleY;tanA = dy / dx;angleA = Math.atan(tanA);//控制点的计算controlX = (mSmallCircleX + mBigCircleX) / 2;controlY = (mSmallCircleY + mBigCircleY) / 2;p1X = (float) (mSmallCircleX + Math.sin(angleA) * mSmallCircleRaduis);p1Y = (float) (mSmallCircleY - Math.cos(angleA) * mSmallCircleRaduis);p2X = (float) (mBigCircleX + Math.sin(angleA) * mBigCircleRaduis);p2Y = (float) (mBigCircleY - Math.cos(angleA) * mBigCircleRaduis);p3X = (float) (mBigCircleX - Math.sin(angleA) * mBigCircleRaduis);p3Y = (float) (mBigCircleY + Math.cos(angleA) * mBigCircleRaduis);p4X = (float) (mSmallCircleX - Math.sin(angleA) * mSmallCircleRaduis);p4Y = (float) (mSmallCircleY + Math.cos(angleA) * mSmallCircleRaduis);//绘制路径bezierPath = new Path();bezierPath.moveTo(p1X, p1Y);bezierPath.quadTo(controlX, controlY, p2X, p2Y);bezierPath.lineTo(p3X, p3Y);bezierPath.quadTo(controlX, controlY, p4X, p4Y);bezierPath.close();return bezierPath;}}
最后呢,给出本效果的全部代码,期间由于隔了几天再来继续写这个效果,代码的关键处也补了一点点注释。哈哈,隔了几天没去瞧一眼,差点给我整懵逼了,还好,还好。
这篇关于贝塞尔曲线(Bezier)之 QQ 消息拖拽动画效果的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!