Android實現圓弧滑動效果之ArcSlidingHelper篇

前言

咱們平時在開發中,不免會遇到一些比較特殊的需求,就好比咱們這篇文章的主題,一個關於圓弧滑動的,通常是比較少見的。其實在遇到這些東西時,不要怕,一步步分析他實現原理,問題便能迎刃而解。android

前幾天一位羣友發了一張圖,問相似這種要怎麼實現:canvas

1.要支持手勢旋轉設計模式

2.旋轉後慣性滾動數組

3.滾動後自動選中app

哈哈, 來一張本身實現的效果圖:

初步分析

首先咱們看下設計圖,Item繞着一個半圓旋轉,若是咱們是自定義ViewGroup的話,那麼在onLayout之 後,就要把這些Item按必定的角度旋轉了。若是直接繼承View,這個比較方便,能夠直接用Canvas的rotate方法。不過若是繼承View的話,作起來是簡單,也能知足上面的需求,但侷限性就比較大了: 只能draw,並且Item內容不宜過多。因此此次咱們打算自定義ViewGroup,它的好處呢就是:什麼都能放,我無論你Item裏面是什麼,反正我就負責顯示。 慣性滾動的話,這個很容易,咱們能夠用Scroller配合VelocityTracker來完成。 旋轉手勢,無非就是計算手指滑動的角度。異步

選擇旋轉方案

提及View的動畫播放,你們確定都是輕車熟路了,若是一個View,它有監聽點擊事件,那麼在播放位移動畫後,監聽的位置按道理,也應該在它最新的位置上(即位移後的位置),在這種狀況下咱們用View的startAnimation就不奏效了:ide

TranslateAnimation translateAnimation = new TranslateAnimation(0, 150, 0, 300);
    translateAnimation.setDuration(500);
    translateAnimation.setFillAfter(true);
    mView.startAnimation(translateAnimation);
複製代碼

能夠看到,在View位移以後,監聽點擊事件的區域仍是在原來的地方。 咱們再看下用屬性動畫的:

mView.animate().translationX(150).translationY(300).setDuration(500).start();
複製代碼

監聽點擊事件的區域隨着View的移動而更新了。 嘻嘻,咱們經過實踐來驗證了這個說法。

那麼咱們作的這個是要支持觸摸事件的,確定是使用第二種方法。 ViewPropertyAnimator的源碼分析相信你們以前也都已經看過其餘大佬們的文章了,這裏就只講講關鍵代碼: ViewPropertyAnimator它不是ValueAnimator的子類,哈哈,這個有點意外吧,咱們直接看startAnimation方法(這個方法是start()裏面調用的):源碼分析

private void startAnimation() {
    ...
    //能夠看到這裏建立了ValueAnimator對象
    ValueAnimator animator = ValueAnimator.ofFloat(1.0f);
    ...
    animator.addUpdateListener(mAnimatorEventListener);
    ...
    animator.start();
}
複製代碼

中間那裏addUpdateListener(mAnimatorEventListener),咱們來看看這個listener裏面作了什麼:佈局

@Override
    public void onAnimationUpdate(ValueAnimator animation) {
        ...
        ...
        ArrayList<NameValuesHolder> valueList = propertyBundle.mNameValuesHolder;
        if (valueList != null) {
            int count = valueList.size();
            for (int i = 0; i < count; ++i) {
                NameValuesHolder values = valueList.get(i);
                float value = values.mFromValue + fraction * values.mDeltaValue;
                if (values.mNameConstant == ALPHA) {
                    alphaHandled = mView.setAlphaNoInvalidation(value);
                } else {
                    setValue(values.mNameConstant, value);
                }
            }
        }
        ...
        ...
    }
複製代碼

else裏面調用了setValue方法,咱們再繼續跟下去 (哈哈,感受好像捉賊同樣):post

private void setValue(int propertyConstant, float value) { final View.TransformationInfo info = mView.mTransformationInfo; final RenderNode renderNode = mView.mRenderNode; switch (propertyConstant) { case TRANSLATION_X: renderNode.setTranslationX(value); break; case TRANSLATION_Y: renderNode.setTranslationY(value); break; case TRANSLATION_Z: renderNode.setTranslationZ(value); break; case ROTATION: renderNode.setRotation(value); break; case ROTATION_X: renderNode.setRotationX(value); break; case ROTATION_Y: renderNode.setRotationY(value); break; case SCALE_X: renderNode.setScaleX(value); break; case SCALE_Y: renderNode.setScaleY(value); break; case X: renderNode.setTranslationX(value - mView.mLeft); break; case Y: renderNode.setTranslationY(value - mView.mTop); break; case Z: renderNode.setTranslationZ(value - renderNode.getElevation()); break; case ALPHA: info.mAlpha = value; renderNode.setAlpha(value); break; } }

咱們能夠看到,它就調用了View的mRenderNode裏面的setXXX方法,最關鍵就是這些方法啦,其實這幾個setXXX方法在View裏面也有公開的,咱們也是能夠直接調用的,因此咱們在處理ACTION_MOVE的時候,就直接調用它而不用播放動畫啦。 咱們如今驗證一下這個方案可不可行: 先試試setTranslationY:

將setTranslationY方法換成setRotation看看:

好了,通過咱們實踐驗證了這個方案是可行的,在旋轉以後,監聽點擊事件的位置也更新了,這正好是咱們須要的效果。

知其然,知其因此然

哈哈,其實如今就有點 知其然而不知其因此然 的感受了,既然咱們都知道補間動畫不能改變接受觸摸事件的區域,而屬性動畫就能夠。 那麼,有沒有想過爲何會這樣呢? 可能有同窗就會說了: 「由於屬性動畫改變了座標」 真的是這樣嗎? 額,若是這個"座標"指的是getX,getY取得的值,那就是對的。爲何呢?很簡單,咱們來看看getX和getY的方法源碼就知道了:

public float getX() {
    return mLeft + getTranslationX();
}
public float getY() {
    return mTop + getTranslationY();
}
複製代碼

哈哈,看到了吧,它們返回的值都分別加上了對應的Translation的值,而屬性動畫更新幀時,也是更新了Translation的值,因此當動畫播放完畢,getX和getY時,老是能取到正確的值。

但若是說這個座標是指left,top,right,bottom呢,那就不對了,爲何呢?由於通過咱們剛剛對ViewPropertyAnimator的源碼分析,知道了位移動畫最終也只是調用了RenderNode的setTranslation方法,而left,top,right,bottom這四個值並無改變。 這時候可能有同窗就會說了:我不信!既然沒有真正改變它的座標,那它接受觸摸事件的區域怎麼也會跟着移動呢? 好吧,既然你不信,那咱們來作個試驗就知道了,此次須要到 設置 - 開發者選項 裏面把顯示佈局邊界這個選項打開:

關鍵代碼:

mView.setOnTouchListener(new View.OnTouchListener() {

        int lastX, lastY;
        Toast toast = Toast.makeText(TestActivity.this, "", Toast.LENGTH_SHORT);

        @Override
        public boolean onTouch(View v, MotionEvent event) {
            int x = (int) event.getRawX();
            int y = (int) event.getRawY();

            if (event.getAction() == MotionEvent.ACTION_MOVE) {
                //Toolbar和狀態欄的高度
                int toolbarHeight = (getWindow().getDecorView().getHeight() - findViewById(R.id.root_view).getHeight());
                int widthOffset = mView.getWidth() / 2;
                int heightOffset = mView.getHeight() / 2;

                mView.setTranslationX(x - mView.getLeft() - widthOffset);
                mView.setTranslationY(y - mView.getTop() - heightOffset - toolbarHeight);

                toast.setText(String.format("left: %d, top: %d, right: %d, bottom: %d",
                        mView.getLeft(), mView.getTop(), mView.getRight(), mView.getBottom()));
                toast.show();
            }
            lastX = x;
            lastY = y;
            return true;
        }
    });
複製代碼

看看效果:

emmm,咱們開啓了佈局邊界選項以後,能夠看到當View移動的時候,那個框框並無跟着移動,且咱們打印的left, top, right, bottom的值一直都是同樣的。 好,咱們把setTranslation改爲layout方法看看: 代碼:

@Override
public boolean onTouch(View v, MotionEvent event) {
    ...
    if (event.getAction() == MotionEvent.ACTION_MOVE) {
        ...
        mView.layout(x - widthOffset, y - heightOffset - toolbarHeight,
                x + widthOffset, y + heightOffset - toolbarHeight);
        ...
    }
    return true;
複製代碼

效果:

哈哈哈,看到了吧,用layout方法來移動View,那個框框也會跟着走的,且打印的ltrb值,也會跟着變(廢話),而使用setTranslation的話,就像元神出竅了同樣。。。 相信如今你們都已經知道了爲何說setTranslation方法也不是真正能改變座標了吧。

好了,咱們如今回到上面的問題:既然setTranslation方法沒有真正的改變座標,那爲何觸摸區域卻會跟着移動呢? 這個就須要看一下ViewGroup的源碼了,咱們先從哪裏開始看呢?emmm,確定是從dispatchTouchEvent方法開始啦,緣由想必你們都已經想到了吧。 咱們要先找到判斷ACTION_DOWN的,而後再找遍歷子View的for循環,看看它是怎麼找到偏移後的View的:

@Override
public boolean dispatchTouchEvent(MotionEvent ev) {

    ...

    if (actionMasked == MotionEvent.ACTION_DOWN
            || (split && actionMasked == MotionEvent.ACTION_POINTER_DOWN)
            || actionMasked == MotionEvent.ACTION_HOVER_MOVE) {

        ...

        final View[] children = mChildren;
        //從最後添加到ViewGroup的View(最上面的)開始遞減遍歷
        for (int i = childrenCount - 1; i >= 0; i--) {
            final int childIndex = getAndVerifyPreorderedIndex(childrenCount, i, customOrder);
            final View child = getAndVerifyPreorderedView(preorderedList, children, childIndex);

            ...

            //判斷當前遍歷到的子View是否符合條件
            if (!canViewReceivePointerEvents(child)
                    || !isTransformedTouchPointInView(x, y, child, null)) {
                ev.setTargetAccessibilityFocus(false);
                continue;
            }

            //找到合適的子View以後,將事件向下傳遞
            if (dispatchTransformedTouchEvent(ev, false, child, idBitsToAssign)) {
                ...
            }

            ...
        }
    }

}
複製代碼

咱們重點看for循環裏面的第一個if,由於它能決定是否還要繼續往下執行。經過看方法名能猜到,前面的方法大概就是判斷子View能不能接受到事件,它裏面是這樣的:

private static boolean canViewReceivePointerEvents(@NonNull View child) {
    return (child.mViewFlags & VISIBILITY_MASK) == VISIBLE
            || child.getAnimation() != null;
}
複製代碼

emmm,不可見的時候又沒有設置動畫,天然就不會把觸摸事件給它了。 咱們來看看第二個:isTransformedTouchPointInView方法:

protected boolean isTransformedTouchPointInView(float x, float y, View child,
        PointF outLocalPoint) {
    final float[] point = getTempPoint();
    point[0] = x;
    point[1] = y;
    transformPointToViewLocal(point, child);
    final boolean isInView = child.pointInView(point[0], point[1]);
    if (isInView && outLocalPoint != null) {
        outLocalPoint.set(point[0], point[1]);
    }
    return isInView;
}
複製代碼

中間調用了transformPointToViewLocal方法,看看:

public void transformPointToViewLocal(float[] point, View child) {
    point[0] += mScrollX - child.mLeft;
    point[1] += mScrollY - child.mTop;

    if (!child.hasIdentityMatrix()) {
        child.getInverseMatrix().mapPoints(point);
    }
}
複製代碼

咱們先放一放這個hasIdentityMatrix方法,直接看if裏面的內容,它是get了一個矩陣而後調用了mapPoints方法,這個mapPoints方法就是:當矩陣發生變化後(旋轉,縮放等),將最初位置上面的座標,轉換成變化後的座標,好比說: 數組[0, 0](分別對應x和y)在矩陣向右邊平移了50(matrix.postTranslate(50, 0))以後,調用mapPoints方法並將這個數組做爲參數傳進去,那這個數組就變成[50, 0],若是這個矩陣繞[100, 100]上的點順時針旋轉了90度(matrix.postRotate(90, 100, 100))的話,那這個數組就會變成[200, 0]了,只看文字可能有點難理解,不要緊,咱們作個圖出來就很清晰明瞭了: 例如這個順時針旋轉90度的:

咱們能夠把矩形的寬高看成100x100,那個紅點的座標就是[0, 0]了,當這個矩形旋轉的時候,能夠看到它是以[100, 100]的點做旋轉中心的,在旋轉完以後,那個紅點的Y軸並無變化,而X軸則向右移動了兩個矩形的寬,emmm,這下你們都明白上面說的爲何會由[0, 0]變成[200, 0]了吧。 如今就不難理解,爲何ViewGroup能找到「元神出竅」的View了,咱們回到上面的isTransformedTouchPointInView方法: 能夠看到,當它調用transformPointToViewLocal方法時,把觸摸點的座標傳進去了,那麼,等這個transformPointToViewLocal方法執行完畢以後呢,這個觸摸點座標就是轉換後的座標了,隨後它還調用了View的pointInView方法,並把轉換後的座標分別傳了進去,這個方法咱們看名字就大概能猜到是檢測傳進去的xy座標點是否在View內(哈哈,咱們平時在開發中也應該儘可能把方法和變量命名得通俗易懂些,一看就知道個大概那種,這樣在團隊協做中,就算註釋寫的比較少,同事也不會太難看懂),咱們來看看這個pointInView方法:

final boolean pointInView(float localX, float localY) {
    return pointInView(localX, localY, 0);
}

public boolean pointInView(float localX, float localY, float slop) {
    return localX >= -slop && localY >= -slop && localX < ((mRight - mLeft) + slop) &&
            localY < ((mBottom - mTop) + slop);
}
複製代碼

嗯,很顯然就是判斷傳進去的座標是否在此View中。

好了,如今咱們來總結一下:

  • ViewGroup在分派事件的時候,會從最後添加到ViewGroup的View(最上面的)開始遞減遍歷;

  • 經過調用isTransformedTouchPointInView方法來處理判斷觸摸的座標是否在子View內;

  • 這個isTransformedTouchPointInView方法會調用transformPointToViewLocal來把相對於ViewGroup的觸摸座標轉換成相對於該子View的座標,而且若是該子View所對應的矩陣有應用過變換(平移,旋轉,縮放等)的話,還會繼續將座標轉換成矩陣變換後的座標。觸摸座標轉換後,會調用View的pointInView方法來判斷此觸摸點是否在View內;

  • ViewGroup會根據isTransformedTouchPointInView方法的返回值來決定要不要把事件交給這個子View; 好,咱們來模擬一下ViewGroup是怎麼找到這個 「元神出竅」 的View的,加深下理解: 關鍵代碼:

    @Override public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { float[] points = new float[2]; mView.setRotation(progress); mView.setTranslationX(-progress); mView.setTranslationY(-progress); Matrix matrix = getViewMatrix(mView); if (matrix != null) { matrix.mapPoints(points); } mToast.setText(String.format("綠點在View中嗎? %s", pointInView(mView, points) ? "是的" : "不不不不")); mToast.show(); }

    private Matrix getViewMatrix(View view) { try { Method getInverseMatrix = View.class.getDeclaredMethod("getInverseMatrix"); getInverseMatrix.setAccessible(true); return (Matrix) getInverseMatrix.invoke(view); } catch (Exception e) { e.printStackTrace(); } return null; }

    private boolean pointInView(View view, float[] points) { try { Method pointInView = View.class.getDeclaredMethod("pointInView", float.class, float.class); pointInView.setAccessible(true); return (boolean) pointInView.invoke(view, points[0], points[1]); } catch (Exception e) { e.printStackTrace(); } return false; }

由於View的getInverseMatrix和pointInView方法,咱們都不能直接調用到的,因此要用反射,來看看效果:

哈哈,如今你們都明白ViewGroup爲何還能找到 「元神出竅」 後的View了吧。

好了,如今來回顧一下transformPointToViewLocal方法,咱們剛剛忽略了裏面調用的hasIdentityMatrix方法,到如今這個方法也大概能猜到個大概了:就是鑑定這個View所對應的矩陣有沒有應用過好比setTranslation,setRotation,setScale這些方法,若是有就返回false, 沒有就true。

再回到最初的問題:既然屬性動畫能夠,那爲何補間動畫就不行呢?你們都是動畫啊!

有同窗可能已經知道爲何了,由於播放補間動畫並無影響到上面說的hasIdentityMatrix方法的返回值,那它是怎麼改變View的位置或大小的呢?咱們仍是來看看源碼吧: 經過看ScaleAnimation,TranslateAnimation和RotateAnimation能看出來,他們都重寫了Animation類的applyTransformation和initialize方法,這個initialize方法看名字就大概知道是初始化一些東西,因此咱們重點仍是看他們重寫以後的applyTransformation方法: 首先是ScaleAnimation:

@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
    ...
    if (mPivotX == 0 && mPivotY == 0) {
        t.getMatrix().setScale(sx, sy);
    } else {
        t.getMatrix().setScale(sx, sy, scale * mPivotX, scale * mPivotY);
    }
}
複製代碼

TranslateAnimation:

@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
    ...
    t.getMatrix().setTranslate(dx, dy);
}
複製代碼

RotateAnimation:

@Override
protected void applyTransformation(float interpolatedTime, Transformation t) {
    ...
    if (mPivotX == 0.0f && mPivotY == 0.0f) {
        t.getMatrix().setRotate(degrees);
    } else {
        t.getMatrix().setRotate(degrees, mPivotX * scale, mPivotY * scale);
    }
}
複製代碼

emmm,經過對比他們各自實現的方法,發現最後都是調用Transformation的getMatrix方法來獲取到矩陣對象而後對這個矩陣進行操做的,那咱們就要看看這個Transformation是在哪裏傳進來的了: 回到Animation中,會發現applyTransformation方法是在getTransformation(long currentTime, Transformation outTransformation)方法中調用的,它直接把參數中的outTransformation做爲applyTransformation方法的t參數傳進去了,那如今就要看看在哪裏調用了會發現applyTransformation方法是在getTransformation方法了: 在View中,咱們經過搜索方法名能夠找到調用它的是applyLegacyAnimation方法,咱們此次主要是看它傳進取的Transformation對象是哪裏來的,最終要到哪裏去:

private boolean applyLegacyAnimation(ViewGroup parent, long drawingTime,
        Animation a, boolean scalingRequired) {
    ...
    final Transformation t = parent.getChildTransformation();
    boolean more = a.getTransformation(drawingTime, t, 1f);
    if (scalingRequired && mAttachInfo.mApplicationScale != 1f) {
        invalidationTransform = parent.mInvalidationTransformation;
        a.getTransformation(drawingTime, invalidationTransform, 1f);
    } 
    ...
}
複製代碼

咱們繼續搜 「parent.getChildTransformation()」,最終發如今draw方法有再次調用,來看看精簡後的draw方法:

boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {

    Transformation transformToApply = null;

    final Animation a = getAnimation();
    if (a != null) {
        more = applyLegacyAnimation(parent, drawingTime, a, scalingRequired);
        transformToApply = parent.getChildTransformation();
    }

    if (transformToApply != null) {
        if (drawingWithRenderNode) {
            renderNode.setAnimationMatrix(transformToApply.getMatrix());
        } else {
            canvas.translate(-transX, -transY);
            canvas.concat(transformToApply.getMatrix());
            canvas.translate(transX, transY);
        }
    }
}
複製代碼

emmm,能夠看到,當getAnimation不爲空的時候,它就會先調用applyLegacyAnimation方法,而這個方法最終會調用到Animation的applyTransformation方法,Animation的子類會在這個方法中根據傳進來的Transformation對象get到矩陣,而後那些平移呀,旋轉,縮放等操做都只是對這個矩陣進行操做。 那麼等這個applyLegacyAnimation方法執行完畢以後呢,就是時候刷新幀了,在draw方法中他會根據一個drawingWithRenderNode,來決定是調用RenderNode的setAnimationMatrix仍是Canvas的concat方法,還記不記得咱們上面分析的屬性動畫?它更新幀也是調用RenderNode提供的一系列方法,那咱們再看看這個setAnimationMatrix方法的源碼:

/**
 * Set the Animation matrix on the display list. This matrix exists if an Animation is
 * currently playing on a View, and is set on the display list during at draw() time. When
 * the Animation finishes, the matrix should be cleared by sending <code>null</code>
 * for the matrix parameter.
 *
 * @param matrix The matrix, null indicates that the matrix should be cleared.
 */
public boolean setAnimationMatrix(Matrix matrix) {
    return nSetAnimationMatrix(mNativeRenderNode,
            (matrix != null) ? matrix.native_instance : 0);
}
複製代碼

看它的文檔註釋能夠大概知道:當動畫正在播放的時候就會顯示這個矩陣,當播放完畢時,就應該把它清除掉。 emmm,那就說明,播放補間動畫的時候,咱們所看到的變化,都只是臨時的。而屬性動畫呢,它所改變的東西,卻會更新到這個View所對應的矩陣中,因此當ViewGroup分派事件的時候,會正確的將當前觸摸座標,轉換成矩陣變化後的座標,這就是爲何播放補間動畫不會改變觸摸區域的緣由了。

哈哈,如今咱們就知其然,知其因此然了,是否是很開心?

計算旋轉角度

如今旋轉這一塊是搞定了,那麼咱們怎麼計算出來手指滑動的角度呢?

想一下,它旋轉的時候,確定是有一個開始角度和結束角度的,咱們把圓心座標,起始座標,結束座標用線連起來,不就是三角形了?咱們先來看看下面的圖:

哈哈,看到了吧,黃色兩個圓點就是咱們手指的開始和結束座標,因此咱們如今只要計算出紅色兩條線的夾角就好了。 先找下咱們能直接拿到的東西:

  • 圓心座標
  • 起始點座標 *結束點座標 咱們知道,三角形中,只要拿到三條邊的長度,就能求出它的三個角,那麼能不能計算出三邊的長度呢?答案是確定的,咱們能夠這樣作:

哈哈,想必你們都已經想到了吧,三角形的三條邊都有屬於本身的矩形,咱們如今只要計算出三個矩形的對角線長度,進而求出夾角的大小。 藍色矩形上的黃點爲起始點,那麼 (mPivotX和mPivotY是圓心的座標,mStartX和mStartY是手指按下的座標,mEndX和mEndY就是手指鬆開的所在座標):

矩形寬(小三角形的直角邊1) = Math.abs(mStartX - mPivotX); 矩形高(直角邊2) = Math.abs(mStartY - mPivotY);

根據勾股定理公式:bc = √ (ab² + ac²) 那麼 第一條邊 = (float) Math.sqrt(Math.pow(矩形寬, 2) + Math.pow(矩形高, 2));

咱們按照這個公式依次計算出剩餘兩條邊以後,再根據餘弦定理進一步計算出夾角的角度,公式:**cosC = (a² + b² - c²) / 2ab 即:float angle = (float) Math.toDegrees(Math.acos((Math.pow(lineA, 2) +Math.pow(lineB, 2) - Math.pow(lineC, 2)) / (2 * lineA * lineB))); **

好了,咱們來看看效果如何:

如今角度是計算出來了,可是,有沒有發現,咱們的角度都是正數,這在順時針旋轉時沒問題,可是逆時針旋轉的話,角度就應該爲負數了,因此咱們要加一個判斷它是順時針仍是逆時針旋轉的方法:

要判斷手指的旋轉方向,咱們要先知道手指是水平滑動仍是垂直滑動 (mPivotX和mPivotY是圓心的座標,mStartX和mStartY是手指按下的座標,mEndX和mEndY就是手指鬆開的所在座標):

boolean isVerticalScroll = Math.abs(mEndY - mStartY) > Math.abs(mEndX - mStartX);

咱們將x軸和y軸的滑動距離進行對比,判斷哪一個距離更長,若是x軸的滑動距離長,那就是水平滑動了,反之,若是y軸滑動距離比x軸的長,就是垂直滑動。

進一步:若是他是垂直滑動的話:若是它是在圓心的左邊,即mEndX < mPivotX:這時候,若是是向上滑動(mEndY < mStartY,則認爲是順時針,若是是向下滑動呢,就是逆時針了。若是是在圓心右邊呢,恰好相反:即向上滑動是逆時針,向下是順時針。

水平滑動的話:若是它是在圓心上面(mEndY < mPivotY):這時候,若是是向左滑動就是逆時針,向右就是順時針。若是在圓心下面則相反。

看代碼:

private boolean isClockwise() {
    boolean isClockwise;
    //垂直滑動  上下滑動的幅度 > 左右滑動的幅度,則認爲是垂直滑動,反之
    boolean isVerticalScroll = Math.abs(mEndY - mStartY) > Math.abs(mEndX - mStartX);
    //手勢向下
    boolean isGestureDownward = mEndY > mStartY;
    //手勢向右
    boolean isGestureRightward = mEndX > mStartX;

    if (isVerticalScroll) {
        //若是手指滑動的地方是在圓心左邊的話:向下滑動就是逆時針,向上滑動則順時針。反之,若是在圓心右邊,向下滑動是順時針,向上則逆時針。
        isClockwise = mEndX < mPivotX != isGestureDownward;
    } else {
        //邏輯同上:手指滑動在圓心的上方:向右滑動就是順時針,向左就是逆時針。反之,若是在圓心的下方,向左滑動是順時針,向右是逆時針。
        isClockwise = mEndY < mPivotY == isGestureRightward;
    }
    return isClockwise;
}
複製代碼

好了,如今咱們來看下效果:

哈哈,如今能夠正確的判斷出是順時針滑動仍是逆時針了,逆時針旋轉後,咱們獲得的角度是負數,這是咱們想要的結果。

實現慣性滾動 (Scroller的妙用)

說到Scroller,相信你們第一時間想到要配合View中的computeScroll方法來使用對吧,可是呢,咱們這篇文章的主題是輔助類,並不打算繼承View,並且不持有Context引用,這個時候,可能有同窗就會有如下疑問了:

1.這種狀況下,Scroller還能正常工做嗎? 2.調用它的startScroll或fling方法後,不是還要調用View中的invalidate方法來觸發的嗎? 3.不繼承View,哪來的 invalidate方法? 4.不繼承View,怎麼重寫computeScroll方法?在哪裏處理慣性滾動? 哈哈,其實Scroller是徹底能夠脫離View來使用的,既然說是妙用,妙在哪裏呢?在開始以前,咱們先來了解一下Scroller: 1.它看上去更像是一個ValueAnimator,但它跟ValueAnimator有個明顯的區別就是:它不會主動更新動畫的值。咱們在獲取最新值以前,老是要先調用computeScrollOffset方法來刷新內部的mCurrX、mCurrY的值,若是是慣性滾動模式(調用fling方法),還會刷新mCurrVelocity的值。

2.在這裏先分享你們一個理解源碼調用順序的方法: 好比咱們想知道是哪一個方法調用了computeScroll:

@Override
public void computeScroll() {
    StackTraceElement[] elements = Thread.currentThread().getStackTrace();
    for (StackTraceElement element : elements) {
        Log.i("computeScroll", String.format(Locale.getDefault(), "%s----->%s\tline: %d",
                element.getClassName(), element.getMethodName(), element.getLineNumber()));
    }
}
複製代碼

日誌輸出:

com.wuyr.testview.MyView----->computeScroll	line: 141
 android.view.View----->updateDisplayListIfDirty	line: 15361
 android.view.View----->draw	line: 16182
 android.view.ViewGroup----->drawChild	line: 3777
 android.view.ViewGroup----->dispatchDraw	line: 3567
 android.view.View----->updateDisplayListIfDirty	line: 15373
 android.view.View----->draw	line: 16182
 android.view.ViewGroup----->drawChild	line: 3777
 android.view.ViewGroup----->dispatchDraw	line: 3567
 android.view.View----->updateDisplayListIfDirty	line: 15373
 android.view.View----->draw	line: 16182
複製代碼

這樣咱們就可以很清晰的看到它的調用鏈了。

回到正題,所謂的調用invalidate方法來觸發,是這樣的:咱們都知道,調用了這個方法以後,onDraw方法就會回調,而調用onDraw的那個方法,是draw(Canvas canvas),再上一級,是draw(Canvas canvas, ViewGroup parent, long drawingTime),**重點來了:computeScroll也是在這個方法中回調的,**如今能夠得出一個結論: 咱們在View中調用invalidate方法,也就是間接地調用computeScroll,而computeScroll中,是咱們處理滾動的方法,在使用Scroller時,咱們都會重寫這個方法,並在裏面調用Scroller的computeScrollOffset方法,而後調用getCurrX或getCurrY來獲取到最新的值。(好像我前面說的都是多餘的) 可是!有沒有發現,這個過程,咱們徹底能夠不依賴View來作到的?

3.如今思路就很清晰了,invalidate方法?對於Scroller來講,它的做用只是回調computeScroll從而更新x和y的值而已。

4.因此徹底能夠本身寫兩個方法來實現Scroller在View中的效果,咱們此次打算利用Hanlder來幫咱們處理異步的問題,這樣的話,咱們就不用本身新開線程去不斷的調用方法啦。

好了,如今咱們所遇到的問題,都已經有解決方案了,能夠動手咯!

構思ArcSlidingHelper

還記得VelocityTracker是怎麼用的嗎:

@Override
public boolean onTouchEvent(MotionEvent event) {

    mVelocityTracker.addMovement(event);

    switch (event.getAction()) {
        ...
        case MotionEvent.ACTION_UP:
            mVelocityTracker.computeCurrentVelocity(1000);
            mScroller.fling(0, 0, (int) mVelocityTracker.getXVelocity(), (int) mVelocityTracker.getYVelocity(),
                    Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
            invalidate();
            break;
    }
    ...
}
複製代碼

咱們每次在onTouchEvent中都調用它的addMovement方法,當ACTION_UP時,調用它的computeCurrentVelocity方法計算速率後,再配合Scroller來實現慣性滾動。 感受VelocityTracker設計得很是好,咱們使用起來很舒服,沒有多餘的操做,簡單明瞭,乾淨利落,恭喜發財,六畜興旺。因此咱們決定使用它這種設計模式:

  • 咱們也可公開一個handleMovement(MotionEvent event)方法,用來傳入觸摸事件
  • 咱們打算用回調的方式來通知滑動的角度,因此還要寫一個接口OnSlidingListener
  • 公開一個靜態的create方法,用來建立ArcSlidingHelper對象 好了,如今咱們ArcSlidingHelper的基本結構也已經肯定了。

建立ArcSlidingHelper

先是構造方法,參數呢,咱們須要:

1.pivotX和pivotY,這個是圓心的座標值。 2.由於建立Scroller對象須要Context,因此還須要傳進來一個Context。 3.滑動的監聽器OnSlidingListener,當計算出滑動角度的時候,會回調這個方法 咱們來看代碼:

private ArcSlidingHelper(Context context, int pivotX, int pivotY, OnSlidingListener listener) {
    mPivotX = pivotX;
    mPivotY = pivotY;
    mListener = listener;
    mScroller = new Scroller(context);
    mVelocityTracker = VelocityTracker.obtain();
}
複製代碼

咱們的構造方法私有了,再看看create方法:

/**
 * 建立ArcSlidingHelper對象
 *
 * @param targetView 接受滑動手勢的View (圓弧滑動事件以此View的中心點爲圓心)
 * @param listener   當發生圓弧滾動時的回調
 * @return ArcSlidingHelper
 */
public static ArcSlidingHelper create(@NonNull View targetView, @NonNull OnSlidingListener listener) {
    int width = targetView.getWidth();
    int height = targetView.getHeight();
    //若是寬度爲0,提示寬度無效,須要調用updatePivotX方法來設置x軸的旋轉基點
    if (width == 0) {
        Log.e(TAG, "targetView width = 0! please invoke the updatePivotX(int) method to update the PivotX!", new RuntimeException());
    }
    //若是高度爲0,提示高度無效,須要調用updatePivotY方法來設置y軸的旋轉基點
    if (height == 0) {
        Log.e(TAG, "targetView height = 0! please invoke the updatePivotY(int) method to update the PivotY!", new RuntimeException());
    }
    width /= 2;
    height /= 2;

    int x = (int) getAbsoluteX(targetView);
    int y = (int) getAbsoluteY(targetView);
    return new ArcSlidingHelper(targetView.getContext(), x + width, y + height, listener);
}
複製代碼

咱們的create方法只有兩個參數,targetView就是要檢測滑動的View (其實也不絕對是,由於最終決定旋轉哪些View,都是在回調裏面完成的,咱們如今無從得知。傳入這個targetView的主要做用就是獲取到Context對象(用來初始化Scroller),還有圓心的座標(pivotX和pivotY,默認是View的中心點,固然這個咱們等下也會提供更新圓心座標的方法的))。

裏面還有個getAbsoluteX和getAbsoluteY方法,這兩個方法分別是獲取view在屏幕中的絕對x和y座標,爲何要有這兩個方法呢,由於targetView所在的ViewGroup不必定top、left都是0的,因此若是咱們直接獲取這個View的xy座標的話,是不夠的,還要加上它父容器的xy座標,咱們要一直遞歸下去,這樣就能真正獲取到View在屏幕中的絕對座標值了:

/**
 * 獲取view在屏幕中的絕對x座標
 */
private static float getAbsoluteX(View view) {
    float x = view.getX();
    ViewParent parent = view.getParent();
    if (parent != null && parent instanceof View) {
        x += getAbsoluteX((View) parent);
    }
    return x;
}

/**
 * 獲取view在屏幕中的絕對y座標
 */
private static float getAbsoluteY(View view) {
    float y = view.getY();
    ViewParent parent = view.getParent();
    if (parent != null && parent instanceof View) {
        y += getAbsoluteY((View) parent);
    }
    return y;
}
複製代碼

好了,接下來就是要處理TouchEvent了,咱們效仿VelocityTracker公開一個handleMovement(MotionEvent event)方法,咱們的核心代碼,也是在這裏面了。像VelocityTracker同樣,在View中的onTouchEvent方法中,調用此方法,咱們在內部計算出旋轉的角度以後,經過OnSlidingListener來回調。流程基本也是這樣了。

咱們來看看handleMovement方法怎麼寫:

public void handleMovement(MotionEvent event) {
    checkIsRecycled();
    float x, y;
    if (isSelfSliding) {
        x = event.getRawX();
        y = event.getRawY();
    } else {
        x = event.getX();
        y = event.getY();
    }
    mVelocityTracker.addMovement(event);
    switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            if (!mScroller.isFinished()) {
                mScroller.abortAnimation();
            }
            break;
        case MotionEvent.ACTION_MOVE:
            handleActionMove(x, y);
            break;
        case MotionEvent.ACTION_UP:
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_OUTSIDE:
            if (isInertialSlidingEnable) {
                mVelocityTracker.computeCurrentVelocity(1000);
                mScroller.fling(0, 0, (int) mVelocityTracker.getXVelocity(), (int) mVelocityTracker.getYVelocity(),
                        Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
                startFling();
            }
            break;
        default:
            break;
    }
    mStartX = x;
    mStartY = y;
}
複製代碼

checkIsRecycled就是檢測是否已經調用過release方法(釋放資源),若是資源已回收則拋異常。 咱們還判斷了isSelfSliding,這個表示接受觸摸事件的和實際旋轉的都是同一個View。 在ACTION_DOWN的時候,若是Scroller還沒滾動完成,則中止。 當ACTION_MOVE的時候,調用了handleActionMove方法,咱們來看看handleActionMove是怎麼寫的:

private void handleActionMove(float x, float y) {
    //              __________
    //根據公式 bc = √ ab² + ac² 計算出對角線的長度

    //圓心到起始點的線條長度
    float lineA = (float) Math.sqrt(Math.pow(Math.abs(mStartX - mPivotX), 2) + Math.pow(Math.abs(mStartY - mPivotY), 2));
    //圓心到結束點的線條長度
    float lineB = (float) Math.sqrt(Math.pow(Math.abs(x - mPivotX), 2) + Math.pow(Math.abs(y - mPivotY), 2));
    //起始點到結束點的線條長度
    float lineC = (float) Math.sqrt(Math.pow(Math.abs(x - mStartX), 2) + Math.pow(Math.abs(y - mStartY), 2));

    if (lineC > 0 && lineA > 0 && lineB > 0) {
        //根據公式 cosC = (a² + b² - c²) / 2ab
        float angle = fixAngle((float) Math.toDegrees(Math.acos((Math.pow(lineA, 2) + Math.pow(lineB, 2) - Math.pow(lineC, 2)) / (2 * lineA * lineB))));
        if (!Float.isNaN(angle)) {
            mListener.onSliding((isClockwiseScrolling = isClockwise(x, y)) ? angle : -angle);
        }
    }
}
複製代碼

哈哈,其實也就是咱們前面所說的,根據起始點和結束點,計算出夾角的角度。其中還有一個fixAngle方法,這個方法就是不讓角度超出0 ~ 360這個範圍的,看代碼:

/**
 * 調整角度,使其在0 ~ 360之間
 *
 * @param rotation 當前角度
 * @return 調整後的角度
 */
private float fixAngle(float rotation) {
    float angle = 360F;
    if (rotation < 0) {
        rotation += angle;
    }
    if (rotation > angle) {
        rotation %= angle;
    }
    return rotation;
}
複製代碼

例如傳進去的是-90,返回的就是270,傳進去是365,返回的就是5。咱們最終看到的效果都是同樣的。 計算出滑動的角度以後呢,還判斷了一下數值是否合法,而後就是判斷順時針仍是逆時針旋轉啦,判斷順逆時針這個問題咱們在前面就解決了,嘻嘻。最後把角度傳給監聽器。獲取到角度具體要作什麼,那就要看這個監聽器的onSliding是怎麼寫了的,哈哈。

ACTION_MOVE處理完以後,還剩一個ACTION_UP的,沒錯,慣性滑動就是在這裏處理的,咱們再來看看ACTION_UP下面的代碼:

if (isInertialSlidingEnable) {
        mVelocityTracker.computeCurrentVelocity(1000);
        mScroller.fling(0, 0, (int) mVelocityTracker.getXVelocity(), (int) mVelocityTracker.getYVelocity(),
                Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE);
        startFling();
    }
複製代碼

isInertialSlidingEnable就是是否開啓慣性滾動。接下來就是Scroller所妙之處了,能夠看到,咱們在調用Scroller的fling方法以後,並無調用invalidate方法,而是咱們自定義的startFling方法,咱們看看是怎麼寫的:

private void startFling() {
    mHandler.sendEmptyMessage(0);
}
複製代碼

哈哈哈,就是這樣啦,咱們前面所說的,用Handler來處理異步的問題,這樣就不用本身去新開線程了。咱們看看Hanlder怎麼寫:

private static class InertialSlidingHandler extends Handler {

    ArcSlidingHelper mHelper;

    InertialSlidingHandler(ArcSlidingHelper helper) {
        mHelper = helper;
    }

    @Override
    public void handleMessage(Message msg) {
        mHelper.computeInertialSliding();
    }
}
複製代碼

很簡單,handleMessage方法中直接又調用了computeInertialSliding,咱們再看看computeInertialSliding:

/**
 * 處理慣性滾動
 */
private void computeInertialSliding() {
    checkIsRecycled();
    if (mScroller.computeScrollOffset()) {
        float y = ((isShouldBeGetY ? mScroller.getCurrY() : mScroller.getCurrX()) * mScrollAvailabilityRatio);
        if (mLastScrollOffset != 0) {
            float offset = fixAngle(Math.abs(y - mLastScrollOffset));
            mListener.onSliding(isClockwiseScrolling ? offset : -offset);
        }
        mLastScrollOffset = y;
        startFling();
    } else if (mScroller.isFinished()) {
        mLastScrollOffset = 0;
    }
}
複製代碼

是否是有種似曾相識的感受?沒錯啦,咱們用computeInertialSliding來代替了View中的computeScroll方法,用startFling代替了invalidate,能夠說是徹底脫離了View來使用Scroller,妙就妙在這裏啦,嘻嘻。 回到正題,咱們在調用computeScrollOffset方法(更新currX和currY的值)以後,判斷isShouldBeGetY來決定到底是getCurrX好仍是getCurrY好,這個isShouldBeGetY的值就是在判斷是否順時針旋轉的時候更新的,咱們不是有一個isVerticalScroll(是否垂直滑動)嗎,isShouldBeGetY的值其實也就是isVerticalScroll的值,由於若是是垂直滑動的話,VelocityTracker的Y速率會更大,因此這個時候getCurrY是很明智的,反之。 在肯定好了get哪一個值以後,咱們還將它跟mScrollAvailabilityRatio相乘,這個mScrollAvailabilityRatio就是速率的利用率,默認是0.3,就是用來縮短慣性滾動的距離的,由於在測試的時候,以爲這個慣性滾動的距離有點長,輕輕一劃就轉了十幾圈,好像很輕的樣子,固然了,貼心的咱們還提供了一個setScrollAvailabilityRatio方法來動態設置這個值:

/**
 * VelocityTracker的慣性滾動利用率
 * 數值越大,慣性滾動的動畫時間越長
 *
 * @param ratio (範圍: 0~1)
 */
public void setScrollAvailabilityRatio(@FloatRange(from = 0.0, to = 1.0) float ratio) {
    mScrollAvailabilityRatio = ratio;
}
複製代碼

計算出本次滾動的角度以後,像handleActionMove同樣,判斷順時針仍是逆時針,回調接口,最後還調用了startFling,開始了下一輪的計算。。。

好了,咱們的ArcSlidingHelper算是完工了,來兩張效果圖檢驗下勞動成果:

使用起來是很是簡單的,看下佈局代碼:

<View
    android:id="@+id/view"
    android:layout_width="match_parent"
    android:layout_height="60dp"
    android:layout_gravity="center"
    android:background="@color/colorPrimary" />
複製代碼

看下MainActivity的:

private ArcSlidingHelper mArcSlidingHelper;
private View mView;

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.act_main_view);

    mView = findViewById(R.id.view);
    mView.post(() -> {
        //建立對象
        mArcSlidingHelper = ArcSlidingHelper.create(mView,
                angle -> mView.setRotation(mView.getRotation() + angle));
        //開啓慣性滾動
        mArcSlidingHelper.enableInertialSliding(true);

    });
    getWindow().getDecorView().setOnTouchListener((v, event) -> {
        //處理滑動事件
        mArcSlidingHelper.handleMovement(event);
        return true;
    });
}

@Override
protected void onDestroy() {
    super.onDestroy();
    //釋放資源
    mArcSlidingHelper.release();
}
複製代碼

效果:

這麼少的代碼就實現了圓弧滑動的效果,是否是很開心(__) 咱們來把普通的View換成RecyclerView試試:

哈哈

RecyclerView竟然能夠斜着滑動,利用這點咱們能夠作不少意想不到的效果哦~

好啦,本篇文章到此結束,有錯誤的地方請指出,謝謝你們!

相關文章
相關標籤/搜索