Android drawPath實現QQ拖拽泡泡

rong424 8年前發布 | 26K 次閱讀 Android Android開發 移動開發

這兩天學習了使用Path繪制貝塞爾曲線相關,然后自己動手做了一個類似QQ未讀消息可拖拽的小氣泡,效果圖如下:

最終效果圖

接下來一步一步的實現整個過程。

基本原理

其實就是使用Path繪制三點的二次方貝塞爾曲線來完成那個妖嬈的曲線的。然后根據觸摸點不斷繪制對應的圓形,根據距離的改變改變原始固定圓形的半徑大小。最后就是松手后返回或者爆裂的實現。

Path介紹:

顧名思義,就是一個路徑的意思,Path里面有很多的方法,本次設計主要用到的相關方法有

moveTo() 移動Path到一個指定的點
quadTo() 繪制二次貝塞爾曲線,接收兩個點,第一個是控制弧度的點,第二個是終點。
lineTo() 就是連線
close() 閉合Path路徑,
reset() 重置Path的相關設置

  • Path入門熱身:

      path.reset();
      path.moveTo(200, 200);
      //第一個坐標是對應的控制的坐標,第二個坐標是終點坐標
      path.quadTo(400, 250, 600, 200);
    
      canvas.drawPath(path, paint);
      canvas.translate(0, 200);
      //調用close,就會首尾閉合連接
      path.close();
      canvas.drawPath(path, paint);

    記得不要在onDraw方法中new Path或者 Paint喲!

Path

具體實現拆分:

其實整個過程就是繪制了兩個貝塞爾二次曲線的的閉合Path路徑,然后在上面添加兩個圓形。

原理圖1

原理圖2

  • 閉合的Path 路徑實現從左上點畫二次貝塞爾曲線到左下點,左下點連線到右下點,右下點二次貝塞爾曲線到右上點,最后閉合一下!!

  • 相關坐標的確定
    這是這次里面的難點之一,因為涉及到了數學里面的一個sin,cos,tan等等,我其實也忘完了,然后又腦補了一下,廢話不多說,直接上圖!!

    旋轉過程的角標

為什么自己要親自去畫一下呢,因為畫了你才知道,在360旋轉的過程中,角標體系是有兩套的,如果就使用一套來畫的話,就畫出現在旋轉的過程中曲線重疊在一起的情況!

問題已經拋出來了,接下來直接看看代碼實現!

角度確定

根據貼出來的原理圖可以知道,我們可以使用起始圓心坐標和拖拽的圓心坐標,根據反正切函數來得到具體的弧度。

int dy = Math.abs(CIRCLEY - startY);
int dx = Math.abs(CIRCLEX - startX);
 angle = Math.atan(dy * 1.0 / dx);

ok,這里的startX,Y就是移動過程中的坐標。angle就是得到的對應的弧度(角度)。

相關Path繪制

前面已經提到在旋轉的過程中有兩套坐標體系,一開始我也很糾結這個坐標體系要怎么確定,后面又恍然大悟,其實相當于就是一三象限正比例增長,二四象限,反比例增長。

 flag = (startY - CIRCLEY  ) * (startX- CIRCLEX ) <= 0;
 //增加一個flag,用于判斷使用哪種坐標體系。

最最重要的來了,繪制相關的Path路徑!

 path.reset();
 if (flag) {
     //第一個點
 path.moveTo((float) (CIRCLEX - Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY - Math.cos(angle) * ORIGIN_RADIO));

 path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (startX - Math.sin(angle) * DRAG_RADIO), (float) (startY - Math.cos(angle) * DRAG_RADIO));
path.lineTo((float) (startX + Math.sin(angle) * DRAG_RADIO), (float) (startY + Math.cos(angle) * DRAG_RADIO));

path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (CIRCLEX + Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY + Math.cos(angle) * ORIGIN_RADIO));
path.close();
canvas.drawPath(path, paint);
 } else {
     //第一個點
     path.moveTo((float) (CIRCLEX - Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY + Math.cos(angle) * ORIGIN_RADIO));

     path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (startX - Math.sin(angle) * DRAG_RADIO), (float) (startY + Math.cos(angle) * DRAG_RADIO));
     path.lineTo((float) (startX + Math.sin(angle) * DRAG_RADIO), (float) (startY - Math.cos(angle) * DRAG_RADIO));

     path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (CIRCLEX + Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY - Math.cos(angle) * ORIGIN_RADIO));
     path.close();
     canvas.drawPath(path, paint);
 }

這里的代碼就是把圖片上相關的數學公式Java化而已!

到這里,其實主要的工作就完成的差不多了!
接下來,設置paint 為填充的效果,最后再畫兩個圓

 paint.setStyle(Paint.Style.FILL)
 canvas.drawCircle(CIRCLEX, CIRCLEY, ORIGIN_RADIO, paint);//默認的
 canvas.drawCircle(startX == 0 ? CIRCLEX : startX, startY == 0 ? CIRCLEY : startY, DRAG_RADIO, paint);//拖拽的

就可以繪制出想要的效果了!

這里不得不再說說onTouch的處理!

  case MotionEvent.ACTION_DOWN://有事件先攔截再說!!
            getParent().requestDisallowInterceptTouchEvent(true);
            CurrentState = STATE_IDLE;
            animSetXY.cancel();
            startX = (int) ev.getX();
            startY = (int) ev.getRawY();
            break;

處理一下事件分發的坑!

測量和布局

這樣基本過得去了,但是我們的布局什么的還沒有處理,math_parent是萬萬沒法使用到具體項目當中去的!
測量的時候,如果發現不是精準模式,那么都手動去計算出需要的寬度和高度。

 @Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

    int modeWidth = MeasureSpec.getMode(widthMeasureSpec);
    int modeHeight = MeasureSpec.getMode(heightMeasureSpec);
    if (modeWidth == MeasureSpec.UNSPECIFIED || modeWidth == MeasureSpec.AT_MOST) {
        widthMeasureSpec = MeasureSpec.makeMeasureSpec(DEFAULT_RADIO * 2, MeasureSpec.EXACTLY);
    }
    if (modeHeight == MeasureSpec.UNSPECIFIED || modeHeight == MeasureSpec.AT_MOST) {
        heightMeasureSpec = MeasureSpec.makeMeasureSpec(DEFAULT_RADIO * 2, MeasureSpec.EXACTLY);
    }
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
}

然后在布局變化時,獲取相關坐標,確定初始圓心坐標:

@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
    super.onSizeChanged(w, h, oldw, oldh);
    CIRCLEX = (int) ((w) * 0.5 + 0.5);
    CIRCLEY = (int) ((h) * 0.5 + 0.5);
}

然后清單文件里面就可以這樣配置了:

 <com.lovejjfg.circle.DragBubbleView
    android:id="@+id/dbv"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center"/>

這樣之后,又會出現一個問題,那就是wrap_content 之后,這個View能繪制的區域只有自身那么大了,拖拽了都看不見了!這個坑怎么辦呢,其實很簡單,父布局加上android:clipChildren="false" 的屬性!
這個坑也算是解決了!!

相關狀態的確定

我們是不希望它可以無限的拖拽的,就是有一個拖拽的最遠距離,還有就是放手后的返回,爆裂。那么對應的,這里需要確定幾種狀態:

  private final static int STATE_IDLE = 1;//靜止的狀態
    private final static int STATE_DRAG_NORMAL = 2;//正在拖拽的狀態
    private final static int STATE_DRAG_BREAK = 3;//斷裂后的拖拽狀態
    private final static int STATE_UP_BREAK = 4;//放手后的爆裂的狀態
    private final static int STATE_UP_BACK = 5;//放手后的沒有斷裂的返回的狀態
    private final static int STATE_UP_DRAG_BREAK_BACK = 6;//拖拽斷裂又返回的狀態
    private int CurrentState = STATE_IDLE;

private int MIN_RADIO = (int) (ORIGIN_RADIO * 0.4);//最小半徑
    private int MAXDISTANCE = (int) (MIN_RADIO * 13);//最遠的拖拽距離

確定好這些之后,在move的時候,就要去做相關判斷了:

  case MotionEvent.ACTION_MOVE://移動的時候
            startX = (int) ev.getX();
            startY = (int) ev.getY();

            updatePath();
            invalidate();
            break;

private void updatePath() {
    int dy = Math.abs(CIRCLEY - startY);
    int dx = Math.abs(CIRCLEX - startX);

    double dis = Math.sqrt(dy * dy + dx * dx);
    if (dis <= MAXDISTANCE) {//增加的情況,原始半徑減小
        if (CurrentState == STATE_DRAG_BREAK || CurrentState == STATE_UP_DRAG_BREAK_BACK) {
            CurrentState = STATE_UP_DRAG_BREAK_BACK;
        } else {
            CurrentState = STATE_DRAG_NORMAL;
        }
        ORIGIN_RADIO = (int) (DEFAULT_RADIO - (dis / MAXDISTANCE) * (DEFAULT_RADIO - MIN_RADIO));
        Log.e(TAG, "distance: " + (int) ((1 - dis / MAXDISTANCE) * MIN_RADIO));
        Log.i(TAG, "distance: " + ORIGIN_RADIO);
    } else {
        CurrentState = STATE_DRAG_BREAK;
    }
//        distance = dis;
    flag = (startY - CIRCLEY) * (startX - CIRCLEX) <= 0;
    Log.i("TAG", "updatePath: " + flag);
    angle = Math.atan(dy * 1.0 / dx);
}

updatePath() 的方法之前已經看過部分了,這次的就是完整的。
這里做的事就是根據拖拽的距離更改相關的狀態,并根據百分比來修改原始圓形的半徑大小。還有就是之前介紹的確定相關的弧度!

最后放手的時候:

   case MotionEvent.ACTION_UP:
            if (CurrentState == STATE_DRAG_NORMAL) {
                CurrentState = STATE_UP_BACK;
                valueX.setIntValues(startX, CIRCLEX);
                valueY.setIntValues(startY, CIRCLEY);
                animSetXY.start();
            } else if (CurrentState == STATE_DRAG_BREAK) {
                CurrentState = STATE_UP_BREAK;
                invalidate();
            } else {
                CurrentState = STATE_UP_DRAG_BREAK_BACK;
                valueX.setIntValues(startX, CIRCLEX);
                valueY.setIntValues(startY, CIRCLEY);
                animSetXY.start();
            }
            break;

自動返回這里使用到的 ValueAnimator

 animSetXY = new AnimatorSet();

    valueX = ValueAnimator.ofInt(startX, CIRCLEX);
    valueY = ValueAnimator.ofInt(startY, CIRCLEY);
    animSetXY.playTogether(valueX, valueY);
    valueX.setDuration(500);
    valueY.setDuration(500);
    valueX.setInterpolator(new OvershootInterpolator());
    valueY.setInterpolator(new OvershootInterpolator());
    valueX.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            startX = (int) animation.getAnimatedValue();
            Log.e(TAG, "onAnimationUpdate-startX: " + startX);
            invalidate();
        }

    });
    valueY.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
        @Override
        public void onAnimationUpdate(ValueAnimator animation) {
            startY = (int) animation.getAnimatedValue();
            Log.e(TAG, "onAnimationUpdate-startY: " + startY);
            invalidate();

        }
    });

最后在看看完整的onDraw方法吧!

 @Override
protected void onDraw(Canvas canvas) {
    switch (CurrentState) {
        case STATE_IDLE://空閑狀態,就畫默認的圓
            if (showCircle) {
                canvas.drawCircle(CIRCLEX, CIRCLEY, ORIGIN_RADIO, paint);//默認的
            }
            break;
        case STATE_UP_BACK://執行返回的動畫
        case STATE_DRAG_NORMAL://拖拽狀態 畫貝塞爾曲線和兩個圓
            path.reset();
            if (flag) {
                //第一個點
                path.moveTo((float) (CIRCLEX - Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY - Math.cos(angle) * ORIGIN_RADIO));

                path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (startX - Math.sin(angle) * DRAG_RADIO), (float) (startY - Math.cos(angle) * DRAG_RADIO));
                path.lineTo((float) (startX + Math.sin(angle) * DRAG_RADIO), (float) (startY + Math.cos(angle) * DRAG_RADIO));

                path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (CIRCLEX + Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY + Math.cos(angle) * ORIGIN_RADIO));
                path.close();
                canvas.drawPath(path, paint);
            } else {
                //第一個點
                path.moveTo((float) (CIRCLEX - Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY + Math.cos(angle) * ORIGIN_RADIO));

                path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (startX - Math.sin(angle) * DRAG_RADIO), (float) (startY + Math.cos(angle) * DRAG_RADIO));
                path.lineTo((float) (startX + Math.sin(angle) * DRAG_RADIO), (float) (startY - Math.cos(angle) * DRAG_RADIO));

                path.quadTo((float) ((startX + CIRCLEX) * 0.5), (float) ((startY + CIRCLEY) * 0.5), (float) (CIRCLEX + Math.sin(angle) * ORIGIN_RADIO), (float) (CIRCLEY - Math.cos(angle) * ORIGIN_RADIO));
                path.close();
                canvas.drawPath(path, paint);
            }
            if (showCircle) {
                canvas.drawCircle(CIRCLEX, CIRCLEY, ORIGIN_RADIO, paint);//默認的
                canvas.drawCircle(startX == 0 ? CIRCLEX : startX, startY == 0 ? CIRCLEY : startY, DRAG_RADIO, paint);//拖拽的
            }
            break;

        case STATE_DRAG_BREAK://拖拽到了上限,畫拖拽的圓:
        case STATE_UP_DRAG_BREAK_BACK:
            if (showCircle) {
                canvas.drawCircle(startX == 0 ? CIRCLEX : startX, startY == 0 ? CIRCLEY : startY, DRAG_RADIO, paint);//拖拽的
            }
            break;

        case STATE_UP_BREAK://畫出爆裂的效果
            canvas.drawCircle(startX - 25, startY - 25, 10, circlePaint);
            canvas.drawCircle(startX + 25, startY + 25, 10, circlePaint);
            canvas.drawCircle(startX, startY - 25, 10, circlePaint);
            canvas.drawCircle(startX, startY, 18, circlePaint);
            canvas.drawCircle(startX - 25, startY, 10, circlePaint);
            break;

    }


}

到這里,成品就出來了!!

總結:

1、確定默認圓形的坐標;
2、根據move的情況,實時獲取最新的坐標,根據移動的距離(確定出角度),更新相關的狀態,畫出相關的Path路徑。超出上限,不再畫Path路徑。
3、松手時,根據相關的狀態,要么帶Path路徑執行動畫返回,要么不帶Path路徑直接返回,要么直接爆裂!

下一篇 Android drawTextOnPath 水果忍者跳動的文字

相關源碼請移步Github,喜歡就請Start或者 fork一下吧,有問題歡迎留言或者issue。。


文/lovejjfg(簡書)
 

 本文由用戶 rong424 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!