教你10行代碼寫側滑菜單,10行代碼滑菜單
原帖最初發表於傳智播客黑馬程式員論壇,地址:http://bbs.itheima.com/thread-167442-1-1.html
先來看個側滑菜單效果:
上面分別為:初始狀態->滑動中->鬆開開啟菜單
你造嗎?看完本文章,寫這種側滑菜單,so easy for you!
你造嗎?其實寫這個側滑菜單,只需要10行手寫代碼就能搞定!
你造嗎?看完本文章,當你再聽到產品說"我要這個效果"時,再也不會底氣不足!
在Android開發中,自訂View效果是屬於進階的部分。因為常用邏輯上的東西,經過1年開發後,你會的大家都會。而對於如何?特殊效果的view的技能上,就可以分個高低了。所以說,無論是面子上的事兒(可以得到程式員妹紙的崇拜),還是職業技能的提高,我們都非常有必要去熟諳view特效之技巧。
接下來,我將用原理分析加程式碼範例的方式說明讓view移動的一些常用技巧。
在實際開發中,我們一般會在2種情況下需要讓view移動:
1.觸摸過程中,隨著手指拖動讓view移動
2.手指抬起後,讓view平滑緩慢移動到某個位置
View移動的本質是什嗎?
試想一下,view無論出現在螢幕中任何一個位置都是有其座標點,假如沒有座標,那麼view的出現就是不可控的,從而也無法進行繪製了。
所謂view移動,無非是讓view從一個座標點移動到另外一個座標點。
View在什麼座標系裡面移動?
先想想座標系的概念:
螢幕座標系:原點在螢幕左上方,x軸正方向朝右,y軸正方向朝下;
view座標系:原點在view的左上方,同樣x軸正方向朝右,y軸正方向朝下;
:
(如果對上面2種座標系的說明有疑惑,可以在activity中放置一個簡單的TextView,最好讓TextView距離父view有些距離,對TextView進行觸摸監聽,然後在onTouchEvent中列印event.getX()和event.getRawX(),前者是觸摸點相對於view左上方的座標,後者是觸摸點相對於螢幕左上方的座標)
View移動可以細分為2類:
a.view本身在父view中移動,即整體移動,同時該分類也可看做是父view的內容在移動
b.view的內容在移動,即內部移動
View移動有哪些具體的API?
下面結合上面的分析來看移動view的具體方法:
1.通過更改view的left,top,right,bottom來移動view
該方法只適用於在ViewGroup中移動子view,即移動分類中的a類。我們知道left,top,right,bottom4個值是在onLayout方法執行的時候初始化的。同時對4個參數的意思再做一次簡單說明,只拿left和top為例:
left:表示當前view的左邊距父view原點的距離
top:表示當前view的頂部距父view原點的距離
view提供了3個方法來更改這些值:
layout(l,r,t,b);offsetLeftAndRight(offset);//同時對left和right進行位移offsetTopAndBottom(offset);//同時對top和bottom進行位移
那麼假如我們對一個view添加觸摸監聽,在TouchMove的時候得到觸摸移動的值,分別使用這3個方法就可以達到讓view隨我們手指移動而在父view中移動的目的.
程式碼範例:
public boolean onTouch(View v, MotionEvent event) { float x = event.getX(); float y = event.getY(); switch(event.getAction()) { case MotionEvent.ACTION_DOWN: break; case MotionEvent.ACTION_MOVE: float deltaX = x -lastX; float deltaY = y -lastY;// text.layout(text.getLeft()+deltaX,text.getTop()+deltaY, text.getRight()+deltaX, text.getBottom()+deltaY); text.offsetLeftAndRight((int) deltaX); text.offsetTopAndBottom((int) deltaY); break; } lastX = x; lastY = y; return true;}
2.通過更改scrollX和scrollY來移動view,即scrollTo和scrollBy方法,該方法是用來移動view內容的,用官方的話說就是:internally scroll theircontent.
根據對view移動的分類,如果是在ViewGroup中使用,則可以讓當前view的所有子view同時移動,如果是對單個view使用,則可以讓view的內容移動。
注意:view的background不屬於view的內容,
下面摘自一段view的draw方法中的注釋,來看view繪製的步驟:
/* *Draw traversal performs several drawing steps which must be executed *in the appropriate order: * 1. Draw the background * 2. If necessary, save the canvas' layers toprepare for fading * 3. Draw view's content * 4. Draw children * 5. If necessary, draw the fading edges and restorelayers * 6. Draw decorations (scrollbars forinstance) */
對於這裡的view的content,對於TextView,content就是它的文本,對於ImageView,則是它的drawable對象。
下面解釋scrollX和scrollY:
首先如果直接使用scrollTo和scrollBy這2個方法,你會發現當你scrollTo(10,0)的時候,view的內容卻向左移動10,同樣scrollBy(10,0),view的內容會繼續向左移動10,竟然不是向右移動,這很讓人迷惑。
先來看下官方對scrollX的解釋:
/** *Return the scrolled left position of this view. This is the left edge of *the displayed part of your view. You do not need to draw any pixels *farther left, since those are outside of the frame of your view on screen. * @return The leftedge of the displayed part of your view, in pixels. */
首先Android繪製view的時候都有一個bounds,可通過view.getDrawingRect()擷取,其實是個Rect對象,這個Rect的地區就是Canvas真正繪製的地區,超過這個bounds就不會再繪製。其實上邊注釋中的the left edge of the displayed part if yourview,就是指bounds的left。Rect的left,top,right,bottom值分別為scrollX,scrollY,scrollX+width,scrollY+height;顯然Rect的left和top最初都是0,因為沒有scroll,當scrollTo(10,0)後,Rect的left為10,即bounds向右移動了10,那麼這時候再在移動後的bounds範圍內繪製的時候,會看到是view的內容向左移動了,因為view的位置是不變的,bounds右移,會造成內容向左移動的視覺效果。這也是我們疑惑的地方。究其原因,就是scrollTo和scrollBy是指view的bounds移動,並不是直接指view內容的移動。同時scroll所在座標系是當前view座標系。
scrollTo和scrollBy方法移動view過程:
那麼假如我們對一個view添加觸摸監聽,在TouchMove的時候得到觸摸移動的值,分別使用scrollTo和scrollBy方法就可以達到讓view的內容隨我們手指移動而移動的目的。
程式碼範例:
public boolean onTouch(View v, MotionEvent event) { float x = event.getX(); float y = event.getY(); switch(event.getAction()) { case MotionEvent.ACTION_DOWN: break; case MotionEvent.ACTION_MOVE: float deltaX = x -lastX; float deltaY = y -lastY; /*為什麼是減去delatX:newScrollX:手指往右移動,deltaX會是正值,scrollX會更小,bounds的left更小,則bounds往左移動,view的內容會往右移動*/ float newScrollX =text.getScrollX()- deltaX; float newScrollY =text.getScrollY()- deltaY; text.scrollTo((int)newScrollX, (int)newScrollY); //scrollBy傳負值的原理同上text.scrollBy((int)-deltaX,(int)-deltaY); break; } lastX = x; lastY = y; return true;}
如何讓View平滑移動?
上面2種方法一般用在對View進行TouchMove的時候讓view移動的,而且layout和scroll都是瞬間移動過去,那麼問題來了,當我們手指抬起後,想讓View移動平滑移動到指定位置該怎麼辦?既然layout和scroll都能移動view,那我們在一段時間內迴圈調用這些方法,每次移動一點,不就能夠平滑移動了嗎。
對於這個需求,一般有2種做法:
1.Scroller實現
2.自訂動畫實現
一.用Scroller實現View平滑移動
Scroller封裝了對view移動的操作,但它是一個類比移動,並不是真正去移動view。由於它類比了view的整個移動過程,所以我們可以在類比過程中,迴圈擷取當前view真實移動的時候的scrollX,scrollY;那麼拿到scrollX和scrollY後,再調用scrollTo就達到平滑移動的目的了。
使用Scroller一般有3個固定步驟:
a.初始化Scroller
Scroller scroller =new Scroller(getContext());
b.開啟類比過程
scroller.startScroll(startX, startY, dx, dy,500);invalidate();
c.在類比過程中擷取view真實移動時的值,並調用scrollTo去真正移動view
public void computeScroll() { super.computeScroll(); if(scroller.computeScrollOffset()){ scrollTo(scroller.getCurrX(),scroller.getCurrY()); invalidate(); }}
說明:在b,c步驟中,需要說明的是必須調用invalidate()方法,因為我們只能在computeScroll方法中擷取類比過程中的scrollX和scrollY,但是computeScroll不會自動調用,而invalidate->draw->computeScroll;所以需要調用invalidate,從而間接調用computeScroll,達到迴圈擷取scrollX和scrollY的目的。當類比過程結束後,scroller.computeScrollOffset()會返回false,從而中斷迴圈。
二.用自訂動畫實現View平滑移動
由於系統定義的TranslateAnimation只能移動view本身,而不能移動view的內容,所以需要自訂。系統定義好的4種動畫,本質都是在一段時間內改變view的某個屬性,所以我們也可以自訂動畫,繼承自Animation類,去在一段時間內改變我們想改變的屬性,比如scrollX,scrollY。
Animation同樣是類比了一個執行過程,它與Scroller很相似,不同的是Scroller為我們計算出了view真實移動情況下的scrollX和scrollY,而Animation沒有。另外Scroller需要我們去主動調用computeScroll,而Animation不需要,它在執行過程中會迴圈調用applyTransformation方法,直到動畫結束為止。所以我們需要在applyTransformation方法中計算當前的scrollX和scrollY,然後調用view.scrollTo(x,y);
由於applyTransformation的第一個interpolatedTime為我們標識了動畫執行的進度和百分比,所以我們可以根據這個參數擷取執行過程中任意時刻的scrollX和scrollY。
下面寫一個通用的讓view在一段時間內緩慢scroll到指定位置的動畫,代碼如下:
public class SmoothScrollAnimation extends Animation{ private View view; private int originalScrollX; private int totalScrollDistance; public SmoothScrollAnimation(View view,int targetScrollX){ super(); this.view= view; originalScrollX = view.getScrollX(); totalScrollDistance = targetScrollX- originalScrollX; setDuration(400); } @Override protected void applyTransformation(float interpolatedTime, Transformation t){ super.applyTransformation(interpolatedTime, t); int newScrollX =(int)(originalScrollX+ totalScrollDistance*interpolatedTime); view.scrollTo(newScrollX,0); }}
使用強大的ViewDragHelper
最後,介紹一個更強大的類ViewDragHelper,在上面的過程中我們需要監視觸摸,計算手指移動距離,在去不斷調用layout方法去移動view,在手指抬起時去自己緩慢移動view,有了這個類,你統統不需要做了。
ViewDragHelper一般用於在ViewGroup中對子view的拖拽移動,不過它需要接收一個TouchEvent事件。
它封裝了對觸摸位置(是否在邊緣和當前觸摸的是哪個子view),手指移動距離,移動速度,移動方向的檢測,以及Scroller.只需要我們指定什麼時候開始檢測,具體移動多少。
那麼我們現在來做這個文章開頭的側滑菜單的效果,是真正的10行手寫代碼實現的,可見ViewDragHelper的強大。
方法的說明都在代碼中,代碼如下:
public class SlideMenu extends FrameLayout{ public SlideMenu(Contextcontext, AttributeSet attrs) { super(context,attrs); init(); } private View menuView,mainView; private int menuWidth;//菜單寬度 private ViewDragHelperviewDragHelper; private void init(){ viewDragHelper =ViewDragHelper.create(this,callback); } @Override protected void onFinishInflate() { super.onFinishInflate(); if(getChildCount()!=2){ throw newIllegalArgumentException("Youlayout must have only 2 children!"); } menuView =getChildAt(0); mainView = getChildAt(1); } @Override protected void onSizeChanged(int w,int h, int oldw,int oldh) { super.onSizeChanged(w,h, oldw, oldh); menuWidth = menuView.getMeasuredWidth(); } @Override public boolean onInterceptTouchEvent(MotionEvent ev) { return viewDragHelper.shouldInterceptTouchEvent(ev); } @Override public boolean onTouchEvent(MotionEvent event) { //將觸摸事件傳遞給ViewDragHelper,此操作必不可少 viewDragHelper.processTouchEvent(event); return true; } //ViewDragHelper對觸摸監聽的回調 private ViewDragHelper.Callbackcallback = new Callback() { /** * 什麼時候開始監測觸摸 */ @Override public boolean tryCaptureView(View child,int pointerId) { return mainView==child;//如果當前觸摸的child是mainView時開始檢測 } /** * 當觸摸到childView時回調 */ @Override public void onViewCaptured(View capturedChild,int activePointerId) { super.onViewCaptured(capturedChild,activePointerId); } /** * 當拖拽狀態改變,比如idle,dragging */ @Override public void onViewDragStateChanged(int state) { super.onViewDragStateChanged(state); } /** * 在這裡決定真正讓view在垂直方向移動多少,預設實現則不會移動 * 移動原理:最初top為0,當檢測到手指向下拖動了10,則dy=10,top=child.getTop()+dy; * 如果返回0,則top一直為0,那麼view就在垂直方向就不會移動 * 如果返回top,則view會一直跟隨手指拖動而移動 * @param top top為它認為你想移動到最新的top值 * @param dy垂直方向移動了多少 */ @Override public int clampViewPositionVertical(View child,int top, int dy) { return 0; } /** * 在這裡決定真正讓view在水平方向移動多少,預設實現則不會移動 * 移動原理:最初left為0,當檢測到手指向右拖動了10,則dx=10,left=child.getLeft()+dx; * 如果返回0,則left一直為0,那麼view就在水平方向就不會移動 * 如果返回left,則view會一直跟隨手指拖動而移動 * @param left left為它認為你想移動到最新的left值 * @param dx水平方向移動了多少 */ @Override public int clampViewPositionHorizontal(View child,int left, int dx) { return left; } /** * view移動後的回調 * @param left移動後的view最新的left值 * @param top移動後的view最新的top值 * @param dx x方向移動了多少 * @param dy y方向移動了多少 */ @Override public void onViewPositionChanged(View changedView,int left, int top, int dx,int dy) { super.onViewPositionChanged(changedView,left, top, dx, dy); } /** * 手指抬起回調 * @param xvel x方向滑動的速度 * @param yvel y方向滑動的速度 */ @Override public void onViewReleased(View releasedChild,float xvel, float yvel) { super.onViewReleased(releasedChild,xvel, yvel); //手指抬起後緩慢移動到指定位置 if(mainView.getLeft()<menuWidth/2){ //關閉菜單 viewDragHelper.smoothSlideViewTo(mainView, 0, 0);//相當於Scroller的startScroll方法 ViewCompat.postInvalidateOnAnimation(SlideMenu.this); }else { //開啟菜單 viewDragHelper.smoothSlideViewTo(mainView,menuWidth, 0); ViewCompat.postInvalidateOnAnimation(SlideMenu.this); } } }; /** * 對Scroller的封裝 */ public void computeScroll() { if(viewDragHelper.continueSettling(true)){ ViewCompat.postInvalidateOnAnimation(this); } }}View移動總結
綜上所述,當你明白了移動view的原理和api後,不用再去在TouchMove的時候去自己手動移動view,如果對layout方法和scrollTo,scrollBy方法理解不深,就將上面對應代碼複製到自己的demo中去感受下。
由於更多的移動veiw的情況是在ViewGroup中去移動子view,所以一般都用ViewDragHelper去做,這個類的介紹由於篇幅有限,可能對各個方法的理解還不夠透徹,將代碼運行起來並試著去改改效果,多感受一下就明白了。