這互動炸了系列 第十二式之年年有魚
1 前言
先來看兩張效果圖:


哈哈,就是這樣了。
前段時間在鴻神的群裡看到有群友截了一張QQ空間的圖,問它那個是怎麼實現的: 在好友動態的列表中多了個Header,這個Header有一疊卡片的效果,上面的卡片都可以跟隨手指移動,還可以扔走,拖拽時,還有一個有趣的效果,就是卡片像是被一種無形的東西吸住一樣,扔出去的時候,卡片還會根據當前前進的方向來調整角度。。。
然後我自己開啟QQ空間想看下這個效果,卻死活刷不出來,本來想放棄了,有一天無聊打開了QQ空間,卻發現這個效果出來了,於是我趕緊錄了幾個gif,果不其然,過了幾天後就一直沒出現過。
2 初步分析
來看看它原來的樣子:



emmmm,從效果上來看呢,其實也只是基本的Translation和Rotation組合而已,難點是在於慣性移動時,那個角度的變化(好像它QQ空間的還有bug: 向右上角扔的時候那卡片還會閃一下,哈哈哈), 接下來我們就一步步分析,從而打造出屬於我們的自己的效果 。
再仔細觀察下,有沒有發現:
-
在開始拖動的時候,如果手指是偏向View的左邊按下,那麼向上移動是順時針旋轉,向下則逆時針。反之,如果手指是在偏右邊的位置按下的話,那麼向上移動就是逆時針,向下則順時針;
-
在水平拖動的時候,可以看到View的旋轉角度是基本沒有變化的(小變化是因為Y軸的偏差),那麼我們可以斷定,X軸變化的時候,是不影響旋轉角度的,只有Y軸變化才有效;
-
在手指鬆開的時候,如果有滑動速率的話,會慣性移動一段距離。相反,如果沒有滑動速率的話(或低於某個閥值),那麼這個View會播放一個位移動畫,動畫的目標位置是根據上下左右四個方向的已滑動距離的多少來決定的;
那麼,View在旋轉時,需要一個旋轉基點,也就是PivotX和PivotY,這個點預設情況下在View的中心。但很明顯,它這個就不是在中心了,至於在哪裡,先看下這張圖:

可以看到, 無論View怎麼旋轉,手指按下的點在View上的位置都基本是不變的,也就說明旋轉基點就在觸控點的位置上了。
好,現在我們基本分析的差不多了,下面開始構思程式碼。
3 構思程式碼
大多數情況下,當我們要做一個View跟隨手指移動的效果時,都是直接setOnTouchListener或者直接重寫onTouchEvent去實現的。
但這種方式用在我們即將要做的這個效果上,就很不合適了,因為我們是要做到可以作用在任意一個View上的,這樣一來,如果目標View本來就已經重寫了OnTouchEvent或者設定了OnTouchListener,就很可能會滑動衝突,而且還非常不靈活,這個時候,使用自定義ViewGroup的方式是最佳選擇:
自定義ViewGroup的話,能直接套在任意一個View上,使用起來非常方便,而且不需要再做任何操作,就能正常執行。
我們應該控制一下直接子View的數量為1,這樣的話,就不用考慮如何排版的問題:因為只有一個子View,在佈局的時候只需要處理一下子View的margin,寬高可以直接參照子View的尺寸(如果沒有指定寬高的話)
接下來到觸控事件的處理,這個只需要注意一點, 就是在開始了拖動之後,要防止父佈局攔截事件。
至於子View的旋轉與移動,如果是直接通過setRotation、setTranslation、layout、offsetTopAndBottom等一系列方法直接改變View屬性的話, 考慮到檢視層級關係,可能會出現被其他View遮擋的現象 ,還有,隨著手指不斷地移動,很大機率會移動超出了View自身的邊界,導致內容顯示不全。
這時候可能有同學會說:“咦,這個問題不是可以通過設定clipToPadding屬性來解決嗎?”
**不行,用這個方法不靠譜的,你怎麼保證他滑動不會超出設定了clipToPadding屬性的那個ViewGroup的範圍? **
“我可以遞迴設定,一直到最頂級的ViewGroup”
好主意!那我們來試試吧:
setClipToPadding(false); setClipChildren(false); ViewParent parent = getParent(); while (parent instanceof ViewGroup) { ViewGroup vg = (ViewGroup) parent; vg.setClipChildren(false); vg.setClipToPadding(false); parent = vg.getParent(); }

咦?怎麼回事?我的RecyclerView怎麼變成這樣了?
哈哈哈,像現在這樣,就傷及無辜了,所以這種方式是不可取的。
那現在用哪種方法,既可以解決層級關係的問題,又能避免超出父佈局邊界的情況呢?
還記不記得上次的主題切換動畫的實現?
那個思路也能用到這裡來: 在動畫開始前給DecorView新增一個View,並在這個View上去應用動畫,這樣就能覆蓋到整個Activity甚至StatusBar和NavigationBar。
不錯,現在思路也蠻清晰的了:
-
在拖動事件觸發時,先把一個透明的View新增到DecorView上,在上面draw子View的內容,並隱藏真實的View,再根據當前觸控點的位置計算旋轉角度。
-
當手指繼續移動,這時只需要update座標值以及重新計算旋轉角度就行了。
-
當手指鬆開,我們可以藉助VelocityTracker來計算滑動速率,然後配合Scroller進行慣性移動,或通過ValueAnimator直接播放位移動畫。
-
當慣性移動結束,或者是位移動畫播放完畢,這時候應該把剛新增的View從DecorView中移除掉。
其實在這個新的View每次draw的時候,我們都應該判斷它所draw的內容是否已經完全超出了螢幕的範圍,並立即作出反應,因為在螢幕範圍之外去draw是沒有意義的,還有一個理由就是:如果滑動速率很大時,內容可能在100毫秒內就已經滑到螢幕外面,看不見了,但是Scroller那邊還沒有滾動完成(因為我們剛剛的想法是: “在Scroller滾動結束後才移除那個View”)這樣無疑會造成不必要的等待,正確的做法應該是: 在內容完全超出螢幕邊界時,也要移除掉那個View。
那麼問題來了,怎麼判斷內容是否超出螢幕範圍呢?
有的同學會說: “直接用內容Bitmap的left, top, right, bottom與View的left, top, right, bottom作比較”。
沒錯,大概就是這樣,但是還不夠,因為在上下滑動時會發生旋轉,它一旦旋轉了,原來的邊界資料就不對了,舉個例子,比如說旋轉了60度:

image
很明顯,現在這個Bitmap在螢幕上所佔據的寬高跟使用getWidth(),getHeight()方法獲取到的寬高值是不同的,那要怎樣才能得到這個旋轉後的尺寸呢?
還記得上次我們分析過的ViewGroup如何正確處理旋轉、縮放、平移後的View的觸控事件的嗎?
https://blog.csdn.net/u011387817/article/details/80313184
在ViewGroup中的transformPointToViewLocal方法內可以看到這段程式碼:
if (!child.hasIdentityMatrix()) { child.getInverseMatrix().mapPoints(point); }
如果child所對應的矩陣發生過旋轉、縮放等變化的話(補間動畫不算,因為是臨時的),會通過矩陣的mapPoints方法來將觸控點轉換到矩陣變換後的座標。
沒錯,我們也可以用矩陣的mapRect方法來將內容Bitmap的座標及尺寸轉換一下,就像這樣:

哈哈,轉換之後,我們就可以準確地判斷內容是否超出螢幕邊界了。
好了,接下來我們看看它那個旋轉的角度是如何計算的,有什麼規律:
仔細看看開頭那段分析: 在開始拖動的時候,如果手指是偏向View的左邊按下,那麼向上移動是順時針旋轉,向下則逆時針。反之,如果手指是在偏右邊的位置按下的話,那麼向上移動就是逆時針,向下則順時針;
哈哈,這個是不是有點像我們上次做的那個圓弧滑動的行為?
沒錯,在拖動時,我們可以從 View原始位置的中心點 (起始點) 連一條線到 當前觸控點(結束點) 並計算出角度,這個角度就剛好是View需要旋轉的角度。
手指鬆開之後(有滑動速率),每次位置更新時,都跟著去更新這個 起始點 ,也就是上面經過矩陣mapRect方法轉換座標後的矩形的中心點。
看這張圖:

emmm,整篇文章的重點就在這裡了,可以看到,在手指還沒鬆開的時候,藍色點(起始點)的位置是不變的(所在就是View原始位置的中心點),當手指鬆開後,這個藍色的點就移動到了藍色矩形的中心,並一直跟隨著更新位置。正是因為這樣,我們在甩出去的一瞬間,才能看到一個像圓弧滑動的效果。
當移動了一段距離之後,可以看到不再旋轉了,是因為後面這段距離的移動是在同一個方向上一直走,哈哈,希望大家也能像這個View一樣,在確定好方向後一直走下去~
好,現在從手指按下到手指鬆開的思路都已經有了,來總結一下整個過程:
-
當觸發了拖動事件時: 把一個透明的View新增到Activity的最頂層檢視中,然後把對應的子View內容draw上去,再隱藏該子View;
-
當手指繼續拖動時: 根據當前觸控點位置和View的原始位置中心點計算出對應的旋轉角度,並應用到最頂層View的Canvas中;
-
當手指鬆開: 藉助VelocityTracker獲得滑動速率,如果速率大於指定值,則判定為 “甩”,並通過Scroller來進行慣性移動,每次座標位置更新時,順便更新計算旋轉角度的起始點位置;
-
如手指鬆開後滑動速率低於指定值,則視為 “放手”,這時候需要通過ValueAnimator來配合位移,動畫目標落點的計算方式為:當前觸控點在上下左右四個方向中,偏移得最大的一方 + 隨機的偏移量;
-
當動畫播放完畢或Scroller滾動完成或者View內容超出螢幕時: 移除最頂層View,並回調監聽器,更新狀態;
嗯,整個過程的大致行為就是這樣了。
開工寫程式碼咯~
4 起名字
在開始寫程式碼之前,要先給這個自定義ViewGroup起一個接地氣的名字,
就叫:任意拖布局( RandomDragLayout ) 吧。
還要自定義一下那個被新增在最頂層的View(繪製和角度計算等任務都是在這個View裡面去處理),名字就叫GhostView好了。
開始編寫程式碼!
5 計算旋轉角度
先來看看怎麼正確計算兩個點的旋轉角度(順時針):

我們把藍點作為起始點,紅點作為結束點,將起始點和結束點連線(把矩形切分成了兩個直角三角形),因為計算的是順時針的角度,那就要找逆時針方向上的那一個三角形,可以看到,圖中的四個紅點(結束點)分別在四個不同的象限上:
-
當結束點在第四象限或第一象限時,我們要計算的角是斜邊和水平輔助線的夾角;
-
當結束點在第二或第三象限時,要計算的角則是斜邊與垂直輔助線的夾角;
好,我們可以把水平輔助線當作lineA,垂直輔助線作lineB,斜邊當作lineC,因為lineA和lineB的長度都能直接算出來,那麼根據勾股定理: a² + b² = c² 可得出lineC的長度。接著,求夾角,如果是在第四象限或第一象限,根據餘弦定理,即 cosB = lineA / lineC ,如果是第二或第三象限則: cosA = lineB / lineC ,接著用Math.acos函式得出反餘弦值(弧度),再通過 Math.toDegrees 將弧度轉為角度,當然了,最後別忘記加上基本角度(即: 第三象限要加上90,第一象限要加上180,第二象限+270),來看看程式碼怎麼寫:
/** * 計算兩個座標點的順時針角度,以第一個座標點為圓心 * * @param startX 起始點X軸的值 * @param startY 起始點Y軸的值 * @param endX結束點X軸的值 * @param endY結束點Y軸的值 * @return 以起始點為旋轉中心計算的順時針角度 */ private float computeClockwiseAngle(float startX, float startY, float endX, float endY) { //需要追加的角度 int appendAngle = computeNeedAppendAngle(startX, startY, endX, endY); //線條長度 float lineA = Math.abs(endX - startX); float lineB = Math.abs(endY - startY); //lineC = √ ̄ lineA² + lineB² float lineC = (float) Math.sqrt(Math.pow(lineA, 2) + Math.pow(lineB, 2)); float angle; //如果是第一象限或第四象限,則計算斜邊和水平線的夾角 if (appendAngle == 0 || appendAngle == 180) { //cosB = lineA / lineC angle = (float) Math.toDegrees(Math.acos(lineA / lineC)); } else {//如果是第二,第三象限,則計算斜邊和垂直線的夾角 //cosA = lineB / lineC angle = (float) Math.toDegrees(Math.acos(lineB / lineC)); } //加上需要追加的角度 return angle + appendAngle; } /** * 根據兩點的位置來判斷從起始點到結束點連線後的象限,並返回對應的角度 * * @param startX 起始點X軸的值 * @param startY 起始點Y軸的值 * @param endX結束點X軸的值 * @param endY結束點Y軸的值 * @return 對應象限的順時針基礎角度 */ private int computeNeedAppendAngle(float startX, float startY, float endX, float endY) { int needAppendAngle; //2 or 4 if (endX > startX) { if (endY > startY) { //4 needAppendAngle = 0; } else { //2 needAppendAngle = 270; } } //1 or 3 else { if (endY > startY) { //3 needAppendAngle = 90; } else { //1 needAppendAngle = 180; } } return needAppendAngle; //return (endX > startX) ? (endY > startY ? 0 : 270) : (endY > startY ? 90 : 180); }
看看效果:

哈哈,可以看到,無論手指在哪個位置按下,在拖動時,都能準確地計算出旋轉角度。
6 建立GhostView
好,那我們來看看GhostView應該怎麼寫:
先是成員變數:
private Bitmap mBitmap;//內容Bitmap private float mDownX, mDownY;//手指按下時的座標 private float mDownRawX;//手指按下時,在螢幕上的絕對X值 private float mBitmapCenterX, mBitmapCenterY;//Bitmap的中心點 private float mCurrentRawX, mCurrentRawY;//當前手指在螢幕上的絕對座標點 private float mStartAngle;//手指按下的角度 private float mCurrentAngle;//當前角度 private boolean isLeanLeft;//手指按下時,是否偏向View的左邊 private Matrix mMatrix;//應用旋轉的矩陣 private RectF mBitmapRect;//Bitmap的邊界(通過mapRect對映後的矩形) private OnOutOfScreenListener mOnOutOfScreenListener;
再到構造方法,方法引數的話,Context是不用說了,我們還要加一個內容超出螢幕的監聽器:
GhostView(Context context, OnOutOfScreenListener listener) { super(context); mOnOutOfScreenListener = listener; mMatrix = new Matrix(); mBitmapRect = new RectF(); } interface OnOutOfScreenListener { /** * 當Canvas的內容全部draw在View的邊界外面時回撥此方法 * * @param view 發生事件所對應的View */ void onOutOfScreen(GhostView view); }
好了,在觸發拖拽事件時,我們需要對一些數值進行初始化,比如說手指按下的座標值,初始角度等等:
/** * 當此方法被呼叫時,表示已經開始了拖動 * * @param event觸控事件 * @param bitmap View所對應的Bitmap */ void onDown(MotionEvent event, Bitmap bitmap) { //當前手指在螢幕中的絕對座標值 mCurrentRawX = mDownRawX = event.getRawX(); mCurrentRawY = event.getRawY(); //在View內的座標值 mDownX = event.getX(); mDownY = event.getY(); //計算出Bitmap的Left值和Top值 float l = mCurrentRawX - mDownX, t = mCurrentRawY - mDownY; //根據Bitmap的Left和Top分別得出Bitmap的中心點位置 mBitmapCenterX = l + bitmap.getWidth() / 2F; mBitmapCenterY = t + bitmap.getHeight() / 2F; //根據手指當前位置與Bitmap中心點位置計算出旋轉角度 mStartAngle = computeClockwiseAngle(mBitmapCenterX, mBitmapCenterY, mCurrentRawX, mCurrentRawY); //Bitmap寬度的一半 float halfWidth = bitmap.getWidth() / 2F; //如果手指在View內的X值小於Bitmap寬度的一半,那麼手指的位置就是在View的左邊 isLeanLeft = mDownX < halfWidth; mBitmap = bitmap; //通知draw一下 invalidate(); }
在觸發拖動的第一時間,RandomDragLayout那邊會呼叫這個onDown方法,bitmap就是子View的影象。
接著到onDraw方法了:
@Override protected void onDraw(Canvas canvas) { if (mBitmap != null) { //得出原始的l,t,r,b邊界值 float l = mCurrentRawX - mDownX, t = mCurrentRawY - mDownY; float r = l + mBitmap.getWidth(); float b = t + mBitmap.getHeight(); //應用到這個矩形裡面 mBitmapRect.set(l, t, r, b); //旋轉操作,旋轉中心就是手指的當前位置 mMatrix.setRotate(mCurrentAngle, mCurrentRawX, mCurrentRawY); //對映矩形 mMatrix.mapRect(mBitmapRect); //將進行過旋轉操作後的矩陣應用到Canvas裡 canvas.setMatrix(mMatrix); //畫出內容 canvas.drawBitmap(mBitmap, l, t, null); //檢查是否超出邊界,如果超出邊界則回撥監聽器 if (checkIsContentOutOfScreen()) { if (mOnOutOfScreenListener != null) { mOnOutOfScreenListener.onOutOfScreen(this); } } } }
可以看到,我們先是根據當前點(最新)和起始點(最早)算出了對映前的Bitmap邊界,然後應用到mBitmapRect裡。在mMatrix旋轉之後,像前面說的那樣,把旋轉後的Bitmap位置對映到了這個矩形上,接著把矩陣應用到Canvas裡然後drawBitmap,這樣draw出來的bitmap就是旋轉後的樣子了。
最後還呼叫了checkIsContentOutOfScreen方法,這個就是我們上面說的,根據對映後的矩形位置及尺寸,判斷是否在螢幕外面:
/** * 檢查Bitmap是否完全draw在螢幕之外 */ private boolean checkIsContentOutOfScreen() { return mBitmapRect.bottom < 0 || mBitmapRect.top > getBottom() || mBitmapRect.right < 0 || mBitmapRect.left > getRight(); }
emmmm,按下的事件處理完,接下來到移動了,這個其實也就更新一下當前觸控座標值已及根據新的座標值來重新計算下旋轉角度而已:
/** * 更新Bitmap的座標和旋轉角度 * * @param offsetX X軸上新的位置(相對) * @param offsetY Y軸上新的位置(相對) */ void updateOffset(float offsetX, float offsetY) { //更新座標值 mCurrentRawX += offsetX; mCurrentRawY += offsetY; //更新角度值: 為什麼要減去起始角度呢?因為手指按下時的角度不可能每次都是0, //這時候移動的話,比如說從90度降到了85度,實際上只是旋轉了5度,如果不減去起始角度, //在應用旋轉時就是85度,這顯然是錯誤的。 mCurrentAngle = computeClockwiseAngle(mBitmapCenterX, mBitmapCenterY, mDownRawX, mCurrentRawY) - mStartAngle; //通知重繪 invalidate(); }
哈哈,其實這裡還有個小細節,不知道大家有沒有看出來, 就是在呼叫計算角度方法的時候,endX的值不是像endY那樣傳的mCurrentRawY(當前值),而是傳的mDownRawX(初始值),這樣的話,因為X軸的值始終不變,那麼就能像我們上面說的那樣:手指水平拖動不會改變旋轉角度。 看這張圖:

可以看到,當手指左右拖動的時候,藍色線條的端點並不會跟著移動,計算出來的角度,自然也是不變了。
好,ACTION_MOVE處理完,到ACTION_UP了。如果有滑動速率的話,RandomDragLayout那邊就要呼叫Scroller的fling方法來進行慣性移動了,我們也可以在GhostView中用一個isFlinging來記錄一下是否已經開始了慣性移動,如果已經開始了的話,就應該更新起始點的位置了,即像上面說的那樣: " 每次座標位置更新時,順便更新計算旋轉角度的起始點位置"
我們來改一下上面的updateOffset方法:
/** * 標記已經開始慣性移動 */ void setFlinging() { isFlinging = true; } void updateOffset(float offsetX, float offsetY) { //更新座標值 mCurrentRawX += offsetX; mCurrentRawY += offsetY; if (isFlinging) { //如果已經開始了慣性移動,則每次更新中心點位置 mBitmapCenterX = mBitmapRect.centerX(); mBitmapCenterY = mBitmapRect.centerY(); //解除左右移動不能旋轉的束縛 mDownRawX = mCurrentRawX; } //更新角度值 mCurrentAngle = computeClockwiseAngle(mBitmapCenterX, mBitmapCenterY, mDownRawX, mCurrentRawY) - mStartAngle; //通知重繪 invalidate(); }
我們在更新角度前,還判斷了當前是否處於慣性移動狀態,如果是,則更新中心點位置。其次,因為在手指鬆開之前,endX是傳手指按下的值,那麼在手指鬆開後,那個 “左右移動不能旋轉” 的限制應該要解除了,可以看到,我們在更新中心點位置的同時,還把當前的X值賦給了mDownRawX。
好,開始慣性移動之前,還需要在RandomDragLayout呼叫這個方法來更新狀態
/** * 標記已經開始慣性移動 */ void setFlinging() { isFlinging = true; }
這樣的話,在手指做甩出去的動作時,內容Bitmap就能根據當前前進的方向不斷調整角度啦!
那手指鬆開還有另一種情況,就是沒有滑動速率的(低於指定值),那麼,這種情況下就需要我們自己去確定落點位置,然後播放一個位移動畫,位移的話,肯定需要一個起始點,和一個結束點,我們可以在這裡提供給RandomDragLayout那邊:
首先是起始點:
/** * 獲取位移動畫的起點 * * @return 起點位置 */ PointF getAnimationStartPoint() { return new PointF(mCurrentRawX, mCurrentRawY); }
沒錯,其實這個起始點也就是當前手指的位置了。
那現在來看看結束點怎麼計算:
/** * 獲取位移動畫的終點 * * @return 終點位置 */ PointF getAnimationEndPoint() { //螢幕一半寬度 float halfWidth = mBitmapCenterX; //螢幕一半高度 float halfHeight = mBitmapCenterY; //以螢幕中心為起點,手指向左邊移動相對於螢幕寬度一半的百分比距離 float leftPercent = 1F - mCurrentRawX / halfWidth; //同上,此為向右 float rightPercent = (mCurrentRawX - halfWidth) / halfWidth; //向上 float topPercent = 1F - mCurrentRawY / halfHeight; //向下 float bottomPercent = (mCurrentRawY - halfHeight) / halfHeight; //取其中最大值 float max = Math.max(Math.max(leftPercent, rightPercent), Math.max(topPercent, bottomPercent)); //反正一移動出螢幕就會移除View並中斷動畫,並且我們需要在任何地方的移動速度都不變,所以我們的距離可以指定為螢幕高度 + View高度 int maxBitmapLength = (int) Math.max(mBitmapRect.width(), mBitmapRect.height()); float distance = Math.max(getWidth(), getHeight()) + maxBitmapLength; //一個隨機大小的偏移量,範圍: -maxBitmapLength ~ maxBitmapLength int offset = -maxBitmapLength + new Random().nextInt(maxBitmapLength * 2); float toX, toY; //根據手指在四個方向上移動距離最長的那一方作為目標落點方向 if (max == leftPercent) { toX = -distance; toY = offset; //記錄當前方向 mTargetOrientation = ORIENTATION_LEFT; } else if (max == rightPercent) { toX = mCurrentRawX + distance; toY = offset; //記錄當前方向 mTargetOrientation = ORIENTATION_RIGHT; } else if (max == topPercent) { toX = offset; toY = -distance; //記錄當前方向 mTargetOrientation = ORIENTATION_TOP; } else { toX = offset; toY = mCurrentRawY + distance; //記錄當前方向 mTargetOrientation = ORIENTATION_BOTTOM; } return new PointF(toX, toY); }
恩,就像我們前面構思的一樣:“動畫目標落點的計算方式為:當前觸控點在上下左右四個方向中,偏移得最大的一方 + 隨機的偏移量;”,可以看到初始化toX,toY時,還記錄了一下當前的方向。
7 建立RandomDragLayout
好,是時候建立RandomDragLayout了,繼承自ViewGroup,這個不用說。
初始化我們要做點什麼呢?
因為等下要把GhostView新增到Activity最頂層檢視中,那首先肯定要拿到對應Activity的根檢視了:
我們要先根據Context來獲取到對應的Activity:
/** * 根據View的Context來獲取對應的Activity * * @return 該View所在的Activity */ private Activity getActivity() { Context context = getContext(); while (context instanceof ContextWrapper) { if (context instanceof Activity) { return (Activity) context; } context = ((ContextWrapper) context).getBaseContext(); } throw new RuntimeException("Activity not found!"); }
再根據這個Activity來獲取到DecorView:
mRootView = (ViewGroup) getActivity().getWindow().getDecorView();
嗯,等下就直接呼叫mRootView的addView方法把GhostView新增到最頂層了。
好,接下我們需要重寫addView方法,來控制子View的數量,使他只能存在一個直接子View:
/** * 重寫父類addView方法,僅允許擁有一個直接子View */ @Override public void addView(View child, int index, LayoutParams params) { if (getChildCount() > 0) { throw new IllegalStateException("RandomDragLayout can only contain 1 child!"); } super.addView(child, index, params); mChild = child; }
為方便接下來的程式碼編寫,可以看到我們在addView的時候,還用了一個成員變數mChild來引用這個唯一的子View。
接下來到 onMeasure :
@Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { if (mChild == null) { throw new IllegalStateException("RandomDragLayout at least one child is needed!"); } //測量子View measureChild(mChild, widthMeasureSpec, heightMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int widthMode = MeasureSpec.getMode(widthMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int width, height; //如果RandomDragLayout有指定尺寸則使用指定的尺寸,沒有指定的話,我們用子View的 MarginLayoutParams layoutParams = (MarginLayoutParams) mChild.getLayoutParams(); if (widthMode == MeasureSpec.EXACTLY) { width = widthSize; } else { width = mChild.getMeasuredWidth() + layoutParams.leftMargin + layoutParams.rightMargin; } if (heightMode == MeasureSpec.EXACTLY) { height = heightSize; } else { height = mChild.getMeasuredHeight() + layoutParams.topMargin + layoutParams.bottomMargin; } setMeasuredDimension(width, height); }
這個比較好理解,如果RandomDragLayout設定的是wrap_content,則使用子View的尺寸。
好,接下來到 onLayout 了:
@Override protected void onLayout(boolean changed, int l, int t, int r, int b) { MarginLayoutParams layoutParams = (MarginLayoutParams) mChild.getLayoutParams(); mChild.layout(getPaddingLeft() + layoutParams.leftMargin, getPaddingTop() + layoutParams.topMargin, mChild.getMeasuredWidth() - getPaddingRight() + layoutParams.leftMargin, mChild.getMeasuredHeight() - getPaddingBottom() + layoutParams.topMargin); }
onLayout方法非常簡單,因為只有一個子View,我們不用多餘的邏輯,就處理了一下Padding和Margin。
好了,現在開始處理觸控事件了,考慮到子View也有它自己的點選,長按之類的事件,所以我們還需要重寫 onInterceptTouchEvent 方法,在手指按下並移動了一定的距離之後,才觸發我們的拖拽效果:
@Override public boolean onInterceptTouchEvent(MotionEvent event) { //不可用 if (!isEnabled()) { return false; } if ((event.getAction() == MotionEvent.ACTION_MOVE && isBeingDragged) || super.onInterceptTouchEvent(event)) { //如果已經開始了拖動,則繼續佔用此次事件 requestDisallowInterceptTouchEvent(true); return true; } float x = event.getX(), y = event.getY(); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: //記錄手指按下的座標 mLastX = x; mLastY = y; break; case MotionEvent.ACTION_MOVE: float offsetX = x - mLastX; float offsetY = y - mLastY; //判斷是否觸發拖動事件,垂直或水平移動距離大於mTouchSlop觸發 if (Math.abs(offsetX) > mTouchSlop || Math.abs(offsetY) > mTouchSlop) { mLastX = x; mLastY = y; //標記已經開始拖拽 isBeingDragged = true; } break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_OUTSIDE: //手指鬆開後,要重置拖拽狀態 isBeingDragged = false; break; } //如果已經開始了拖拽,則禁止父佈局攔截接下來的事件,反之,允許 requestDisallowInterceptTouchEvent(isBeingDragged); return isBeingDragged; }
mTouchSlop是通過:
ViewConfiguration.get(context).getScaledTouchSlop();
來獲得。
好了,攔截到了事件之後,開始處理了,我們來看 onTouchEvent :
public boolean onTouchEvent(MotionEvent event) { float x = event.getX(), y = event.getY(); mVelocityTracker.addMovement(event); switch (event.getAction()) { case MotionEvent.ACTION_DOWN: case MotionEvent.ACTION_MOVE: handleActionMove(event, x, y); break; case MotionEvent.ACTION_UP: case MotionEvent.ACTION_CANCEL: case MotionEvent.ACTION_OUTSIDE: handleActionUp(); break; default: break; } mLastX = x; mLastY = y; return true; }
細心的同學會發現,ACTION_DOWN居然跟ACTION_MOVE是同樣的行為,為什麼呢?
因為觸控事件在經過onInterceptTouchEvent方法之後,如果View有設定自己的點選事件時,那麼需要移動一小段距離才會觸發攔截,這時候,事件動作已經不是ACTION_DOWN,而是ACTION_MOVE了,所以我們還要在處理ACTION_MOVE的時候,判斷GhostView是否已經添加了,這個可以用一個isGhostViewShown來記錄:
/** * 處理 ACTION_MOVE 事件 */ private void handleActionMove(MotionEvent event, float x, float y) { if (isGhostViewShown) { //如果GhostView已經在顯示的話,直接更新座標 mGhostView.updateOffset(x - mLastX, y - mLastY); } else { //如果還沒新增,先把子View顯示的東西都draw到mBitmap上 mChild.draw(mCanvas); //隱藏真實的View mChild.setVisibility(INVISIBLE); //初始化GhostView initializeGhostView(); //新增到根檢視,寬高=螢幕尺寸 mRootView.addView(mGhostView, LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT); //回撥GhostView的onDown方法,表示已經開始了拖拽 mGhostView.onDown(event, mBitmap); //標記一下狀態 isGhostViewShown = true; } } /** * 初始化GhostView */ private void initializeGhostView() { //防止重複新增 if (mGhostView != null) { mRootView.removeView(mGhostView); } mGhostView = new GhostView(getContext(), new GhostView.OnOutOfScreenListener() { @Override public void onOutOfScreen(GhostView view) { //當GhostView內容超出屏幕後,打斷慣性動畫 mScroller.abortAnimation(); } }); }
可以看到在初始化GhostView之前,呼叫了mChild的draw方法來將子View的影象儲存在mBitmap上,而這個mCanvas和mBitmap的初始化,我們選擇在onSizeChanged裡面初始化:
@Override protected void onSizeChanged(int w, int h, int oldw, int oldh) { super.onSizeChanged(w, h, oldw, oldh); if (w > 0 && h > 0) { //更新畫布尺寸 mCanvas = new Canvas(mBitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)); } }
好,現在來看看ACTION_UP怎麼處理的:
/** * 處理 ACTION_UP 事件 */ private void handleActionUp() { //ACTION_UP時,可能GhostView沒有被新增, //這個事件可能是從子View傳回來的,所以這裡需要做非空判斷 if (mGhostView != null) { //標記狀態:已經不是在拖拽中了 isBeingDragged = false; //計算當前滑動速率 mVelocityTracker.computeCurrentVelocity(1000); float xVelocity = mVelocityTracker.getXVelocity(); float yVelocity = mVelocityTracker.getYVelocity(); //X軸和Y軸其中一個的滑動速率超過500,則視為有滑動速率,這時候要進行慣性移動 if (Math.abs(xVelocity) > 500 || Math.abs(yVelocity) > 500){ startFling(xVelocity, yVelocity); } else { //否則播放位移動畫 startAnimator(); } } } /** * 開始慣性移動 */ private void startFling(float xVelocity, float yVelocity) { //先標記開始慣性移動 mGhostView.setFlinging(); //開始 mScroller.fling(0, 0, (int) xVelocity, (int) yVelocity, Integer.MIN_VALUE, Integer.MAX_VALUE, Integer.MIN_VALUE, Integer.MAX_VALUE); //使其回撥computeScroll invalidate(); }
emmmm,邏輯挺簡單的,就是判斷一下當前的滑動速率,超過500(當然了,這裡不應該寫死的),就開啟慣性滾動,小於500則播放動畫,這個做法跟前面的思路是一樣的。
我們還需要重寫computeScroll方法,並在這裡面去處理慣性移動的邏輯:
@Override public void computeScroll() { if (mScroller.computeScrollOffset()) { float y = mScroller.getCurrY(); float x = mScroller.getCurrX(); //更新座標 mGhostView.updateOffset(x - mLastScrollOffsetX, y - mLastScrollOffsetY); //更新上一次的座標值 mLastScrollOffsetX = x; mLastScrollOffsetY = y; //繼續通知回撥 invalidate(); } else if (mScroller.isFinished()) { if (mRootView != null) { //防止報:Attempt to read from field 'int android.view.View.mViewFlags' on a null object reference post(new Runnable() { @Override public void run() { //慣性移動完畢,從Activity頂層檢視移除GhostView mRootView.removeView(mGhostView); mGhostView = null; } }); } //慣性移動完畢,重置偏移量 mLastScrollOffsetX = 0; mLastScrollOffsetY = 0; } }
其實也很簡單,就是判斷Scroller是否滾動完畢,如果還沒滾動完,就繼續呼叫GhostView的updateOffset來更新座標值,然後通過呼叫invalidate方法來通知重繪。如果滾動完畢,則將GhostView從頂層檢視中移除。當然了,在這裡我們也可以加上一些狀態回撥的監聽。
好,那現在就剩下最後一個:播放位移動畫啦,堅持~
還記不記得我們剛剛在GhostView那邊定義的獲取位移動畫起始點和結束點的方法?
它返回的是一個PointF物件,而我們要用的ValueAnimator並沒有ofPoint之類的靜態方法,所以只能ofObject了,然後自定義一個TypeEvaluator,先來看看這個TypeEvaluator怎麼自定義:
/** * 自定義Evaluator,使ValueAnimator支援PointF */ TypeEvaluator<PointF> mEvaluator = new TypeEvaluator<PointF>() { //物件複用,小幅度提升執行效率和降低記憶體佔用 private final PointF temp = new PointF(); @Override public PointF evaluate(float fraction, PointF startValue, PointF endValue) { //X軸總距離 float totalX = endValue.x - startValue.x; //Y軸總距離 float totalY = endValue.y - startValue.y; //當前絕對座標值 = 開始座標值 + 相對座標值 float x = startValue.x + (totalX * fraction); float y = startValue.y + (totalY * fraction); //更新數值 temp.set(x, y); return temp; } };
看,我們自定義的這個TypeEvaluator也是很簡單的:在每次回撥時,根據當前進度計算出X和Y各自的座標值,然後賦值到temp裡面並返回。
那現在回到startAnimator方法:
/** * 播放位移動畫 */ private void startAnimator() { mAnimator = ValueAnimator.ofObject(mEvaluator, mGhostView.getAnimationStartPoint(), mGhostView.getAnimationEndPoint()).setDuration(800); mAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() { @Override public void onAnimationUpdate(ValueAnimator animation) { //防止內容在已超出螢幕之後(這時候GhostView已移除和置空)還繼續更新位置 if (mGhostView != null) { mGhostView.onAnimationUpdate((PointF) animation.getAnimatedValue()); } } }); mAnimator.start(); }
emmmm,就是建立了一個ValueAnimator監聽進度更新然後播放(但那個時長是不應該寫死的這裡只是為了方便閱讀)。
可以看到在UpdateListener的回撥裡面,呼叫了GhostView的onAnimationUpdate方法,傳了一個PointF進去,我們來看看這個方法做了什麼:
/** * 播放位移動畫時的幀更新回撥 * * @param location 新的位置(絕對) */ void onAnimationUpdate(PointF location) { //更新座標值 mCurrentRawX = location.x; mCurrentRawY = location.y; invalidate(); }
哈哈,非常簡單,因為傳進來的location已經是絕對座標值,我們可以直接賦值,然後通知重繪了。
好啦,現在我們已經處理完了從手指按下到手指鬆開的整個流程。
在使用的時候是非常簡單的,只需要直接在目標View外面套一個RandomDragLayout就行了,比如說像這樣:
<com.wuyr.randomdraglayout.RandomDragLayout android:layout_width="wrap_content" android:layout_height="wrap_content"> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="#88F" android:padding="16dp" android:text="RandomDragLayoutTest" /> </com.wuyr.randomdraglayout.RandomDragLayout>
發一下跟QQ空間的效果對比:


哈哈哈,效果還不錯~
好了,本篇文章到此結束,在這裡祝大家:新春快樂,年年有魚!
有錯誤的地方請指出,多謝~
Github地址:
https://github.com/wuyr/RandomDragLayout 歡迎Star
免費獲取安卓開發架構的資料(包括Fultter、高階UI、效能優化、架構師課程、 NDK、Kotlin、混合式開發(ReactNative+Weex)和一線網際網路公司關於android面試的題目彙總可以加:936332305 / 連結:點選連結加入【安卓開發架構】: https://jq.qq.com/?_wv=1027&k=515xp64

image