自定義View之QQ小紅點(一)

前言

之前沒有見到有封裝好的類似QQ小紅點的控件,雖然公司項目中并沒有使用到該效果,不過出于練習與回顧的角度決定自己動手寫一個。

貝塞爾曲線

在開始動手寫之前,我先介紹一下貝塞爾曲線。貝賽爾曲線(Bézier曲線)是電腦圖形學中相當重要的參數曲線。更高維度的廣泛化貝塞爾曲線就稱作貝塞爾曲面,其中貝塞爾三角是一種特殊的實例。貝塞爾曲線于1962年,由法國工程師皮埃爾·貝塞爾(Pierre Bézier)所廣泛發表,他運用貝塞爾曲線來為汽車的主體進行設計。貝塞爾曲線最初由Paul de Casteljau于1959年運用de Casteljau算法開發,以穩定數值的方法求出貝塞爾曲線。

線性貝塞爾曲線

給定點P0、P1,線性貝塞爾曲線只是一條兩點之間的直線。這條線由下式給出:


這里寫圖片描述
這里寫圖片描述

二次方貝塞爾曲線

二次方貝塞爾曲線的路徑由給定點P0、P1、P2的函數B(t)追蹤:


這里寫圖片描述
這里寫圖片描述
這里寫圖片描述

三次方貝塞爾曲線

P0、P1、P2、P3四個點在平面或在三維空間中定義了三次方貝塞爾曲線。曲線起始于P0走向P1,并從P2的方向來到P3。一般不會經過P1或P2;公式如下:


這里寫圖片描述
這里寫圖片描述
這里寫圖片描述

N次方貝塞爾曲線

這里寫圖片描述
這里寫圖片描述

貝塞爾曲線代碼實現

Android在API=1的時候就提供了貝塞爾曲線的畫法,只是隱藏在Path#quadTo()Path#cubicTo()方法中,一個是二階貝塞爾曲線,一個是三階貝塞爾曲線。當然,如果你想自己寫個方法,依照上面貝塞爾的表達式也是可以的。不過一般沒有必要,因為Android已經在native層為我們封裝好了二階和三階的函數。

效果分析

首先咱們來看看QQ的效果圖,如下:


這里寫圖片描述

看到這個效果圖,結合上述對貝塞爾的簡單描述。你是否已經有想法了?如果有那么請跟著我繼續驗證你的想法,如果沒有請容我向你娓娓道來。

1.首先確定有兩個點,這兩個點是不一樣的。下面是原始紅點,上面的手勢拖拽之后產生的紅點。原始點大小會隨著拖拽的距離而逐漸變大,并且當達到閥值會消失。而拖拽點大小始終與原始大小一致保持不變。


這里寫圖片描述

2.兩點之間的曲線效果。如下圖,兩條線段,其實就是兩條二階貝塞爾曲線。咱們只要分析出其中一條便可,就拿線1來說吧。貝塞爾曲線1的起點是兩個小圓與大圓在某一側的外切點,控制點是兩圓點構成的線段的中心點。


這里寫圖片描述

3.如何實現動態變化?這個就好說了,手指移動的時候,小圓不斷變化,切點自然也在變,并且兩個圓的中心距離位置也是隨著改變。

代碼實現

梳理完畢之后,咱們就開始擼代碼吧。再來回顧前面說的核心內容:1.貝塞爾曲線知識;2.兩個圓的變化,以及利用貝塞爾曲線繪制兩個圓拉動的效果,并且隨著距離變化而變化。OK,下面開始看代碼

首先創建類DragPointView并且繼承View

public class DragPointView extends View {...}

成員變量

這里為了簡便,一些可配屬性直接寫死。后續完善的時候會將屬性抽離出來,供使用者可以通過自定義屬性控制該控件的樣式。

    public static final int DEFAULT_WIDTH = 23; // 默認寬度 單位:dp
    public static final int DEFAULT_HEIGHT = 23; // 默認高度 單位:dp

    private Paint mPaint; // 畫筆 繪制的是圓和貝塞爾曲線路徑 外貌一樣
    private Path mPath; // 存儲貝塞爾曲線
    private int width,height; // 控件寬高
    private boolean isInCircle; // 判斷DOWN事件是否有效
    private float downX,downY; // 按下的位置
    private PointF[] mDragTangentPoint; // 兩個圓切點中位于拖拽圓上的兩個點
    private PointF[] mCenterTangentPoint; // 兩個圓切點中位于初始圓上的兩個點
    private PointF mCenterCircle; // 初始圓圓心
    private PointF mCenterCircleCopy; // 初始圓圓心copy
    private PointF mDragCircle; // 拖拽圓圓心
    private PointF mDragCircleCopy; // 拖拽圓圓心copy
    private double mDistanceCircles; // 兩個圓心距離
    private PointF mControlPoint; // 貝塞爾曲線控制點 兩條曲線控制點一致
    private boolean mIsOut; // 拖拽是否超出范圍
    private ValueAnimator mRecoveryAnim; // 沒超過范圍時圓的恢復動畫

    // 初始半徑,運動時初始圓半徑,拖拽圓半徑
    private float mRadius, mRatioRadius, mDragRadius;
    private int mDragLength = 500; // 最長允許拖拽長度
    private float mDragMinRatio = 0.5f; // 初始圓允許最小比
    private long mRecoveryDuration = 200l; // 恢復動畫時長
    private long mFrameDuration = 200l; // 氣泡幀動畫時長

    /**
     * 初始化操作
     */
    private void init() {
        mPath = new Path();
        mPaint = new Paint();
        mPaint.setStyle(Paint.Style.FILL);
        mPaint.setTextSize(18f);
        mPaint.setColor(0xffff0000);
        mPaint.setAntiAlias(true);
        mPaint.setDither(true);
        mDragRadius = mRadius = DensityUtil.dip2px(getContext(),23)/2;
        mDragTangentPoint = new PointF[2];
        mCenterTangentPoint = new PointF[2];
        mControlPoint = new PointF();
        mCenterCircle = new PointF();
        mCenterCircleCopy = new PointF();
        mDragCircle = new PointF();
        mDragCircleCopy = new PointF();
    }

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

測量過程

自定義控件時候要注意處理寬高為MeasureSpec.AT_MOST的情況,一般做法:設置默認寬高或者根據內容需要設置寬高。此外,由于咱們的自定義控件是直接繼承View,如果需要的話還得為其支持Padding屬性(這里簡單說一下怎么支持,主要兩處:1.測量的時候要考慮padding 2.繪制的時候考慮padding),小紅點這個控件我覺得就沒必要了哈。略過~

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        int heightSize = MeasureSpec.getSize(heightMeasureSpec);
        int widthSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        int widthMode = MeasureSpec.getMode(widthMeasureSpec);
        if(heightMode == MeasureSpec.AT_MOST){
            heightSize = DensityUtil.dip2px(getContext(),DEFAULT_HEIGHT);
        }
        if(widthMode == MeasureSpec.AT_MOST){
            widthSize = DensityUtil.dip2px(getContext(),DEFAULT_WIDTH);
        }
        setMeasuredDimension(widthSize,heightSize);
    }

繪制過程

代碼很簡練,主要是就三個方法。大概說一下邏輯。drawCenterCircle判斷若沒有拖拽超出范圍則繪制初始圓,并且圓的半徑是要根據mDistanceCircles、mDragLength以及mDragMinRatio實時計算的。drawDragCircle就沒有太多邏輯了。drawBezierLine中將獲取到的四個切點配合貝塞爾曲線連接為封閉空間后繪制即可。這里先理清邏輯就行,后續到具體方法具體分析~

    @Override
    protected void onDraw(Canvas canvas) {
        drawCenterCircle(canvas);
        if(isInCircle){
            drawDragCircle(canvas);
            drawBezierLine(canvas);
        }
    }

繪制初始圓

首先,如果此時手勢動作已超出定義的范圍。那么直接return~
否則,計算出應該繪制的圓的半徑,簡單的比例乘除~

    private void drawCenterCircle(Canvas canvas) {
        if(mIsOut) return;
        mRatioRadius = mRadius;
        if(isInCircle && mDragMinRatio < 1.f){
            mRatioRadius = (float) (Math.max((mDragLength - mDistanceCircles)*1.f/ mDragLength, mDragMinRatio) * mRadius);
        }
        canvas.drawCircle(mCenterCircle.x,mCenterCircle.y, mRatioRadius,mPaint);
    }

繪制拖拽圓

    private void drawDragCircle(Canvas canvas) {
        canvas.drawCircle(mDragCircle.x, mDragCircle.y, mRadius,mPaint);
    }

繪制貝塞爾曲線部分

這里寫圖片描述

首先,找到兩個圓形成的4個切點p1,p2,p3,p4,此處使用的方式通過指定圓心與一條已知斜率的直線去獲取切點數學不好的我也沒辦法啦,因為我也懵懵懂懂數學什么的忘得差不多了,但是請記住,這不是重點,哈哈。

其次,找到控制點,有人說了:“這個我會”~

最后,將四個點用Path連接并繪制,切記最后要使路徑為閉合空間。p3-p4直連,p4-p1貝塞爾曲線,p1-p2直連,p2-p3貝塞爾曲線~ OK

    private void drawBezierLine(Canvas canvas) {
        if(mIsOut) return;
        float dx = mDragCircle.x - mCenterCircle.x;
        float dy = mDragCircle.y - mCenterCircle.y;
        // 控制點
        mControlPoint.set((mDragCircle.x + mCenterCircle.x) / 2,
                (mDragCircle.y + mCenterCircle.y) / 2);
        // 四個切點
        if (dx != 0) {
            float k1 = dy / dx;
            float k2 = -1 / k1;
            mDragTangentPoint = MathUtil.getIntersectionPoints(
                    mDragCircle.x, mDragCircle.y, mDragRadius, (double) k2);
            mCenterTangentPoint = MathUtil.getIntersectionPoints(
                    mCenterCircle.x, mCenterCircle.y, mRatioRadius, (double) k2);
        } else {
            mDragTangentPoint = MathUtil.getIntersectionPoints(
                    mDragCircle.x, mDragCircle.y, mDragRadius, (double) 0);
            mCenterTangentPoint = MathUtil.getIntersectionPoints(
                    mCenterCircle.x, mCenterCircle.y, mRatioRadius, (double) 0);
        }
        // 路徑構建
        mPath.reset();
        mPath.moveTo(mCenterTangentPoint[0].x, mCenterTangentPoint[0].y);
        mPath.quadTo(mControlPoint.x, mControlPoint.y,mDragTangentPoint[0].x,mDragTangentPoint[0].y);
        mPath.lineTo(mDragTangentPoint[1].x, mDragTangentPoint[1].y);
        mPath.quadTo(mControlPoint.x, mControlPoint.y,
                mCenterTangentPoint[1].x, mCenterTangentPoint[1].y);
        mPath.close();
        canvas.drawPath(mPath,mPaint);
    }

    /**
     * Get the point of intersection between circle and line.
     * 獲取 通過指定圓心,斜率為lineK的直線與圓的交點。
     *
     * @param radius The circle radius.
     * @param lineK The slope of line which cross the pMiddle.
     * @return
     */
    public static PointF[] getIntersectionPoints(float cx,float cy, float radius, Double lineK) {
        PointF[] points = new PointF[2];

        float radian, xOffset = 0, yOffset = 0;
        if(lineK != null){

            radian= (float) Math.atan(lineK);
            xOffset = (float) (Math.cos(radian) * radius);
            yOffset = (float) (Math.sin(radian) * radius);
        }else {
            xOffset = radius;
            yOffset = 0;
        }
        points[0] = new PointF(cx + xOffset, cy + yOffset);
        points[1] = new PointF(cx - xOffset, cy - yOffset);

        return points;
    }

這時候大家有疑問啦,LZ是不是把觸摸事件給漏了~,漏不了,這就來

觸摸事件

DOWN,MOVE,CANCEL,UP事件,需要做什么?重繪是肯定的~

DOWN事件:判斷點擊位置是否處于初始圓范圍內,此處直接判斷的是否位于圓的外切矩形內,當然如果你非要往細的扣,可以使用Region

MOVE事件:記錄拖拽圓的圓心位置,即事件位置。計算兩個圓心的距離,并且判斷是否超過閥值啦。

CANCEL與UP事件:無非就是越界與否的判斷,加上不同結果對應的動畫效果

這里想到個事,如果的長動畫或者其他類似的,記得在onDetachedFromWindow方法中處理一下,否則造成內存泄漏

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if(mRecoveryAnim ==null || !mRecoveryAnim.isRunning()) {
            switch (event.getAction()) {
                case MotionEvent.ACTION_DOWN:
                    downX = event.getX();
                    downY = event.getY();
                    isInCircle = isInPointCircle(downX, downY);
                    postInvalidate();
                    break;
                case MotionEvent.ACTION_MOVE:
                    mDragCircle.x = event.getX();
                    mDragCircle.y = event.getY();
                    mDistanceCircles = MathUtil.getDistance(mCenterCircle, mDragCircle);
                    mIsOut = mIsOut ? mIsOut : mDistanceCircles > mDragLength;
                    postInvalidate();
                    break;
                case MotionEvent.ACTION_UP:
                case MotionEvent.ACTION_CANCEL:
                    upAndCancelEvent();
                    break;
            }
        }
        return true;
    }

    /**
     *  Gets the distance between two points.
     *  獲取兩點之間的距離
     *
     * @param p1
     * @param p2
     * @return
     */
    public static double getDistance(PointF p1,PointF p2){
        return Math.sqrt((p1.x-p2.x)*(p1.x-p2.x)+(p1.y-p2.y)*(p1.y-p2.y));
    }

    private void upAndCancelEvent() {
        if(isInCircle && mDistanceCircles == 0) {
            reset();
        }else if(!mIsOut){
            mCenterCircleCopy.set(mCenterCircle.x, mCenterCircle.y);
            mDragCircleCopy.set(mDragCircle.x, mDragCircle.y);
            if(mRecoveryAnim == null){
                mRecoveryAnim = ValueAnimator.ofFloat(0.f,1.5f);
                mRecoveryAnim.setDuration(mRecoveryDuration);
                mRecoveryAnim.setInterpolator(new AccelerateInterpolator());
                mRecoveryAnim.addUpdateListener(this);
                mRecoveryAnim.addListener(this);
            }
            mRecoveryAnim.start();
        }else{
            reset();
        }
    }

    @Override
    public void onAnimationUpdate(ValueAnimator valueAnimator) {
        float value = (float) valueAnimator.getAnimatedValue();
        mDragCircle.x = mDragCircleCopy.x + (mCenterCircleCopy.x - mDragCircleCopy.x)*value;
        mDragCircle.y = mDragCircleCopy.y + (mCenterCircleCopy.y - mDragCircleCopy.y)*value;
        postInvalidate();
    }

    @Override
    public void onAnimationEnd(Animator animator) {
        reset();
    }

    private void reset() {
        mIsOut = false;
        isInCircle = false;
        mDragCircle.x = mCenterCircle.x;
        mDragCircle.y = mCenterCircle.y;
        mDistanceCircles = 0;
        postInvalidate();
    }

效果圖

這里寫圖片描述

總結

1.貝塞爾曲線簡單介紹
2.QQ小紅點效果分析
3.將分析所得代碼實現

tip:目前只是小demo,諸多地方不完善。后續會考慮封裝成開源庫放到github上

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,563評論 6 544
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,694評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,672評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,965評論 1 318
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,690評論 6 413
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 56,019評論 1 329
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 44,013評論 3 449
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,188評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,718評論 1 336
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,438評論 3 360
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,667評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,149評論 5 365
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,845評論 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,252評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,590評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,384評論 3 400
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,635評論 2 380