貝塞爾曲線常用動畫原理學習--萬字長文,全面解析

語言: CN / TW / HK

前言

在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 {

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效果

還是那句話,學習開源庫,原理和思路最重要。