【Android開源項目解析】QQ“一鍵下班”功能實現解析——學習Path及貝塞爾曲線的基本使用

早在很久很久以前,QQ就實現了“一鍵下班”功能。何爲“一鍵下班”?當你QQ有信息時,下部會有信息數量提示紅點,點擊拖動之後,就會出現“一鍵下班”效果。本文將結合github上關於此功能的一個簡單實現,介紹這個功能的基本實現思路。

項目地址

https://github.com/chenupt/BezierDemo

最終實現效果

實現原理解析

我個人感覺,這個效果實現的很漂亮啊!那麼咱們就來看看實現原理是什麼~

注:下面內容請參照項目源碼觀看。

其實如果從代碼來看,實現的過程並不複雜,重點需要掌握的就是

  • path的用法
  • 貝塞爾曲線的使用。

這個項目的核心就是BezierView,繼承自FrameLayout,拖動的時候,相當於覆蓋在屏幕上一樣。在init()方法中主要進行了以下操作

private void init(){
        path = new Path();

        paint = new Paint();
        paint.setAntiAlias(true);
        paint.setStyle(Paint.Style.FILL_AND_STROKE);
        paint.setStrokeWidth(2);
        paint.setColor(Color.RED);

        LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        exploredImageView = new ImageView(getContext());
        exploredImageView.setLayoutParams(params);
        exploredImageView.setImageResource(R.drawable.tip_anim);
        exploredImageView.setVisibility(View.INVISIBLE);

        tipImageView = new ImageView(getContext());
        tipImageView.setLayoutParams(params);
        tipImageView.setImageResource(R.drawable.skin_tips_newmessage_ninetynine);

        addView(tipImageView);
        addView(exploredImageView);
    }

初始化了Path和Paint對象,然後動態生成了兩個ImageView

  • exploredImageView 主要用來實現爆炸效果,默認不可見
  • tipImageView 手指進行拖動時的紅色圖標

exploredImageView設置的圖片資源是一個AnimationDrawable,下面是res中的聲明,控制每張圖片的播放順序和持續時間,這也很好理解

<?xml version="1.0" encoding="utf-8"?>
<animation-list
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:oneshot="true">
    <item android:drawable="@drawable/idp" android:duration="300"/>
    <item android:drawable="@drawable/idq" android:duration="300"/>
    <item android:drawable="@drawable/idr" android:duration="300"/>
    <item android:drawable="@drawable/ids" android:duration="300"/>
    <item android:drawable="@drawable/idt" android:duration="300"/>
    <item android:drawable="@android:color/transparent" android:duration="300"/>
</animation-list>

我們在學習這種自定義控件的時候,可以按照View的繪製過程,對代碼進行重點的查看,比如說,我們可以從
下面這個順序來對這個項目進行學習。

  • onMeasure()
  • onLayout()
  • onDraw()
  • onTouchEvent()

因爲這個項目沒有重寫onMeasure(),所以我們直接從onLayout看看做了什麼

 @Override
    protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
        exploredImageView.setX(startX - exploredImageView.getWidth()/2);
        exploredImageView.setY(startY - exploredImageView.getHeight()/2);
        tipImageView.setX(startX - tipImageView.getWidth()/2);
        tipImageView.setY(startY - tipImageView.getHeight()/2);
        super.onLayout(changed, left, top, right, bottom);
    }

代碼還是非常還理解的,無非就是初始化了ImageView的位置,在這裏出現了兩個變量,startX和startY,這兩個變量控制的是紅點的初始化座標,在整個過程中不會發生改變。

那麼onDraw()呢?

@Override
    protected void onDraw(Canvas canvas){
        if(isAnimStart || !isTouch){
            canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.OVERLAY);
        }else{
            calculate();
            canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.OVERLAY);
            canvas.drawPath(path, paint);
            canvas.drawCircle(startX, startY, radius, paint);
            canvas.drawCircle(x, y, radius, paint);
        }
        super.onDraw(canvas);
    }

onDraw()裏面的操作也並不複雜,如果正在執行動畫或者是沒有在觸摸模式,就畫一個透明的顏色,否則,就開始畫真正的界面了。calculate()這個方法是一個重點,從命名來看應該是計算了一些座標值,然後開始畫了兩個圓,這兩個圓的座標,一個是(startX,startY),另一個是(x,y),顏色和半徑都是相同的,這個是爲了簡化計算,所以將兩個圓的半徑設置成相同的啦。

我們先繼續看一下在onTouchEvent()裏面進行了什麼操作

@Override
    public boolean onTouchEvent(MotionEvent event) {
        if(event.getAction() == MotionEvent.ACTION_DOWN){
            // 判斷觸摸點是否在tipImageView中
            Rect rect = new Rect();
            int[] location = new int[2];
            tipImageView.getDrawingRect(rect);
            tipImageView.getLocationOnScreen(location);
            rect.left = location[0];
            rect.top = location[1];
            rect.right = rect.right + location[0];
            rect.bottom = rect.bottom + location[1];
            if (rect.contains((int)event.getRawX(), (int)event.getRawY())){
                isTouch = true;
            }
        }else if(event.getAction() == MotionEvent.ACTION_UP || event.getAction() == MotionEvent.ACTION_CANCEL){
            isTouch = false;
            tipImageView.setX(startX - tipImageView.getWidth()/2);
            tipImageView.setY(startY - tipImageView.getHeight()/2);
        }
        invalidate();
        if(isAnimStart){
            return super.onTouchEvent(event);
        }
        anchorX =  (event.getX() + startX)/2;
        anchorY =  (event.getY() + startY)/2;
        x =  event.getX();
        y =  event.getY();
        return true;
    }

首先在按下的時候,取得了tipImageView的屏幕座標位置,然後根據觸摸點的位置,來判斷是否是觸摸狀態,從而改變isTouch的取值,而如果不是按下時間,則推出改變isTouch,從而觸摸狀態,還原tipImageVIew的位置。但是無論如何,都會執行invalidate(),來調用onDraw(),在那裏面就執行了實際的畫圓的操作,這個咱們一會在看。再往下呢,就是根據動畫狀態是否正在播放,來更新x、y座標,還有anchorX和anchorY的值。

x和y的值其實就是觸摸點的位置,主要用來控制手指所按下的圓的位置,那麼anchorX和anchorY呢?這兩個值其實就是控制錨點的座標,用於貝塞爾曲線的繪製。

說到了這裏,我相信你應該明白實現的基本思路了,但是最重要的,就是拉扯效果,到底是如何實現的呢?那麼咱們就來看一下最重要的calculate()到底做了些什麼!

在看這段代碼之前,咱們先簡單學習一下貝塞爾曲線及如何繪製。

貝塞爾曲線於1962年由法國數學家Pierre Bézier第一次研究使用並給出了詳細的計算公式,So該曲線也是由其名字命名。

Path中給出的quadTo方法屬於二階貝賽爾曲線。
來看下高清無碼GIF動圖,從愛哥那邊偷的,別告訴他^_^

從上面的動圖中,我們可以發現,二階貝塞爾曲線,我們只需要確定三個點,就可以畫出一條平滑的曲線,P0和P2是起點和終點,而P1就是我們的錨點,也就是前面提到的anchorX和anchorY。

那麼問題來了,如果我們要實現這種拖拽拉伸的效果,需要知道幾個點呢?

先來張設計圖

可以看到,在設計圖中,有P1-P4四個座標點,是兩條圓外切線與圓的交點座標,因爲需要P1-P2和P3-P4兩條貝塞爾曲線的歪曲程度相同,所以錨點只需要在P0到原點座標的連線上取一個點即可,所以,咱們就需要5個座標點。

容我喝口水^_^

來來來,咱們繼續!

那麼,既然知道了需要哪五個座標點,anchorX和anchorY在onTouchEvent()裏面已經算出來了,那麼,剩下的4個座標點怎麼求呢?其實這就是calculate()內部所做的主要工作。

由於將兩個圓的半徑設置爲相同,可以精簡計算,所以下面的代碼也是假設兩個圓的半徑相同進行操作的,凱子哥再給你手繪一張高清無碼大圖

startX和startY是指定值,這裏我們以它爲座標原點,另外一個圓的座標爲(x,y),即手指觸摸的位置座標,兩圓半徑相同,則外切線平行,過(x,y)點做垂直線垂直於兩條切線。

現在,已知(startX,startY),(x,y),半徑radius,還有個直角,因此,我們只需要知道一個角度,然後就可以求出offsetX和offsetY,也就求出P1-P4的四點座標了~~~

那麼這個角度好求麼?

簡單,再來張高清無碼大圖~

因爲

  • ∠α=∠3
  • ∠3+∠2=90
  • ∠1+∠2=90

所以
∠α=∠1

這是初中的三段式麼…忘記了

那麼∠1怎麼求呢?簡單啊,(x,y)都知道了,

tan∠1= (y-startY)/(x-startX);

因此可得
∠1 = arctan((y-startY)/(x-startX))

知道角度,知道radius,還求不出offsetX和offsetY麼~

所以

float offsetX = (float) (radius*Math.sin(Math.atan((y - startY) / (x - startX))));
float offsetY = (float) (radius*Math.cos(Math.atan((y - startY) / (x - startX))));

那麼。,,現在再來看下面的代碼,你還說你看不懂嗎?

private void calculate(){
        float distance = (float) Math.sqrt(Math.pow(y-startY, 2) + Math.pow(x-startX, 2));
        radius = -distance/15+DEFAULT_RADIUS;

        if(radius < 9){
            isAnimStart = true;

            exploredImageView.setVisibility(View.VISIBLE);
            exploredImageView.setImageResource(R.drawable.tip_anim);
            ((AnimationDrawable) exploredImageView.getDrawable()).stop();
            ((AnimationDrawable) exploredImageView.getDrawable()).start();

            tipImageView.setVisibility(View.GONE);
        }

        // 根據角度算出四邊形的四個點
        float offsetX = (float) (radius*Math.sin(Math.atan((y - startY) / (x - startX))));
        float offsetY = (float) (radius*Math.cos(Math.atan((y - startY) / (x - startX))));

        float x1 = startX - offsetX;
        float y1 = startY + offsetY;

        float x2 = x - offsetX;
        float y2 = y + offsetY;

        float x3 = x + offsetX;
        float y3 = y - offsetY;

        float x4 = startX + offsetX;
        float y4 = startY - offsetY;

        path.reset();
        path.moveTo(x1, y1);
        path.quadTo(anchorX, anchorY, x2, y2);
        path.lineTo(x3, y3);
        path.quadTo(anchorX, anchorY, x4, y4);
        path.lineTo(x1, y1);

        // 更改圖標的位置
        tipImageView.setX(x - tipImageView.getWidth()/2);
        tipImageView.setY(y - tipImageView.getHeight()/2);
    }

算出4個點的座標,並且知道錨點位置,用path連起來就Ok啦

肚子餓了,這一篇就到這裏了,下去吃飯飯

相關項目及文章

相關項目

-


尊重原創,轉載請註明:From 凱子哥(http://blog.csdn.net/zhaokaiqiang1992) 侵權必究!

關注我的微博,可以獲得更多精彩內容

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章