高仿qq‘一鍵下班’—讓你的view‘黏’起來

qq手機客戶端自5.0起有一個‘一鍵下班’的功能,qq聊天的消息數view可以拖拽,有一種黏黏的視覺效果,讓手機控件更加生動,也增加了交互時的趣味性。最近在學習自定義控件的知識,所以試着實現了一下這個功能,來看看整體的一個預覽效果:
這裏寫圖片描述
然後看一下view的拖動特寫:
這裏寫圖片描述

主要要實現的功能:

顯示消息的view被手指按住的時候隨着手指移動而移動,如果觸點和原位置的距離在某個距離A內,移動的view和原位置之間仍然有些‘黏黏的東西’黏住,如果距離大於A,view隨手指移動而沒有中間的連接部分。然後是手指擡起的情況,如果手指擡起的點到原位置距離小於距離B(B小於A),view會立即回到初始位置,這個時候如果連接還沒有斷開,view也會回到初始位置但是會有一個回彈的效果,如果手指擡起點到原位置距離大於B,view就不會回到原位置,且在手指擡起的位置有一個爆炸的效果。如果移除了總消息數的view,消息列表的所有view都會在原位置一一爆炸消失。

下邊分析一下實現邏輯:

工具分析下qq地佈局,這個顯示消息數的view是Textview
這裏寫圖片描述,但是拖動的時候是這個樣子的:
這裏寫圖片描述
發現這個textview消失了,可見拖動的時候的繪製不是發生在這個textview上的,當然拖動發生得時候view的形狀改變也不好處理。
又看了下整個佈局的父控件
這裏寫圖片描述
我靠,居然是複寫的view,而且還是個容器view,直覺裏邊有海量的代碼,雖然ViewGroup可以聊作參考,但是這又得花費哥們兒大量時間,而且有點偏離目標,只好另闢蹊徑。
那麼重點回到了剛纔拖動的時候textview的消失,既然這玩意消失了,那麼拖動的是什麼鬼?直覺是海量代碼容器view裏邊對這個拖動事件做了處理,但是我暫時還不太好複寫這樣一個view,於是我想到了一個替代品,使用一個佔滿整個佈局的一個子view來代替。按住的時候隱藏textview,同時讓這個替代的view顯示,並接着處理以後的事件。但是有個問題當手指移出這個textview按在其他控件上的時候又會被別的控件把手指的事件攔截掉了。所以這個事件的處理應該是在最最開始就被處理掉,這個由涉及到了Android的事件分發機制,這個參考一個很直觀的介紹博客事件分發,所以對事件的處理就放在了activity的dispatchTouchEvent方法中。
那麼問題來了怎麼判定控件觸點是不是落在view內,這用到了View類的一個方法getLocationInWindow,傳入一個長度爲2的數組,調用之後會得到view的位置的橫縱座標,控件的寬高又可以get得到,所以就可以判斷觸點是不是落在了這個view內部,決定要不要做接下來的處理。

下邊講一下關鍵的實現細節

繪製邏輯參考了這篇文章QQ手機版 5.0“一鍵下班”設計小結,這個主要是一些高中幾何的知識,繪製的API可以參考一下aige的自定義控件專欄(強烈推薦),也可以看一下稍後給出的demo。

SnotView的一些主要屬性

    private final long KICK_BACK_DURATION = 200;// 鼻涕回彈的時長 單位ms
    private final int BOOM_DURATION = 300;// 爆炸效果時長
    public float oriX, oriY;// “釘住”的鼻涕部分的中心點
    private int oriR;// “釘住”的鼻涕部分的中心點

    public int MAX_DISTANCE;// 最大距離 超過這個距離鼻涕被扯斷

    private float fingerX, fingerY;// 手指按住的點 座標
    private int fingerR;// 拖出來的園的半徑
    private int snotColor;// 鼻涕的顏色 
    private Paint snotPaint;// 鼻涕畫筆

    private Paint textPaint;// 文字畫筆
    private int textColor;// 文字顏色
    private String text;// 文字內容

    private double newR;// 鼻涕被拖動時候 釘住部分的半徑 變化的
    private double dist;// 手指和釘住點之間的距離

    // newR變化區間
    private int oriRMax;// “釘住”的鼻涕部分的最大半徑
    private int oriRMin;// “釘住”的鼻涕部分做小半徑
    private float textSize;// 文字的大小

    // 手指鬆開的一刻記錄的座標
    private float recordX;
    private float recordY;//

    private double SAFE_DISTANCE;// 安全距離
    volatile boolean hasCut;// 鼻涕是不是被扯斷
    private float width, height;// 鼻涕的寬高
    private int[] imgs = new int[] { R.drawable.idp, R.drawable.idq, R.drawable.idr, R.drawable.ids, R.drawable.idt };// 動畫資源
    private boolean boombing;// 是不是正在播放爆炸動畫
    private Bitmap bitmap;// 動畫幀資源
    Handler handler = new Handler();
    private DragCallback callback;

而我們繪製的內容是要和觸摸到的view相關聯的,於是有了以下這個初始化方法。參數exHeigh是ActionBar加上狀態欄的高度,方便我們計算精確的座標。注意裏邊有些屬性值的確定相較於qq並不是確切的。

private void copyPropertiesOf(TextView view, int exHeigh) {
        int[] location = new int[2];
        view.getLocationInWindow(location);
        textSize = view.getTextSize();
        ColorDrawable cDrawable = (ColorDrawable) view.getBackground();
        snotColor = cDrawable.getColor();
        ColorStateList clist = view.getTextColors();
        textColor = clist.getDefaultColor();
        width = view.getWidth();
        height = view.getHeight();
        oriR = view.getHeight() / 2;
        oriX = location[0] + view.getWidth() / 2;
        oriY = location[1] + oriR - exHeigh;
        text = view.getText().toString();
        fingerR = oriR * 5 / 7;
        oriRMax = oriR;
        oriRMin = oriR * 2 / 5;
        MAX_DISTANCE = oriR * 6;
        SAFE_DISTANCE = oriR * 5;
        boombing = false;
        hasCut = false;
        setBackgroundColor(Color.parseColor("#00000000"));
    }

最後一句話setBackgroundColor(Color.parseColor(“#00000000”));使背景透明,因爲我們的SnotView是在所有控件之上的,拖動的時候底下的部分也需要被看到。

看一看繪製方法,主要還是參考之前提到的那篇文章,有難度的可能是一些簡單的幾何運算,因爲手機屏幕上的座標系和幾何座標系不同,略微燒腦,不過相信對大多數人來說,把這個縷清應該不是問題。

protected void onDraw(Canvas canvas) {
        if (boombing) {
            if (bitmap != null)
                canvas.drawBitmap(bitmap, fingerX - oriR, fingerY - oriR, snotPaint);
        } else {
            drawNowOriCircleAndSnot(canvas);
            drawMovingObject(canvas);
        }
    }

    /**
     * @Description 畫跟隨手指移動的部分
     */
    private void drawMovingObject(Canvas canvas) {
        RectF rect1 = new RectF(fingerX - width / 2, fingerY - height / 2, fingerX + width / 2, fingerY + height / 2);
        canvas.drawRoundRect(rect1, oriR, oriR, snotPaint);
        float dX = (textPaint.measureText(text) / 2);
        float dY = -((textPaint.descent() + textPaint.ascent()) / 2);
        canvas.drawText(text, fingerX - dX, fingerY + dY, textPaint);
    }

    /**
     * 畫出移動狀態的原始的圓形 和中間的鼻涕
     * 
     * @param canvas
     */
    private void drawNowOriCircleAndSnot(Canvas canvas) {
        dist = getDistance(fingerX, fingerY, oriX, oriY);
        if (dist <= MAX_DISTANCE && !hasCut) {
            double factor = dist / MAX_DISTANCE;
            newR = oriRMax - (oriRMax - oriRMin) * factor;
            canvas.drawCircle(oriX, oriY, (float) newR, snotPaint);
            drawSide(canvas);
        }
    }

    /**
     * 繪製兩邊略帶弧度的線 即手指按點和原位置之間‘粘稠’的部分
     * 
     * @param canvas
     */
    private void drawSide(Canvas canvas) {
        double cos = getCons(fingerX, fingerY, oriX, oriY);
        double sin = Math.sqrt(1 - cos * cos);
        double dX1 = newR * cos;
        double dY1 = newR * sin;
        double dX2 = fingerR * cos;
        double dY2 = fingerR * sin;
        Point[] p = new Point[2];
        Point[] p2 = new Point[2];

        Point[] c = new Point[2];
        c[0] = new Point((fingerX + oriX) / 2, (fingerY + oriY) / 2);
        c[1] = c[0];
        if ((fingerY >= oriY && fingerX <= oriX) || (fingerY <= oriY && fingerX >= oriX)) {
            p[0] = new Point(oriX + dX1, oriY + dY1);
            p[1] = new Point(oriX - dX1, oriY - dY1);

            p2[0] = new Point(fingerX + dX2, fingerY + dY2);
            p2[1] = new Point(fingerX - dX2, fingerY - dY2);

        } else if (fingerY >= oriY && fingerX >= oriX || (fingerY <= oriY && fingerX <= oriX)) {

            p[0] = new Point(oriX - dX1, oriY + dY1);
            p[1] = new Point(oriX + dX1, oriY - dY1);

            p2[0] = new Point(fingerX - dX2, fingerY + dY2);
            p2[1] = new Point(fingerX + dX2, fingerY - dY2);
        }
        drawStickyShape(canvas, p, p2, c);
    }

    /**
     * 貝塞爾曲線圍起來的梯形
     */
    public void drawStickyShape(Canvas canvas, Point[] p, Point[] p2, Point[] c) {
        Path path = new Path();
        path.moveTo((float) p[0].x, (float) p[0].y);
        path.quadTo((float) c[0].x, (float) c[0].y, (float) p2[0].x, (float) p2[0].y);
        path.lineTo((float) p2[1].x, (float) p2[1].y);
        path.quadTo((float) c[1].x, (float) c[1].y, (float) p[1].x, (float) p[1].y);
        path.lineTo((float) p[0].x, (float) p[0].y);
        canvas.drawPath(path, snotPaint);
    }

事件的處理因爲事件的來源不是SnotView本身,自然就不能在onTouchEvent方法裏寫。以下這個方法處理來自activity的dispatchTouchEvent事件

public synchronized void handlerTvTouchEvent2(MotionEvent event, View v, int exHeight) {
        float x = event.getX();
        float y = event.getY();

        this.fingerX = x;
        this.fingerY = y - exHeight;
        this.v = v;
        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            setProperty((TextView) v, exHeight);
            setVisibility(View.VISIBLE);
            break;
        case MotionEvent.ACTION_UP:
            handleFingerUp();
            break;
        case MotionEvent.ACTION_MOVE:
            doWhenFingerMove();
            break;
        }
    }

下邊講講手指擡起的時候回彈動畫的處理,看qq的效果我首先想到的是使用屬性動畫,有一個overShoot的效果,但是那個回彈的次數比qq我們要的效果少一次,於是我們自定義一個插值器來實現這種效果。

/**
     * 
     * @Description 回彈
     */
    private void kickback() {
        recordX = fingerX;
        recordY = fingerY;
        ValueAnimator backAnimator = ValueAnimator.ofFloat((float) dist, 0);
        OvershootInterpolator inter = new MyQQDragInterprator();
        // changeTension(inter, 4);
        backAnimator.setInterpolator(inter);
        backAnimator.setDuration(KICK_BACK_DURATION);
        backAnimator.addUpdateListener(new AnimatorUpdateListener() {

            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                doWhenKickback(animation);
            }
        });
        backAnimator.addListener(new AnimatorListener() {

            @Override
            public void onAnimationStart(Animator animation) {
            }

            @Override
            public void onAnimationRepeat(Animator animation) {
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                setVisibility(View.GONE);
                if (callback != null)
                    callback.onFree();
            }

            @Override
            public void onAnimationCancel(Animator animation) {

            }
        });
        backAnimator.start();
    }

    protected void doWhenKickback(ValueAnimator animation) {
        float value = Float.parseFloat(animation.getAnimatedValue().toString());//
        final double cos = getCons(fingerX, fingerY, oriX, oriY);
        final double sin = Math.sqrt(1 - cos * cos);
        if (recordX >= oriX && recordY >= oriY) {
            fingerX = (float) (oriX + value * sin);
            fingerY = (float) (oriY + value * cos);
        } else if (recordX < oriX && recordY > oriY) {
            fingerX = (float) (oriX - value * sin);
            fingerY = (float) (oriY + value * cos);
        } else if (recordX > oriX && recordY < oriY) {
            fingerX = (float) (oriX + value * sin);
            fingerY = (float) (oriY - value * cos);
        } else {
            fingerX = (float) (oriX - value * sin);
            fingerY = (float) (oriY - value * cos);
        }
        postInvalidate();
    }

    /**
     * @Description: 回彈的時候的差值器
     * @author monkey-d-wood
     */
    private class MyQQDragInterprator extends OvershootInterpolator {
        @Override
        public float getInterpolation(float t) {
            t -= 1.0f;
            float answer1 = (float) Math.sin(Math.PI * 5 / 2 * t) * t;
            return 1 - answer1;
        }
    }

下邊講講activity中需要做的處理,在佈局文件中加入我們的自定義view並讓其佔滿整個佈局,並讓其隱藏

    <com.sovnem.qqbardrag.SnotView
        android:id="@+id/snotview"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="#efefef"
        android:visibility="gone" />

在dispatchTouchEvent方法中處理手指事件。

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        getHeights();
        tView.getLocationInWindow(location);
        float x = ev.getX();
        float y = ev.getY();
        if (ev.getAction() == MotionEvent.ACTION_DOWN && x > location[0] && x < location[0] + tView.getWidth() && y > location[1] && y < location[1] + tView.getHeight()) {
            isIn = true;
        }
        if (ev.getAction() == MotionEvent.ACTION_UP) {
            isIn = false;
            touchView.handlerTvTouchEvent2(ev, tView, exHeight);
        }
        if (isIn) {
            touchView.handlerTvTouchEvent2(ev, tView, exHeight);
            return true;
        }

        return super.dispatchTouchEvent(ev);
    }

如果是在佈局中使用了Listview,在dispatchTouchEvent中需要一一判斷手指觸摸的到底是哪個view。如何拿到這些view?在adapter的getView方法中,將這些Textview加一個標記(Tag),監聽listview的OnScroll事件,獲取當前頁面所有可見的item索引,通過索引和tag的一一對應關係,通過索引找到Textview,在做手指觸點的判斷處理。

@Override
    public void onScroll(AbsListView view, int firstVisibleItem, int visibleItemCount, int totalItemCount) {
        firstVisiable = firstVisibleItem;
        visiableCout = visibleItemCount;
    }

總結

其實後來發現跟qq的拖動還是有一些差別的,也可能會有些bug,所以這個東西離實際使用還有一些差距。這篇博客目的是給出一個實現的思路,文中有紕漏和錯誤還望指正,上邊沒講到的細節可以在隨後的demo中查看。
如果這篇博客激發了你的某些靈感或解決了你的某些困惑,那我深感榮幸。

demo地址

版權聲明:本文爲博主原創文章,未經博主允許不得轉載。

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