Android教程網
  1. 首頁
  2. Android 技術
  3. Android 手機
  4. Android 系統教程
  5. Android 游戲
 Android教程網 >> Android技術 >> 關於Android編程 >> androidGraphics(十五)——QQ紅點拖動刪除效果實現(基本原理篇)

androidGraphics(十五)——QQ紅點拖動刪除效果實現(基本原理篇)

編輯:關於Android編程

前幾篇給大家講了有關繪圖的知識,這篇我們稍微停一下,來看下手機QQ中拖動刪除的效果是如何實現的;
這篇涉及到的知識有:
- saveLayer圖層相關知識
- Path的貝賽爾曲線
- 手勢監聽
- animationlist逐幀動畫

本篇的效果圖如下:
這裡寫圖片描述
這裡有三個效果點:
1、拉長效果的實現
2、拉的不夠長時返回初始狀態
3、拉的夠長後顯示爆炸消除效果

一、拉伸效果實現

1、實現原理

一上來先給大家講本篇最難的部分,這點理解了,後面就輕松了
本節先實現一個圓圈的拉伸效果,效果圖如下:
這裡寫圖片描述
看起來是不是挺好玩的,跟拉彈弓一樣,這裡主要有兩個效果組成:<喎?/kf/ware/vc/" target="_blank" class="keylink">vcD4NCtDCvNPSu7j2uPrUssimuPrK1ta4zrvWw9LGtq+1xNSyIMG9uPbUstauvOS1xMGsz9/M7rPk08OxtMj8tvvH+s/fDQo8cD7GtL3Tuf2zzMjnz8LNvKO6PGJyIC8+DQo8aW1nIGFsdD0="這裡寫圖片描述" src="/uploadfile/Collfiles/20160608/20160608230806370.png" title="\" />
從上面的拼接圖中可以看出,整個拉伸效果是由兩個圓和中間的貝賽爾曲線連線所組成的矩形所組成的。
下面部分將涉及貝賽爾曲線:
在貝賽爾曲線部分我們已經講了,貝賽爾曲線關鍵地在於控件點的坐標如何動態的確定,我們已經說過貝賽爾曲線的控制點我們可以借助PhtotoShop的鋼筆工具來找;
那我們就來借助鋼筆工具來找一下,如下圖:
這裡寫圖片描述
我們單獨拿出來最終的結果圖來看一下:
這裡寫圖片描述
P0,P1是兩個圓的切線的交點(切點),Q0是二階貝賽爾曲線的控制點。從圖中大概可以看出Q0的位置應該在兩個圓心連線的中點。
在知道兩個圓心點位置以後,Q0點的坐標很容易求得,但是P0,P1的坐標要怎麼來求得現在的當務之急了。
先給大家畫個圖來看求下圖中P0點的坐標
這裡寫圖片描述
這裡演示的是圓形向右下拉的過程(為什麼選擇向右下拉為例來計算坐標我們後面會講),左上角的圓形是初始圓形(圓心坐標是x0,yo),右下角的圓形是拖動後的圓形(圓心坐標是x1,y1);
首先,在這個圖中有四個切點P0,P1,P2,P3;這四個切點的坐標就是我們所要求的。我們這裡以求P0為例來演示下求坐標的過程。
先看P0所在位置所形成的三角形,所在初始圓形的坐標是(x0,y0)
這裡寫圖片描述
我們單獨把這個三角形拿出來,這裡可以很明顯的可以看出P0的坐標是:

x = x0 + r * sina;
y = y0 - r * cosa;

由於屏幕坐標系是X軸向右為正,Y軸向下為正。所以P0的X坐標是比圓形x0坐標大的,所以要加上r * sina;而P0的Y坐標是在圓形y0坐標的上方,比y0小,所以要減去r * cosa;
用同樣的方法可以求出P1,P2,P3的坐標公式:

//P1
x = x1 + r * sina;
y = y1 - r * cosa;

//P2
x = x1 - r * sina;
y = y1 + r * cosa;

//P3
x = x0 - r * sina;
y = y0 + r * cosa;

那麼問題來了,角度a的值是多少呢?
我們再回過頭來看一下我們的高清無碼大圖:
這裡寫圖片描述
tan(a) = dy/dx;
所以a = arctan(dy/dx);
這樣角度a的值就求到了,自然sina和cosa也就得到了。

2、代碼實現

下面我們就來看一下如何用代碼來實現這個手拖動的過程;

注意:這篇博客並不是要制造出來一個通用組件,而是主要為了講解拖動消除的原理,後面我們會逐漸對這篇文章進行擴充,最終將產出一個通用控件!慢慢來吧

(1)、新建類及初始化
由於我們這篇是講解基本原理,所以我們新建一個類派生自FramLayout,然後在這個類中做繪圖等等操作。

public class RedPointView extends FrameLayout {
    private PointF mStartPoint, mCurPoint;
    private int mRadius = 20;
    private Paint mPaint;
    private Path mPath;

    public RedPointView(Context context) {
        super(context);
        initView();
    }

    public RedPointView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initView();
    }

    public RedPointView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        initView();
    }

    private void initView() {

        mStartPoint = new PointF(100, 100);
        mCurPoint = new PointF();

        mPaint = new Paint();
        mPaint.setColor(Color.RED);
        mPaint.setStyle(Paint.Style.FILL);

        mPath = new Path();
    }

}

我們新建了一個RedPointView類派生自FramLayout,然後添加了一個初始化函數:

private void initView() {

    mStartPoint = new PointF(100, 100);
    mCurPoint = new PointF();

    mPaint = new Paint();
    mPaint.setColor(Color.RED);
    mPaint.setStyle(Paint.Style.FILL);

    mPath = new Path();
}

首先是兩個點坐標,分別表示兩個圓的圓心位置。mStartPoint表示起始圓心位置,mCurPoint是當前手指的位置,也就是移動的圓心位置。然後是初始化Paint和Path;
(2)、圓隨著手指移動
這部分的效果圖如下:當手指移動時新畫一個圓在隨著手指移動
這裡寫圖片描述
所以我們要先定義一個變量表示當前用戶的手指是不是下按狀態,如果是下按狀態就根據當前手指的位置多畫一個圓;完整代碼如下:

@Override
protected void dispatchDraw(Canvas canvas) {

    canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);
    canvas.drawCircle(mStartPoint.x, mStartPoint.y, mRadius, mPaint);
    if (mTouch) {
        canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);
    }
    canvas.restore();
    super.dispatchDraw(canvas);
}

@Override
public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN: {
            mTouch = true;
        }
        break;
        case MotionEvent.ACTION_UP: {
            mTouch = false;
        }
    }
    mCurPoint.set(event.getX(), event.getY());
    postInvalidate();
    return true;
}

我們先來看看對onTouchEvent的攔截過程,在onTouchEvent中,在手指下按時將mTouch賦值為true,在手機抬起時賦值為false;
然後將當前手指的位置傳給mCurPoint保存,然後調用postInvalidate()強制重繪;最後return true表示當前消息到此為止,就不再往父控件傳了。
以前我們講過postInvalidate()和invadite()的區別,這裡再簡單說一下:invadite()必須在主線程中調用,而postInvalidate()內部是由Handler的消息機制實現的,所以在任何線程都可以調用,但實時性沒有invadite()強。所以一般為了保險起見,一般是使用postInvalidate()來刷新界面。
然後是dispatchDraw函數,onDraw、dispatchDraw的區別:
這裡寫圖片描述
由於我們這裡是繼承自FrameLayout所以是重寫dispatchDraw()函數來進行重繪
我們來看看dispatchDraw中實現代碼,這裡可謂是有難度:

protected void dispatchDraw(Canvas canvas) {

    canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);
    canvas.drawCircle(mStartPoint.x, mStartPoint.y, mRadius, mPaint);
    if (mTouch) {
        canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);
    }
    canvas.restore();

    super.dispatchDraw(canvas);
}
super.dispatchDraw(canvas)操作的位置問題
首先是super.dispatchDraw(canvas)放的位置很重要,我們有時把它寫在繪圖操作的最上方,有時把它寫在所有繪圖操作的最下方,關於這兩個位置是有很大差別的,有關位置的問題,下面我們會再講,這裡放在哪裡都不會有影響。 canvas.saveLayer()canvas.restore()是Canvas的繪圖操作,最後是畫初始圓和移動圓的位置
canvas.drawCircle(mStartPoint.x, mStartPoint.y, mRadius, mPaint);
if (mTouch) {
    canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);
}

這裡主要是根據當前手指是不是在移動來判斷是不是畫出隨手指移動的圓。代碼難度不大就不再細講了。
到這裡,我們就實現了兩個圓的顯示了,最關鍵的部分來了——下面就是要看如何利用貝賽爾曲線把這兩個圓連接起來。
(3)、貝賽爾曲線連接兩個圓
首先,我們先看如何把路徑給計算出來的:

//圓半徑
private int mRadius = 20;
private void calculatePath() {

    float x = mCurPoint.x;
    float y = mCurPoint.y;
    float startX = mStartPoint.x;
    float startY = mStartPoint.y;
    // 根據角度算出四邊形的四個點
    float dx = x - startX;
    float dy = y - startY;
    double a = Math.atan(dy / dx);
    float offsetX = (float) (mRadius * Math.sin(a));
    float offsetY = (float) (mRadius * Math.cos(a));

    // 根據角度算出四邊形的四個點
    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;

    float anchorX = (startX + x) / 2;
    float anchorY = (startY + y) / 2;

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

先來看這段:

float x = mCurPoint.x;
float y = mCurPoint.y;
float startX = mStartPoint.x;
float startY = mStartPoint.y;
float dx = x - startX;
float dy = y - startY;
double a = Math.atan(dy / dx);
float offsetX = (float) (mRadius * Math.sin(a));
float offsetY = (float) (mRadius * Math.cos(a));

這裡就是根據兩個圓心坐標來計算出dx,dy,然後利用double a = Math.atan(dy / dx)得到夾角a的值,然後求得mRadius * Math.sin(a) 和 mRadius * Math.cos(a)的值;
然後利用我們開篇中得到的公式計算出P0,P1,P2,P3四個切點的坐標:

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;

最後把這四個點連起來:

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

根據我們畫的圖中也可以知道,P0-P1,P2-P3是用貝賽爾曲線連起來的,P1-P2,P3-P0是用直線連起來的;
在我們得到當前的路徑以後,下面就是畫圖的問題了:

protected void dispatchDraw(Canvas canvas) {

    canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);
    canvas.drawCircle(mStartPoint.x, mStartPoint.y, mRadius, mPaint);
    if (mTouch) {
        calculatePath();
        canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);
        canvas.drawPath(mPath, mPaint);
    }
    canvas.restore();

    super.dispatchDraw(canvas);
}

其實就是添加在手指下按時,先用calculatePath()計算路徑然後再利用canvas.drawPath(mPath, mPaint)把路徑畫出來的過程,難度不大就不再講了。
到這裡,我們這節開始時的效果就實現了,效果圖如剛開始時所示:
這裡寫圖片描述
貼出來完整代碼給大家參考下,結尾時會有源碼部分,大家也可以下載

public class RedPointView extends FrameLayout {
    private PointF mStartPoint, mCurPoint;
    private int mRadius = 20;
    private Paint mPaint;
    private Path mPath;
    private boolean mTouch = false;

    public RedPointView(Context context) {
        super(context);
        initView();
    }

    public RedPointView(Context context, AttributeSet attrs) {
        super(context, attrs);
        initView();
    }

    public RedPointView(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        initView();
    }

    private void initView() {

        mStartPoint = new PointF(100, 100);
        mCurPoint = new PointF();

        mPaint = new Paint();
        mPaint.setColor(Color.RED);
        mPaint.setStyle(Paint.Style.FILL);

        mPath = new Path();
    }


    private void calculatePath() {

        float x = mCurPoint.x;
        float y = mCurPoint.y;
        float startX = mStartPoint.x;
        float startY = mStartPoint.y;
        float dx = x - startX;
        float dy = y - startY;
        double a = Math.atan(dy / dx);
        float offsetX = (float) (mRadius * Math.sin(a));
        float offsetY = (float) (mRadius * Math.cos(a));

        // 根據角度算出四邊形的四個點
        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;

        float anchorX = (startX + x) / 2;
        float anchorY = (startY + y) / 2;

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

    @Override
    protected void dispatchDraw(Canvas canvas) {

        canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);
        canvas.drawCircle(mStartPoint.x, mStartPoint.y, mRadius, mPaint);
        if (mTouch) {
            calculatePath();
            canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);
            canvas.drawPath(mPath, mPaint);
        }
        canvas.restore();

        super.dispatchDraw(canvas);
    }


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                mTouch = true;
            }
            break;
            case MotionEvent.ACTION_UP: {
                mTouch = false;
            }
        }
        mCurPoint.set(event.getX(), event.getY());
        postInvalidate();
        return true;
    }
}

源碼在文章底部給出

3、疑問:當手指拖動位置不同時,也能統一處理求得各個點坐標?

細心的同學可能會發現,同樣是P0,P1,P2,P3四個切點,當移動圓的位置變化時,四個點的計算公式是會變化的,我們同樣以P0點為例來看下
當手指移動點在右下方時的公式為
這裡寫圖片描述

x = x0 + r * sina;
y = y0 - r * cosa;

那麼當手指移動點在左上方時,它的公式又為:
圖示為:
這裡寫圖片描述
在變為左上方時,P0點的X坐標就跑到了原點(x0,y0)的左側,從圖像中不難看出P0點的坐標為:

x = x0 - r * sina;
y = y0 - r * cosa;

但是我們在計算時全部都是使用x = x0 + r * sina;這個公式來計算的,明明在這種情況下使用同一個公式是有問題的,但出來的效果為什麼卻是正確的呢?
這是因為Math的三角函數取值是有正有負的,當Math.atan(double value)的入參value是負值是,它對應的輸出的角度值也是負值,同樣,Math.sin(double a) 的輸出值也是負值
所在因為在手指移動點在左上角時,dx正值,dy卻是負值,所以利用a =Math.atan(dy/dx)求得的角度a是負值,進而sina和cos都是負值
這裡其實是用到了正弦函數和余弦函數的幾個性質:

sin(-a) = - sina;
cos(-a) = cosa;
sin(π/2-α) = cosα
cos(π/2-α) = sinα

所以當a值為負值時:

x = x0 + r * sin(-a);
y = y0 - r * cosa;

也就變成了下面的公式了:

x = x0 - r * sina;
y = y0 - r * cosa;

這也是我們為什麼用同一個公式能解決所有情況的原因所在!
但我們在得到這個公式時,必須在保證dx,dy都為正值的情況下,因為此時夾角a必然是正值,不存在數學換算的問題;不然如果dx,dy中任意一個為負數時,夾角a也將是負值,此時你將算得頭大……

二、自定義文字與爆炸效果

上面把最難的拉伸效果實現以後,下面就要完整的來實現開篇的功能了,再來看下最終的效果圖:
這裡寫圖片描述
除了拉伸效果以後,還有一個TextView用來設置文字,另外在超出定長以後消失時會有爆炸效果
我們先來實現添加TextView,然後再添加爆炸效果

1、添加TextView

我們添加TextVIew後所實現的功能的效果圖為:
這裡寫圖片描述
添加TextView後需要添加三個功能:
1、初始只顯示TextView,而不顯示原來的圓圈
2、點擊TextView所在區域才能移動TextVIew
3、移動時,TextView跟隨手指移動,同時顯示原TextVIew所在的圓圈和貝賽爾連接線
本著上面幾個功能點,我們一步步來實現
(1)、添加並初始化TextView
首先,我們要在初始化的時候原布局中添加一個TextView控件:

private TextView mTipTextView;
private void initView() {
    mStartPoint = new PointF(100, 100);
    mCurPoint = new PointF();

    mPath = new Path();

    mPaint = new Paint();
    mPaint.setColor(Color.RED);
    mPaint.setStyle(Paint.Style.FILL);

    LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    mTipTextView = new TextView(getContext());
    mTipTextView.setLayoutParams(params);
    mTipTextView.setPadding(10, 10, 10, 10);
    mTipTextView.setBackgroundResource(R.drawable.tv_bg);
    mTipTextView.setTextColor(Color.GREEN);
    mTipTextView.setText("99+");
    addView(mTipTextView);
}

這段代碼難度不大,就是在原來初始化的基礎上向ViewGroup中添加一個TextVIew控件,並做一些基本的設置。我們這裡把TextView的一些設置都寫死在類內部了,這樣是為了講解方便,但如果要集成為公用控件,當然要把這些設置文字內容和顏色暴露給外部,最簡單的方法就向外部暴露一個getTextView()的方法,把當前TextView的對象直接返回給外部,讓它直接可以設置TextView;
上面的代碼中有一個設置TextView背景的代碼: mTipTextView.setBackgroundResource(R.drawable.tv_bg),對應的xml文件為:



    
    
    

就是給TextView添加帶有圓角的紅色背景,另外還加了個不怎麼黑的描邊;
(2)、點擊TextView時才允許拖動
我們需要在用戶點擊區域在TextView內部時才允許拖動TextView:

public boolean onTouchEvent(MotionEvent event) {
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN: {
            // 判斷觸摸點是否在tipImageView中
            Rect rect = new Rect();
            int[] location = new int[2];
            mTipTextView.getLocationOnScreen(location);
            rect.left = location[0];
            rect.top = location[1];
            rect.right = mTipTextView.getWidth() + location[0];
            rect.bottom = mTipTextView.getHeight() + location[1];
            if (rect.contains((int) event.getRawX(), (int) event.getRawY())) {
                mTouch = true;
            }
        }
        break;
        case MotionEvent.ACTION_UP: {
            //抬起手指時還原位置
            mTouch = false;
        }
        break;
    }
    mCurPoint.set(event.getX(), event.getY());
    postInvalidate();
    return true;
}

這裡主要是在MotionEvent.ACTION_DOWN的時候,判斷當前當前手指區域是否在TextView內部,如果是就將mTouch賦值為true;
這裡涉及的一個函數還沒的一直沒有提及這裡給大家講一下:

public void getLocationOnScreen(int[] location)

該函數的功能是獲取當前控件所在屏幕的位置,傳進去一個location的數組,在執行以後會把left,top值賦給location[0]和location[1]
我們單獨把這段代碼拿出來看一下:

 Rect rect = new Rect();
 int[] location = new int[2];
 mTipTextView.getLocationOnScreen(location);
 rect.left = location[0];
 rect.top = location[1];
 rect.right = mTipTextView.getWidth() + location[0];
 rect.bottom = mTipTextView.getHeight() + location[1];

這段的意思就是拿到當前TextView所在屏幕的位置矩形
然後就是判斷當前手指所在位置是不是在這個矩形內了:

if (rect.contains((int) event.getRawX(), (int) event.getRawY())) {
    mTouch = true;
}

這裡最主要注意的是,我們前面講了getLocationOnScreen()函數得到的位置是屏幕坐標,所以我們也必須拿到手指的屏幕坐標,所以event.getRawX()得到的就是相對屏幕的坐標
以前在博客中也講到過getX與getRawX的區別:getX()得到是相對當前控件左上角的坐標,而getRawX是得到在屏幕中的坐標,在第三部曲中會單獨開一篇來講解有關坐標的知識,大家這裡先知道這兩個函數的用法就好了,第三部曲中會深入地講解。
(3)、繪圖
在繪圖部分,我們需要完成兩個功能:當用戶沒點擊時將TextView設置為原來的位置,當用戶點擊時一方面TextView要跟著手指移動,另一方面要畫出初始圓形
完整的繪圖代碼如下:

@Override
protected void dispatchDraw(Canvas canvas) {
    canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);
    if (mTouch) {
        calculatePath();
        canvas.drawCircle(mStartPoint.x, mStartPoint.y, mRadius, mPaint);
        canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);
        canvas.drawPath(mPath, mPaint);//將textview的中心放在當前手指位置
        mTipTextView.setX(mCurPoint.x - mTipTextView.getWidth() / 2);
        mTipTextView.setY(mCurPoint.y - mTipTextView.getHeight() / 2);
    }else {
        mTipTextView.setX(mStartPoint.x - mTipTextView.getWidth() / 2);
        mTipTextView.setY(mStartPoint.y - mTipTextView.getHeight() / 2);
    }
    canvas.restore();

    super.dispatchDraw(canvas);
}

先看用戶沒有點擊時,把TextView設置在初始的位置點

mTipTextView.setX(mStartPoint.x - mTipTextView.getWidth() / 2);
mTipTextView.setY(mStartPoint.y - mTipTextView.getHeight() / 2);

再看當用戶點擊時的操作:

calculatePath();
canvas.drawCircle(mStartPoint.x, mStartPoint.y, mRadius, mPaint);
canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);
canvas.drawPath(mPath, mPaint);//將textview的中心放在當前手指位置
mTipTextView.setX(mCurPoint.x - mTipTextView.getWidth() / 2);
mTipTextView.setY(mCurPoint.y - mTipTextView.getHeight() / 2);

畫出初始的圓形,手指處的圓形,和兩個圓之間的貝賽爾曲線連接矩形;最後把TextView蓋在手指處的圓形上即可。代碼難度不大就不再細講了。
源碼在文章底部給出
(4)、拉伸時把圓半徑縮小
正常情況下,隨著拉伸長度的增大,兩個圓的半徑是應該逐步就小的;這樣才更符合力學原理是吧,效果圖如下:
這裡寫圖片描述
這個功能非常簡單,只需要在拉伸時,跟根據用戶的拉伸長度,動態的設置當前所畫圓的半徑即可:

private float DEFAULT_RADIUS = 20;
private float mRadius = DEFAULT_RADIUS;
private void calculatePath() {

    float x = mCurPoint.x;
    float y = mCurPoint.y;
    float startX = mStartPoint.x;
    float startY = mStartPoint.y;
    float dx = x - startX;
    float dy = y - startY;
    double a = Math.atan(dy / dx);
    float offsetX = (float) (mRadius * Math.sin(a));
    float offsetY = (float) (mRadius * Math.cos(a));

    float distance = (float) Math.sqrt(Math.pow(y-startY, 2) + Math.pow(x-startX, 2));
    mRadius = DEFAULT_RADIUS - distance/15;
    if(mRadius<9){
        mRadius = 9;
    }

    // 根據角度算出四邊形的四個點
    …………
}

這裡代碼很簡單,就是根據勾股定理(a^2+b^2=c^2)求出兩個圓心之間當前距離,然後按照一定的規則計算出當前的圓半徑,我這裡定的規則就是DEFAULT_RADIUS-distance/15;
但不要一直小到0,因為我們中間的連接線是兩個相同半徑的圓的切線來計算出來的,所以當圓心半徑變小時,兩個圓之間的連接矩形也在變小,所以小到一定程度後,就不能再小了,我這裡這個臨界值定為9;
源碼在文章底部給出
(5)、答疑:super.dispatchDraw(canvas)的位置問題
這裡大家可能會有個疑問,為什麼super.dispatchDraw(canvas)的位置有時候會直接寫在dispatchDraw的下面呢?比如這樣:

void dispatchDraw(Canvas canvas){
    super.dispatchDraw(canvas);
    …………//其它繪圖操作
}

有時候又這麼寫:先做繪圖操作再寫super.dispatchDraw(canvas)

void dispatchDraw(Canvas canvas){
    …………//其它繪圖操作
    super.dispatchDraw(canvas);
}

這兩個到底有什麼差別呢?至於到底有什麼差別,我們得先來看一下super.dispatchDraw(canvas);的作用是什麼;
super.dispatchDraw(canvas);的作用是繪出該控件的所有子控件,所以這樣結論就很明顯了,如果是像第一個那樣先做super.dispatchDraw(canvas);再做其它繪圖操作的結果是,先把子控件繪制出來,然後再畫自己,這樣可能會造成自己把子控件給覆蓋上;
相反,先做其它繪圖操作然後再調用super.dispatchDraw(canvas)的結果是:先把自己給畫出來,然後再畫子控件,子控件會把自己的繪圖結果給覆蓋上;
所以,我們回過頭來看看我們在上面的例子中的代碼:

protected void dispatchDraw(Canvas canvas) {

    canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);
    if (mTouch) {
        calculatePath();
        canvas.drawCircle(mStartPoint.x, mStartPoint.y, mRadius, mPaint);
        canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);
        canvas.drawPath(mPath, mPaint);//將textview的中心放在當前手指位置
        mTipTextView.setX(mCurPoint.x - mTipTextView.getWidth() / 2);
        mTipTextView.setY(mCurPoint.y - mTipTextView.getHeight() / 2);
    }else {
        mTipTextView.setX(mStartPoint.x - mTipTextView.getWidth() / 2);
        mTipTextView.setY(mStartPoint.y - mTipTextView.getHeight() / 2);
    }
    canvas.restore();

    super.dispatchDraw(canvas);
}

在這段代碼中,我們是先繪制自己,然後再繪制它的子控件(TextView),這樣的結果就是TextView會把當前的繪圖內容覆蓋上,如果我把繪圖畫筆改成綠色,就會很明顯,我們來看下效果:
這裡寫圖片描述
然後我們再反過來看一下,如果我們先做super.dispatchDraw(canvas);然後再做自己的繪圖操作,看下效果是怎麼樣的:
代碼如下:

protected void dispatchDraw(Canvas canvas) {
    super.dispatchDraw(canvas);
    canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);
    if (mTouch) {
        calculatePath();
        canvas.drawCircle(mStartPoint.x, mStartPoint.y, mRadius, mPaint);
        canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);
        canvas.drawPath(mPath, mPaint);//將textview的中心放在當前手指位置
        mTipTextView.setX(mCurPoint.x - mTipTextView.getWidth() / 2);
        mTipTextView.setY(mCurPoint.y - mTipTextView.getHeight() / 2);
    }else {
        mTipTextView.setX(mStartPoint.x - mTipTextView.getWidth() / 2);
        mTipTextView.setY(mStartPoint.y - mTipTextView.getHeight() / 2);
    }
    canvas.restore();
}

效果圖如下:
這裡寫圖片描述
很明顯,後來的繪圖操作把子控件給蓋住了,這就是 super.dispatchDraw(canvas)在不同位置的區別!

2、爆炸效果

這裡我們就差最後一個效果了:當用戶手指拉到一定長度松手後,將出來爆炸效果,效果圖如下:
這裡寫圖片描述
(1)、定義逐幀動畫
首先,我們定義一個爆炸效果的動畫(這些圖片資源都是從手機QQ的apk裡解壓出來的,嘿嘿)
圖片資源如下:
這裡寫圖片描述
先添加個逐幀動畫,對應的代碼如下:



    
    
    
    
    
    

(2)、添加ImageView
我們需要添加一個ImageView控件來單獨來播放這個逐幀動畫:

private ImageView exploredImageView;
private void initView() {
    mStartPoint = new PointF(100, 100);
    mCurPoint = new PointF();

    mPath = new Path();

    mPaint = new Paint();
    mPaint.setColor(Color.RED);
    mPaint.setStyle(Paint.Style.FILL);

    LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
    mTipTextView = new TextView(getContext());
    mTipTextView.setLayoutParams(params);
    mTipTextView.setPadding(10, 10, 10, 10);
    mTipTextView.setBackgroundResource(R.drawable.tv_bg);
    mTipTextView.setTextColor(Color.WHITE);
    mTipTextView.setText("99+");


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

    addView(mTipTextView);
    addView(exploredImageView);
}

在InitVIew中添加一個ImageView,並且給將動畫設置給它,值得注意的是剛開始這個ImageView肯定是隱藏的,當需要爆炸效果時才顯示出來
(3)、定值爆炸
在繪圖的時候,我們就要開啟爆炸效果了,上面我們在半徑小於9的時候,一直給它賦值9,現在我們當它小於9時,讓它爆炸:

private void calculatePath() {
    float x = mCurPoint.x;
    float y = mCurPoint.y;
    float startX = mStartPoint.x;
    float startY = mStartPoint.y;
    float dx = x - startX;
    float dy = y - startY;
    double a = Math.atan(dy / dx);
    float offsetX = (float) (mRadius * Math.sin(a));
    float offsetY = (float) (mRadius * Math.cos(a));


    float distance = (float) Math.sqrt(Math.pow(y-startY, 2) + Math.pow(x-startX, 2));
    mRadius = -distance/15+DEFAULT_RADIUS;
    if(mRadius < 9){
        isAnimStart = true;
        exploredImageView.setX(mCurPoint.x - mTipTextView.getWidth() / 2);
        exploredImageView.setY(mCurPoint.y - mTipTextView.getHeight() / 2);
        exploredImageView.setVisibility(View.VISIBLE);
        ((AnimationDrawable) exploredImageView.getDrawable()).start();

        mTipTextView.setVisibility(View.GONE);
    }
    //根據角度算出四邊形的四個點
    …………
}        

這裡只添加了這麼一段話:

if(mRadius < 9){
    isAnimStart = true;
    exploredImageView.setX(mCurPoint.x - mTipTextView.getWidth() / 2);
    exploredImageView.setY(mCurPoint.y - mTipTextView.getHeight() / 2);
    exploredImageView.setVisibility(View.VISIBLE);
    ((AnimationDrawable) exploredImageView.getDrawable()).start();

    mTipTextView.setVisibility(View.GONE);
}

當半徑小於9時,開始爆炸效果,然後聲明一個變量isAnimStart來標識當前爆炸效果開始了;因為當爆炸效果開始以後,後面的繪圖操作就不能再畫圓和貝賽爾曲線了,應該清空當前畫布,只顯示ImageVIew的動畫效果
然後利用setX和setY函數將當前ImageVIew的位置移動到手指的位置,最後是顯示ImageView並開始動畫;
最後是繪圖操作:

protected void dispatchDraw(Canvas canvas) {

    canvas.saveLayer(0,0,getWidth(),getHeight(),mPaint,Canvas.ALL_SAVE_FLAG);

    if (!mTouch || isAnimStart) {
        mTipTextView.setX(mStartPoint.x - mTipTextView.getWidth() / 2);
        mTipTextView.setY(mStartPoint.y - mTipTextView.getHeight() / 2);
    }else {
        calculatePath();
        canvas.drawPath(mPath, mPaint);
        canvas.drawCircle(mStartPoint.x, mStartPoint.y, mRadius, mPaint);
        canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);

        //將textview的中心放在當前手指位置
        mTipTextView.setX(mCurPoint.x - mTipTextView.getWidth() / 2);
        mTipTextView.setY(mCurPoint.y - mTipTextView.getHeight() / 2);
    }
    canvas.restore();

    super.dispatchDraw(canvas);
}

這裡的繪圖操作加上了isAnimStart變量的判斷,當動畫開始或者手指沒在按的時候只顯示TextView,之外的其它操作肯定是用戶在點按TextView,此時需要畫出拉伸效果。
最後,把整體控件的源碼貼給大家,可以自己對照下,整體工程源碼在文章底部給出

public class RedPointControlVIew extends FrameLayout {
    private PointF mStartPoint, mCurPoint;
    private float DEFAULT_RADIUS = 20;
    private float mRadius = DEFAULT_RADIUS;
    private Paint mPaint;
    private Path mPath;
    private boolean mTouch = false;
    private boolean isAnimStart = false;
    private TextView mTipTextView;
    private ImageView exploredImageView;

    public RedPointControlVIew(Context context) {
        super(context);
        initView();
    }

    public RedPointControlVIew(Context context, AttributeSet attrs) {
        super(context, attrs);
        initView();
    }

    public RedPointControlVIew(Context context, AttributeSet attrs, int defStyle) {
        super(context, attrs, defStyle);
        initView();
    }

    private void initView() {
        mStartPoint = new PointF(100, 100);
        mCurPoint = new PointF();

        mPath = new Path();

        mPaint = new Paint();
        mPaint.setColor(Color.RED);
        mPaint.setStyle(Paint.Style.FILL);

        LayoutParams params = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        mTipTextView = new TextView(getContext());
        mTipTextView.setLayoutParams(params);
        mTipTextView.setPadding(10, 10, 10, 10);
        mTipTextView.setBackgroundResource(R.drawable.tv_bg);
        mTipTextView.setTextColor(Color.WHITE);
        mTipTextView.setText("99+");


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

        addView(mTipTextView);
        addView(exploredImageView);
    }

    private void calculatePath() {
        float x = mCurPoint.x;
        float y = mCurPoint.y;
        float startX = mStartPoint.x;
        float startY = mStartPoint.y;
        float dx = x - startX;
        float dy = y - startY;
        double a = Math.atan(dy / dx);
        float offsetX = (float) (mRadius * Math.sin(a));
        float offsetY = (float) (mRadius * Math.cos(a));


        float distance = (float) Math.sqrt(Math.pow(y-startY, 2) + Math.pow(x-startX, 2));
        mRadius = -distance/15+DEFAULT_RADIUS;
        if(mRadius < 9){
            isAnimStart = true;
            exploredImageView.setX(mCurPoint.x - mTipTextView.getWidth() / 2);
            exploredImageView.setY(mCurPoint.y - mTipTextView.getHeight() / 2);
            exploredImageView.setVisibility(View.VISIBLE);
            ((AnimationDrawable) exploredImageView.getDrawable()).start();

            mTipTextView.setVisibility(View.GONE);
        }

        // 根據角度算出四邊形的四個點
        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;

        float anchorX = (startX + x) / 2;
        float anchorY = (startY + y) / 2;

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

    /**
     * onDraw:為什麼要行繪制自己的,然後再調用super.onDraw
     * @param canvas
     */
    @Override
    protected void dispatchDraw(Canvas canvas) {

        canvas.saveLayer(0,0,getWidth(),getHeight(),mPaint,Canvas.ALL_SAVE_FLAG);

        if (!mTouch || isAnimStart) {
            mTipTextView.setX(mStartPoint.x - mTipTextView.getWidth() / 2);
            mTipTextView.setY(mStartPoint.y - mTipTextView.getHeight() / 2);
        }else {
            calculatePath();
            canvas.drawPath(mPath, mPaint);
            canvas.drawCircle(mStartPoint.x, mStartPoint.y, mRadius, mPaint);
            canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);

            //將textview的中心放在當前手指位置
            mTipTextView.setX(mCurPoint.x - mTipTextView.getWidth() / 2);
            mTipTextView.setY(mCurPoint.y - mTipTextView.getHeight() / 2);
        }
        canvas.restore();

        super.dispatchDraw(canvas);
    }


    @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN: {
                // 判斷觸摸點是否在tipImageView中
                Rect rect = new Rect();
                int[] location = new int[2];
                mTipTextView.getLocationOnScreen(location);
                rect.left = location[0];
                rect.top = location[1];
                rect.right = mTipTextView.getWidth() + location[0];
                rect.bottom = mTipTextView.getHeight() + location[1];
                if (rect.contains((int) event.getRawX(), (int) event.getRawY())) {
                    mTouch = true;
                }
            }
            break;
            case MotionEvent.ACTION_UP: {
                //抬起手指時還原位置
                mTouch = false;
            }
            break;
        }

        postInvalidate();
        mCurPoint.set(event.getX(), event.getY());
        return true;
    }
}

好了,這篇文章就到這了,通過這篇文章,簡單復習了下前面學到的動畫和繪圖的知識,後面我們會這個控件進行擴充,逐步把它封裝成共用的控件。
本篇源碼的效果圖為:
這裡寫圖片描述

  1. 上一頁:
  2. 下一頁:
熱門文章
閱讀排行版
Copyright © Android教程網 All Rights Reserved