自定義View之案列篇(三):仿QQ小紅點

光棍節(jié)快到了,提前祝愿廣大的單身猿猴,早日脫單,盡快找到另一半。

一直覺得 QQ 的小紅點非常具有創(chuàng)新,新穎。要是自己也能實現(xiàn)類似的效果,那怎一個爽字了得。

先來看看它的最終效果:

red

效果圖具有哪些效果:

  1. 在拉伸范圍內(nèi)的拉伸效果
  2. 未拉出拉伸范圍釋放后的效果
  3. 拉出拉伸范圍再拉回的釋放后的效果
  4. 拉出拉伸范圍釋放后的爆炸效果

涉及的相關(guān)知識點:

  • onLayout 視圖位置

  • saveLayer 圖層相關(guān)知識

  • Path 的貝賽爾曲線

  • 手勢監(jiān)聽

  • ValueAnimator 屬性動畫

一、拉伸效果

我們先來講解第一個知識點,onLayout 方法:

方法預(yù)覽:

onLayout(boolean changed, int left, int top, int right, int bottom)

我記得我第一次接觸這個方法的時候?qū)竺鎯蓚€參數(shù)是理解錯了,還糾結(jié)了很久。先來看看一張示意圖就一目了然了:

red

那么我們可以得出:

        right = left + view.getWidth();

        bottom = top + view.getHeight();

注意: right 不要理解成視圖控件右邊距離屏幕右邊的距離;bottom 不要理解成視圖控件底部距離屏幕底部的距離。

1、在屏幕中心繪制小圓點

先來啾啾效果圖,非常簡單:

red
public class QQ_RedPoint extends View {

    private Paint mPaint;   //畫筆

    private int mRadius;

    private PointF mCenterPoint;

    public QQ_RedPoint(Context context) {
        this(context, null);
    }

    public QQ_RedPoint(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public QQ_RedPoint(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

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

        mRadius = 20;

        mCenterPoint = new PointF();
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mCenterPoint.x = w / 2;
        mCenterPoint.y = h / 2;
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
        
        canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius, mPaint);
    }
}

2、小圓點的拉伸效果

先來看看拉伸的效果圖:

red

這里就要講解第二個知識點,Path 路徑貝塞爾曲線,如果您對路徑還不了解,請鏈接以下地址:

自定義View之繪圖篇(二):路徑(Path)

拉伸的效果圖右三部分組成:

  • 中心小圓

  • 跟手指移動的小圓

  • 兩個圓之間使用貝塞爾曲線填充

我們把拼接過程放大來看看:

red

圖片鏈接地址

咦,這個形狀好熟悉啊,明明我在什么地方見過。怎么越看越覺得像女生用的姨媽巾呢?原來,QQ 這么有深意。

中間圓的效果已經(jīng)實現(xiàn)了,接著實現(xiàn)跟手指移動的小圓效果:

red

為了實現(xiàn)手指觸摸屏幕跟隨手指移動的小圓效果,重寫 onTouchEvent 方法(事件不往父控件傳遞):

    @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 方法的返回值為 true,若為 false 捕獲不到 ACTION_DOWN 以后的手指狀態(tài)。

自定義View系列教程06--詳解View的Touch事件處理

接著實現(xiàn)貝塞爾曲線填充效果,這也是本篇的難點,后面的實現(xiàn)就輕松。

red

Ps 技術(shù)很菜,希望繪制的草圖能夠幫助到您。

從上效果圖中分析可得:

貝塞爾曲線 P1P2,起點 P1,控制點 C1C2 的中點 Q0,結(jié)束點 P2

那么我們所需要的就是求到 P1 , P2 , Q0 點的坐標(biāo)系,Q0 的坐標(biāo)很容易得到,那么我們怎么來求 P1 , P2 坐標(biāo)呢?下面我畫出了怎么求 P1 , P2 坐標(biāo)的示意圖:

red

根據(jù)示意圖得到:

  P1x = x0 + r * sina
  P1y = y0 - r * cosa  

進(jìn)一步推得,需要求得 P1 的坐標(biāo),需要知道 a 的角度。根據(jù)數(shù)學(xué)公式: tan(a) = dy / dx 。dx,dy 為兩小圓橫縱坐標(biāo)差值。所以推得 a = arctan(dy / dx) 。同理可以求得 P2 , P3 , P4 坐標(biāo)。

代碼實現(xiàn):

P1 , P2 , P3 , P4 的坐標(biāo)為:

        float x = mCurPoint.x;
        float y = mCurPoint.y;

        float startX = mCenterPoint.x;
        float startY = mCenterPoint.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));

        // 根據(jù)角度計算四邊形的四個點
        float p1x = startX + offsetX;
        float p1y = startY - offsetY;

        float p2x = x + offsetX;
        float p2y = y - offsetY;
        
        float p3x = startX - offsetX;
        float p3y = startY + offsetY;
        
        float p4x = x - offsetX;
        float p4y = y + offsetY;

兩小圓圓心連線中點 Q0 的坐標(biāo)(本賽爾曲線控制點坐標(biāo)):

        float controlX = (startX + x) / 2;
        float controlY = (startY + y) / 2;

效果中 Path 的路徑區(qū)域是個封閉的區(qū)域:

        mPath.reset();
        mPath.moveTo(p1x, p1y);
        mPath.quadTo(controlX, controlY, p2x, p2y);
        mPath.lineTo(p4x, p4y);
        mPath.quadTo(controlX, controlY, p3x, p3y);
        mPath.lineTo(p1x, p1y);
        mPath.close();

路徑繪制完畢,我們來看看 onDraw 方法的繪制:

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);

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

        canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius, mPaint);
        if (mTouch) {
            calculatePath();
            canvas.drawCircle(mCurPoint.x, mCurPoint.y, mRadius, mPaint);
            canvas.drawPath(mPath, mPaint);
        }
        canvas.restore();

        super.dispatchDraw(canvas);//繪出該控件的所有子控件
    }

相關(guān) saveLayer , restore 的相關(guān)知識點請連接以下地址。

自定義控件三部曲之繪圖篇(十三)——Canvas與圖層(一)

自定義控件三部曲之繪圖篇(十四)——Canvas與圖層(二)

我超崇拜的啟航大神的博客。

注意:我們在 onTouchEvent 方法中,我們并沒有對多點觸摸進(jìn)行處理。如果你感興趣,請繼續(xù)關(guān)注我的博客。

在 onTouchEvent 方法中調(diào)用的是 postInvalidate() 從新繪制,從新繪制有兩個方法:postInvalidate ,invadite 。
invadite 必須在 UI 線程中調(diào)用,而 postInvalidate 內(nèi)部是由Handler的消息機(jī)制實現(xiàn)的,可以在任何線程中調(diào)用,效率沒有 invadite 高 。

拉伸范圍內(nèi)釋放效果

在拉伸范圍內(nèi)手指釋放后的效果:

red
  • 初始位置只顯示 TextView 控件。替換掉了以前的小圓點。

  • 點擊 TextView 所在區(qū)域才能移動 TextView 。

  • 拖動 TextView 且與中心小圓點以貝塞爾曲線連接形成閉合的路徑。

  • 距離的拉伸,小圓的半徑逐漸減少。

  • 拉伸一定的范圍內(nèi),釋放手指,按著原來的路徑返回,且運動到中心點有反彈效果。

我們挨著來實現(xiàn)以上效果。

顯示TextView

當(dāng)前控件繼承 ViewGroup ,我這里繼承的是 FrameLayout 。我們在初始化的時候添加 TextView 控件:

    private void init() {
        mPaint = new Paint();
        mPaint.setColor(Color.RED);
        mPaint.setAntiAlias(true);
        mPaint.setStyle(Paint.Style.FILL);

        mRadius = 20;

        mCenterPoint = new PointF();
        mCurPoint = new PointF();

        mPath = new Path();

        mDragTextView = new TextView(getContext());
        LayoutParams lp = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT);
        mDragTextView.setLayoutParams(lp);
        mDragTextView.setPadding(10, 10, 10, 10);
        mDragTextView.setBackgroundResource(R.drawable.tv_bg);
        mDragTextView.setText("99+");

        addView(mDragTextView); 
    }

在 FrameLayout 中添加了 mDragTextView 控件,并對 mDragTextView 控件做了一些基礎(chǔ)的設(shè)置。對應(yīng)的 tv_bg 資源文件:

tv_bg.xml:

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <corners android:radius="10dp"/>
    <solid android:color="#ff0000"/>
    <stroke android:color="#0f000000" android:width="1dp"/>
</shape>

我們重寫 dispatchDraw 方法(view 重寫 onDraw 方法 ,viewgroup 重寫 dispatchDraw 方法):

  @Override
    protected void dispatchDraw(Canvas canvas) {

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

        canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius, mPaint);

        canvas.restore();

        super.dispatchDraw(canvas);
    }

效果圖:

red

這里我們需要注意 super.dispatchDraw(canvas); 的位置,放在最后與放在最前效果是不一樣的。

    @Override
    protected void dispatchDraw(Canvas canvas) {
        //....繪制操作
        super.dispatchDraw(canvas);
        //繪制自身然后繪制子元素  可以理解子控件覆蓋在父控件繪制之上
    }

    @Override
    protected void dispatchDraw(Canvas canvas) {
        super.dispatchDraw(canvas);
        //....繪制操作
        //繪制子控件然后繪制自身  可以理解成父控件繪制覆蓋子控件的繪制
    }

例,我這里調(diào)整一下 super.dispatchDraw(canvas) 的位置:

    @Override
    protected void dispatchDraw(Canvas canvas) {
        super.dispatchDraw(canvas);

        mPaint.setColor(Color.GREEN);//主要是為了區(qū)分紅色
        canvas.saveLayer(new RectF(0, 0, getWidth(), getHeight()), mPaint, Canvas.ALL_SAVE_FLAG);
        canvas.drawCircle(mCenterPoint.x, mCenterPoint.y, mRadius, mPaint);
        canvas.restore();
    }

效果圖:

red

點擊TextView拖動效果

點擊 TextView 才能拖動文本,說明要觸摸到 TextView 的矩形區(qū)域。可以通過:

    int x= (int) event.getX();
    int y= (int) event.getY();

     if(x>=mDragTextView.getLeft()&&x<=mDragTextView.getRight()&&y<=mDragTextView.getBottom()
             &&y>=mDragTextView.getTop()){
      mTouch = true;
     }

也可以通過:

                Rect rect = new Rect();
                rect.left = mDragTextView.getLeft();
                rect.top = mDragTextView.getTop();
                rect.right = mDragTextView.getWidth() + rect.left;
                rect.bottom = mDragTextView.getHeight() + rect.top;
                if (rect.contains((int) event.getX(), (int) event.getY())) {
                    mTouch = true;
                }

獲取到所點擊區(qū)域在 TextView 的矩形之內(nèi)。

繪制貝塞爾曲線,形成閉合的路徑

我們已經(jīng)求出了各個點的坐標(biāo),連接形成閉合的路徑。 so easy . . .

    private void calculatePath() {

        float x = mCurPoint.x;
        float y = mCurPoint.y;

        float startX = mCenterPoint.x;
        float startY = mCenterPoint.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));

        // 根據(jù)角度計算四邊形的四個點
        float p1x = startX + offsetX;
        float p1y = startY - offsetY;

        float p2x = x + offsetX;
        float p2y = y - offsetY;

        float p3x = startX - offsetX;
        float p3y = startY + offsetY;

        float p4x = x - offsetX;
        float p4y = y + offsetY;


        float controlX = (startX + x) / 2;
        float controlY = (startY + y) / 2;

        mPath.reset();
        mPath.moveTo(p1x, p1y);
        mPath.quadTo(controlX, controlY, p2x, p2y);
        mPath.lineTo(p4x, p4y);
        mPath.quadTo(controlX, controlY, p3x, p3y);
        mPath.lineTo(p1x, p1y);
        mPath.close();
    }

啾啾效果圖:

red

在拉伸的過程當(dāng)中,小球的大小是沒有變化的。

越拉伸,小球越小

我們可以根據(jù)拉伸的距離動態(tài)改變小球的半徑,來達(dá)到小球變小的效果。

1、計算中心小球與文本的距離(三角函數(shù)):

        float distance = (float) Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2));

2、距離越大,小球半徑越?。?/p>

        int radius = DEFAULT_RADIUS - (int) (distance / 18); //18 根據(jù)拉伸情況
        if (radius < 8) { //拉伸一定值 固定到最小值
            radius = 8;
        }

然后把效果繪制到畫布上面:

    protected void dispatchDraw(Canvas canvas) {

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

看看效果:

red

拉伸范圍內(nèi),釋放手指后的運動效果

手指釋放,在 onTouchEvent方法 MotionEvent.ACTION_UP 中進(jìn)行處理。

1、判定當(dāng)前是否拖動文本:

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

2、在 MotionEvent.ACTION_UP 中開啟釋放的動畫:

    case MotionEvent.ACTION_UP:
        mTouch = false;
        if (mTouchText) {
            startReleaseAnimator();
        }
        break;

3、釋放動畫效果:

    private Animator getReleaseAnimator() {
        final ValueAnimator animator = ValueAnimator.ofFloat(1.0f, 0.0f);
        animator.setDuration(500);
        animator.setRepeatMode(ValueAnimator.RESTART);
        animator.addUpdateListener(new MyAnimatorUpdateListener(this) {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mReleaseValue = (float) animation.getAnimatedValue();
                postInvalidate();
            }
        });
        animator.setInterpolator(new OvershootInterpolator());
        return animator;
    }

有關(guān)屬性動畫的文章,請鏈接以下地址:

自定義控件三部曲之動畫篇(四)——ValueAnimator基本使用

非常經(jīng)典的屬性動畫系列講解。

animator.setInterpolator(new OvershootInterpolator()); 設(shè)置了插值器,OvershootInterpolator 向前甩一定值后再回到原來位置,就可以實現(xiàn)反彈的效果。

有關(guān)插值器的文章,請鏈接以下地址:

自定義控件三部曲之動畫篇(二)——Interpolator插值器

通過 (float) animation.getAnimatedValue() 獲取動畫運到到某一時刻的屬性值,然后刷新界面:

1、根據(jù)屬性值來計算文本的位置:

首先獲取文本距離中心小圓的橫縱坐標(biāo)差值:

        float dx = mCurPoint.x - mCenterPoint.x;
        float dy = mCurPoint.y - mCenterPoint.y;

文本的位置:

    float x = mCurPoint.x - dx * (1.0f - mReleaseValue);
    float y = mCurPoint.y - dy * (1.0f - mReleaseValue);

dx * (1.0f - mReleaseValue) , dy * (1.0f - mReleaseValue) 表示在 x 軸,y 軸上的運動距離,根據(jù)當(dāng)前的位置 - 運到的距離 = 文本的位置

獲取到文本的位置坐標(biāo),又知道中心點坐標(biāo),根據(jù)上面的公式繪制出閉合的貝塞爾曲線,就很容易了。

2、釋放動畫過程中,防止多次拖動文本:

        animator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                super.onAnimationEnd(animation);
                mMoreDragText = true;
            }

            @Override
            public void onAnimationStart(Animator animation) {
                super.onAnimationStart(animation);
                mMoreDragText = false;
            }
        });

拉伸范圍外的效果

拉伸到一定范圍外,然后再拉回來釋放手指,會發(fā)現(xiàn)文本回到了中心并回彈效果;拉伸到范圍外釋放手指,會出現(xiàn)爆炸效果。

red
  • 拉伸到范圍外再拉回釋放效果

  • 拉伸到范圍外釋放爆炸效果

拉伸到范圍外再拉回釋放效果

只要有一次拉伸到范圍外,再拉回來釋放,就不會再繪制中心小圓以及貝塞爾曲線的閉合路徑。所以這里需要一個布爾值的標(biāo)識,只要小圓半徑減少到一定值就把標(biāo)識設(shè)置為 true

        if (mRadius == 8) {
            mOnlyOneMoreThan = true;
        }

在 dispatchDraw 方法里面繪制文本的位置:

    mDragTextView.setX(mCenterPoint.x - mDragTextView.getWidth() / 2);
    mDragTextView.setY(mCenterPoint.y - mDragTextView.getHeight() / 2);

拉伸到范圍外釋放爆炸效果

爆炸效果,是用一張張圖片實現(xiàn)的。我們需要添加一個 ImageView 控件來單獨播放爆炸的圖片,具體步驟如下:

1、新增圖片數(shù)組:

   private int[] mExplodeImages = new int[]{
           R.mipmap.idp,
           R.mipmap.idq, 
           R.mipmap.idr,
           R.mipmap.ids, 
           R.mipmap.idt};  //爆炸的圖片集合

2、新增 ImageView 用于播放爆炸效果:

    mExplodeImage = new ImageView(getContext());
    mExplodeImage.setLayoutParams(lp);
    mExplodeImage.setImageResource(R.mipmap.idp);
    mExplodeImage.setVisibility(View.INVISIBLE);
    
    addView(mExplodeImage);

mExplodeImage 設(shè)置為不占位不可見。

3、范圍外,手指離開,播放爆炸效果:

    private Animator getExplodeAnimator() {
        ValueAnimator animator = ValueAnimator.ofInt(0, mExplodeImages.length - 1);
        animator.setInterpolator(new LinearInterpolator());
        animator.setDuration(1000);
        animator.addUpdateListener(new MyAnimatorUpdateListener(this) {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mExplodeImage.setBackgroundResource(mExplodeImages[(int) animation.getAnimatedValue()]);
            }
        });
        return animator;
    }

mExplodeImage 的位置應(yīng)該是手指離開的位置:

    private void layoutExplodeImage() {
        mExplodeImage.setX(mCurPoint.x - mDragTextView.getWidth() / 2);
        mExplodeImage.setY(mCurPoint.y - mDragTextView.getHeight() / 2);
    }

本篇篇幅比較長,設(shè)計的知識點比較多。若你有什么不懂疑問的地方,還請留言。

最后預(yù)祝各位過個開心的 11、11

源碼地址

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容