咱們平時在開發中,不免會遇到一些比較特殊的需求,就好比咱們這篇文章的主題,一個關於圓弧滑動的,通常是比較少見的。其實在遇到這些東西時,不要怕,一步步分析他實現原理,問題便能迎刃而解。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);
複製代碼
mView.animate().translationX(150).translationY(300).setDuration(500).start();
複製代碼
那麼咱們作的這個是要支持觸摸事件的,確定是使用第二種方法。 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:
哈哈,其實如今就有點 知其然而不知其因此然 的感受了,既然咱們都知道補間動畫不能改變接受觸摸事件的區域,而屬性動畫就能夠。 那麼,有沒有想過爲何會這樣呢? 可能有同窗就會說了: 「由於屬性動畫改變了座標」 真的是這樣嗎? 額,若是這個"座標"指的是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;
}
});
複製代碼
看看效果:
@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;
複製代碼
效果:
好了,咱們如今回到上面的問題:既然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度的:
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方法,咱們都不能直接調用到的,因此要用反射,來看看效果:
好了,如今來回顧一下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分派事件的時候,會正確的將當前觸摸座標,轉換成矩陣變化後的座標,這就是爲何播放補間動畫不會改變觸摸區域的緣由了。
哈哈,如今咱們就知其然,知其因此然了,是否是很開心?
如今旋轉這一塊是搞定了,那麼咱們怎麼計算出來手指滑動的角度呢?
想一下,它旋轉的時候,確定是有一個開始角度和結束角度的,咱們把圓心座標,起始座標,結束座標用線連起來,不就是三角形了?咱們先來看看下面的圖:
矩形寬(小三角形的直角邊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,相信你們第一時間想到要配合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來幫咱們處理異步的問題,這樣的話,咱們就不用本身新開線程去不斷的調用方法啦。
好了,如今咱們所遇到的問題,都已經有解決方案了,能夠動手咯!
還記得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設計得很是好,咱們使用起來很舒服,沒有多餘的操做,簡單明瞭,乾淨利落,恭喜發財,六畜興旺。因此咱們決定使用它這種設計模式:
先是構造方法,參數呢,咱們須要:
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,開始了下一輪的計算。。。
使用起來是很是簡單的,看下佈局代碼:
<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試試: