SlidingMenu原始碼分析

zhifeng687發表於2015-12-04
本章算是自定義控制元件系列的一個例項分析,SlidingMenu算是自定義控制元件裡比較優秀的一個了,所以選了這個研究
看之前最好先簡單看下我文章裡的自定義控制元件教程以及ScrollView原理分析~可以點開個人資料檢視相關文章~

<Scroll效果研究-系統ScrollView原始碼分析>
http://www.eoeandroid.com/thread-553375-1-1.html

自定義控制元件教程第一篇
http://www.eoeandroid.com/thread-548644-1-1.html


SlidingMenu設計思路三個主要的ViewGroup, 主ViewGroup裡面包含兩個重疊的ViewGroup,蓋在上面的就是顯示內容,而下面的就是選單
上面的側滑以後露出後面的View~
主ViewGroup作為一個自定義控制元件,裡面的內容和選單利用自定義屬性設定

SlidingMenu(繼承RelativeLayout),就是這個主ViewGroup,
其中又包含兩個,內容CustomViewAbove和選單CustomViewBehind(都繼承ViewGroup),
SlidingMenu提供兩個自定義屬性,供使用者傳入兩個佈局id,對應主體內容和選單佈局,
此外還有其他一些屬性供開發者設定選單效果

下面是程式碼分析
----------------------------------------------------------------------------------------

建構函式中主要程式碼如下:
  1. LayoutParams behindParams = new LayoutParams(LayoutParams. MATCH_PARENT, LayoutParams.MATCH_PARENT);
  2. mViewBehind = new CustomViewBehind(context);
  3. addView(mViewBehind , behindParams);
  4. LayoutParams aboveParams = new LayoutParams(LayoutParams. MATCH_PARENT, LayoutParams.MATCH_PARENT);
  5. mViewAbove = new CustomViewAbove(context);
  6. addView(mViewAbove , aboveParams);
  7. // register the CustomViewBehind with the CustomViewAbove
  8. mViewAbove.setCustomViewBehind(mViewBehind );
  9. mViewBehind.setCustomViewAbove(mViewAbove );
  10. mViewAbove.setOnPageChangeListener(new OnPageChangeListener() {
  11.       public static final int POSITION_OPEN = 0;
  12.       public static final int POSITION_CLOSE = 1;
  13.       public static final int POSITION_SECONDARY_OPEN = 2;

  14.       public void onPageScrolled( int position, float positionOffset,
  15.                    int positionOffsetPixels) { }

  16.       public void onPageSelected( int position) {
  17.              if (position == POSITION_OPEN && mOpenListener != null) {
  18.                    mOpenListener.onOpen();
  19.             } else if (position == POSITION_CLOSE && mCloseListener != null) {
  20.                    mCloseListener.onClose();
  21.             } else if (position == POSITION_SECONDARY_OPEN && mSecondaryOpenListner != null ) {
  22.                    mSecondaryOpenListner.onOpen();
  23.             }
  24.       }
  25. });

  26. // now style everything!
  27. TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.SlidingMenu);
  28. // set the above and behind views if defined in xml
  29. int mode = ta.getInt(R.styleable.SlidingMenu_mode, LEFT);
  30. setMode(mode);
  31. int viewAbove = ta.getResourceId(R.styleable. SlidingMenu_viewAbove, -1);
  32. if (viewAbove != -1) {
  33.       setContent(viewAbove );
  34. } else {
  35.       setContent( new FrameLayout(context));
  36. }
  37. int viewBehind = ta.getResourceId(R.styleable.SlidingMenu_viewBehind, -1);
  38. if (viewBehind != -1) {
  39.       setMenu(viewBehind);
  40. } else {
  41.       setMenu(new FrameLayout(context));
  42. }
複製程式碼

新建兩個自定義控制元件CustomViewAbove/Behind將其addView到SlidingMenu中,
然後在通過屬性獲取到view的id後會在setContent/Menu方法中設定對應佈局
下面是content處理相關程式碼,menu同理
  1. /**
  2. * Set the above view content to the given View.
  3. *
  4. * @param view The desired content to display.
  5. */
  6. public void setContent(View view) {
  7.       mViewAbove.setContent(view);
  8.       showContent();
  9. }
複製程式碼
mViewAbove的setContent方法程式碼為
  1. /**
  2. * Set the behind view (menu) content to the given View.
  3. *
  4. * @param view The desired content to display.
  5. */
  6. public void setMenu(View v) {
  7.       mViewBehind.setContent(v);
  8. }
複製程式碼

下面分別介紹Above和Behind兩部分

-----------------------------------------------------------------------------------------

CustViewAbove類,包含主要內容的類,即顯示在SlidingMenu上面/前端的部分
由於SlidingMenu實現效果重點在於滑動,滑動前端顯示的內容然後展現出下面的選單部分,
所以重中之重自然就是這個CustViewAbove類裡的onTouchEvent裡的處理了

以下是核心方法ouTouchEvent中的分析,請先看完教程Scroll效果研究-系統ScrollView原始碼分析



SlidingMenu的CustomViewAbove和ScrollView處理沒什麼區別,看註釋就知道很多都是直接拷貝過去的

同理分按下,滑動,抬起幾部分看
1.按下
  1. case MotionEvent.ACTION_DOWN :
  2.       /*
  3.        * If being flinged and user touches, stop the fling. isFinished
  4.        * will be false if being flinged.
  5.        */
  6.       completeScroll();

  7.       // Remember where the motion event started
  8.       int index = MotionEventCompat. getActionIndex(ev);
  9.       mActivePointerId = MotionEventCompat. getPointerId(ev, index);
  10.       mLastMotionX = mInitialMotionX = ev.getX();
複製程式碼
如果還在滾動則停止~
記住開始在哪裡點下的,這裡只記錄了x軸座標,因為側滑只關注橫線移動~
mActivityPointerId是記錄多點觸碰的activity第一個點的id,總之是用來處理多點除控時拖動的穩定性


2.滑動
  1. case MotionEvent.ACTION_MOVE :
  2.       if (!mIsBeingDragged) {
  3.             determineDrag(ev);
  4.              if ( mIsUnableToDrag)
  5.                    return false;
  6.       }
  7.       if (mIsBeingDragged) {
  8.              // Scroll to follow the motion event
  9.              final int activePointerIndex = getPointerIndex(ev, mActivePointerId);
  10.              if ( mActivePointerId == INVALID_POINTER)
  11.                    break;
  12.              final float x = MotionEventCompat. getX(ev, activePointerIndex);
  13.              final float deltaX = mLastMotionX - x;
  14.              mLastMotionX = x;
  15.              float oldScrollX = getScrollX();
  16.              float scrollX = oldScrollX + deltaX;
  17.              final float leftBound = getLeftBound();
  18.              final float rightBound = getRightBound();
  19.              if (scrollX < leftBound) {
  20.                   scrollX = leftBound;
  21.             } else if (scrollX > rightBound) {
  22.                   scrollX = rightBound;
  23.             }
  24.              // Don't lose the rounded component
  25.              mLastMotionX += scrollX - ( int) scrollX;
  26.             scrollTo(( int) scrollX, getScrollY());
  27.             pageScrolled(( int) scrollX);
  28.       }
  29.       break;
複製程式碼

挑重點介紹了
首先是determineDrag方法,即確定當前動作是否算是一個我們需要的拖動事件,是否要消費處理之~
方法如下
  1. private void determineDrag(MotionEvent ev) {
  2.       final int activePointerId = mActivePointerId;
  3.       final int pointerIndex = getPointerIndex(ev, activePointerId);
  4.       if (activePointerId == INVALID_POINTER || pointerIndex == INVALID_POINTER)
  5.              return;
  6.       final float x = MotionEventCompat. getX(ev, pointerIndex);
  7.       final float dx = x - mLastMotionX;
  8.       final float xDiff = Math.abs(dx);
  9.       final float y = MotionEventCompat. getY(ev, pointerIndex);
  10.       final float dy = y - mLastMotionY;
  11.       final float yDiff = Math.abs(dy);
  12.       if (xDiff > (isMenuOpen()? mTouchSlop/2: mTouchSlop) && xDiff > yDiff && thisSlideAllowed(dx)) {         
  13.             startDrag();
  14.              mLastMotionX = x;
  15.              mLastMotionY = y;
  16.             setScrollingCacheEnabled( true);
  17.              // TODO add back in touch slop check
  18.       } else if (xDiff > mTouchSlop) {
  19.              mIsUnableToDrag = true;
  20.       }
  21. }
複製程式碼

最主要是地方在於if 對diff的判斷語句
其中x/yDiff是x和y軸的移動距離長度,
mTouchSlop是系統對於拖動的最低長度判斷,即至少移動多少多少距離,才算是一個拖動的動作~

所以判斷的條件就是:
當選單沒開啟時,xDiff大於mTouchSlop時才視為一個我們所需要的拖動,開啟時則只要一半即可,
並且要同時滿足xDiff>yDiff,即拖動是一個橫向大於45度的
thisSlideAllowed可以理解為是判斷是否是在選單開啟時touch到了選單部分的位置

總結起來就是
如果拖動距離達到最小閥值且大於橫向45度角度,且是touch到主體部分,則為我們需要的觸控事件

然後記錄x,y的賦值給mLastMotionX/Y


當determineDrag判斷這是一個我們需要的拖動事件了以後,就開始讓aboveView隨著動作滾動了
首先是最後一行pageScrolled((int) scrollX);設定監聽資料的,先無視
最重要是scrollTo方法,在系統的scrollTo基礎上又加了點其他處理,如下
  1. @Override
  2. public void scrollTo(int x, int y) {
  3.       super.scrollTo(x, y);
  4.       mScrollX = x;
  5.       mViewBehind.scrollBehindTo( mContent, x, y);     
  6.       ((SlidingMenu)getParent()).manageLayers(getPercentOpen());
  7. }
複製程式碼
下面程式碼中manageLayers是提高效能用的,不深入研究了
super.scrollTo就是讓aboveView隨著手勢拖動滾動,這個比較簡單

此外,
使用SlidingMenu的時候可以注意到,滾動上面主體內容展示後面選單時,選單也有一個滾動展開效果
這個mViewBehind.scrollBehindTo(mContent, x, y);就是處理這個的
這裡先知道作用,分析完above部分後再詳細分析behind部分,會介紹這個作用


然後是抬起方法,對於SlidingMenu,如果使用過肯定會有一定了解,以下是一些效果需求
如果選單開啟到一定位置,則抬起時選單會完全開啟
如果選單開啟部分太小,則抬起時選單會收回去
還要有甩的處理,隨著手勢甩開選單,甩關閉選單~

下面是程式碼部分
  1. case MotionEvent.ACTION_UP :
  2.       if (mIsBeingDragged) {
  3.              final VelocityTracker velocityTracker = mVelocityTracker;
  4.             velocityTracker.computeCurrentVelocity(1000, mMaximumVelocity);
  5.              int initialVelocity = ( int) VelocityTrackerCompat.getXVelocity(
  6.                         velocityTracker, mActivePointerId);
  7.              final int scrollX = getScrollX();
  8.              final float pageOffset = ( float) (scrollX - getDestScrollX(mCurItem)) / getBehindWidth();
  9.              final int activePointerIndex = getPointerIndex(ev, mActivePointerId);
  10.              if ( mActivePointerId != INVALID_POINTER) {
  11.                    final float x = MotionEventCompat. getX(ev, activePointerIndex);
  12.                    final int totalDelta = ( int) (x - mInitialMotionX);
  13.                    int nextPage = determineTargetPage(pageOffset, initialVelocity, totalDelta);
  14.                   setCurrentItemInternal(nextPage, true, true, initialVelocity);
  15.             } else {   
  16.                   setCurrentItemInternal( mCurItem, true, true, initialVelocity);
  17.             }
  18.              mActivePointerId = INVALID_POINTER;
  19.             endDrag();
  20.       } else if (mQuickReturn && mViewBehind.menuTouchInQuickReturn( mContent, mCurItem, ev.getX() + mScrollX)) {
  21.              // close the menu
  22.             setCurrentItem(1);
  23.             endDrag();
  24.       }
  25.       break;
複製程式碼
同樣,簡單的不介紹了,參考之前的ScrollView原始碼分析教程
直接定位到SlidingMenu中特殊處理的部分

pageOffset為選單當前開啟比例,是一個0~1的值,比如開啟一半了比例就是0.5,
該值可以是正可以是負,用於表示方向,向右為正向左為負~
計算方法為
  1. final float pageOffset = ( float) (scrollX - getDestScrollX(mCurItem )) / getBehindWidth();
複製程式碼
mCurItem為當前頁索引,可能是0,1,2
0代表左邊的選單開啟狀態,1是沒有選單開啟,2是右邊選單開啟

計算出頁pageOffset後再根據速度和移動距離最終算出目標頁位置,方法如下
  1. private int determineTargetPage (float pageOffset, int velocity, int deltaX) {
  2.       int targetPage = mCurItem;
  3.       if (Math.abs(deltaX) > mFlingDistance && Math.abs(velocity) > mMinimumVelocity) {
  4.              if (velocity > 0 && deltaX > 0) {
  5.                   targetPage -= 1;
  6.             } else if (velocity < 0 && deltaX < 0){
  7.                   targetPage += 1;
  8.             }
  9.       } else {
  10.             targetPage = ( int) Math. round(mCurItem + pageOffset);
  11.       }
  12.       return targetPage;
  13. }
複製程式碼
速度和距離達到最小值後,則根據速度和距離的正負控制當前頁索引加或減1,
當達不到if條件時,則計算當前頁索引mCurItem與選單開啟狀態比例值pageOffset和的四捨五入值~


舉個else處理的例子
比如當前頁為1即未開啟選單狀態,此時左移了選單寬度十分之三的距離,即pageOffset=-0.3
那結果就是 1 - 0.3 = 0.7 取四捨五入就是1~ 當前頁還是1,即速度不夠時移動三分之一抬起選單還會收回去
還是剛才的條件,左移換成了十分之八的距離,即pageOffset=-0.8,
結果就是1 - 0.8 = 0.2 四捨五入就是0~ 即當前頁應該是0左選單了


以上簡單總結就是,速度距離足夠後,根據速度控制左右滑動來顯示選單或者內容
如果速度不夠,則移動超過選單一半距離時就切換選單開啟關閉狀態,不然就收回去


注意,determineTargetPage方法只是獲取目標頁位置,實際跳轉工作是下面的方法
  1. void setCurrentItemInternal(int item, boolean smoothScroll, boolean always, int velocity) {
  2.       if (!always && mCurItem == item) {
  3.             setScrollingCacheEnabled( false);
  4.              return;
  5.       }

  6.       item = mViewBehind.getMenuPage(item);

  7.       final boolean dispatchSelected = mCurItem != item;
  8.       mCurItem = item;
  9.       final int destX = getDestScrollX(mCurItem );
  10.       if (dispatchSelected && mOnPageChangeListener != null) {
  11.              mOnPageChangeListener.onPageSelected(item);
  12.       }
  13.       if (dispatchSelected && mInternalPageChangeListener != null ) {
  14.              mInternalPageChangeListener.onPageSelected(item);
  15.       }
  16.       if (smoothScroll) {
  17.             smoothScrollTo(destX, 0, velocity);
  18.       } else {
  19.             completeScroll();
  20.             scrollTo(destX, 0);
  21.       }
  22. }
複製程式碼
監聽設定無視,核心方法是smoothScrollTo方法,如下
  1. /**
  2. * Like {@link View#scrollBy}, but scroll smoothly instead of immediately.
  3. *
  4. * @param x the number of pixels to scroll by on the X axis
  5. * @param y the number of pixels to scroll by on the Y axis
  6. * @param velocity the velocity associated with a fling, if applicable. (0 otherwise)
  7. */
  8. void smoothScrollTo(int x, int y, int velocity) {
  9.       if (getChildCount() == 0) {
  10.              // Nothing to do.
  11.             setScrollingCacheEnabled( false);
  12.              return;
  13.       }
  14.       int sx = getScrollX();
  15.       int sy = getScrollY();
  16.       int dx = x - sx;
  17.       int dy = y - sy;
  18.       if (dx == 0 && dy == 0) {
  19.             completeScroll();
  20.              if (isMenuOpen()) {
  21.                    if ( mOpenedListener != null)
  22.                          mOpenedListener.onOpened();
  23.             } else {
  24.                    if ( mClosedListener != null)
  25.                          mClosedListener.onClosed();
  26.             }
  27.              return;
  28.       }

  29.       setScrollingCacheEnabled( true);
  30.       mScrolling = true;

  31.       final int width = getBehindWidth();
  32.       final int halfWidth = width / 2;
  33.       final float distanceRatio = Math. min(1f, 1.0f * Math.abs(dx) / width);
  34.       final float distance = halfWidth + halfWidth *
  35.                   distanceInfluenceForSnapDuration(distanceRatio);

  36.       int duration = 0;
  37.       velocity = Math.abs(velocity);
  38.       if (velocity > 0) {
  39.             duration = 4 * Math. round(1000 * Math.abs (distance / velocity));
  40.       } else {
  41.              final float pageDelta = ( float) Math. abs(dx) / width;
  42.             duration = ( int) ((pageDelta + 1) * 100);
  43.             duration = MAX_SETTLE_DURATION;
  44.       }
  45.       duration = Math.min(duration, MAX_SETTLE_DURATION);

  46.       mScroller.startScroll(sx, sy, dx, dy, duration);
  47.       invalidate();
  48. }
複製程式碼
最後發現內部實現最終還是Scroller類的startScroll方法~讓它繼續完成開啟/關閉選單的剩下動畫
至於duration滑動持續時間的計算演算法就不研究了~Scroller用法也不介紹了,之前ScrollView教程裡有

------------------------------------------------------------------------------------------------------

以上部分其實看懂ScrollView原理以後看完全沒壓力理解,但還有一個沒介紹的知識點,touch事件分發問題~

問題場景:
在選單開啟的時候要進行判斷,
如果點選在主體部分,則自動收回選單,即讓主體部分消費這個touch事件,
如果此時點選在選單部分,那above中應該不消費此事件,要將其傳給behind部分,
然後讓behind中的listview或者button等自定義設定的控制元件去獲取處理touch事件~


事件分發可以參考
http://www.eoeandroid.com/thread-277371-1-1.html

此外還有選單狀態開啟時點主體部分則直接關閉選單,或者back鍵直接關閉選單等,原理上面都介紹了,
這些邏輯方面的處理還有其他一些優化部分就不介紹了~

------------------------------------------------------------------------------------------------------

選單部分CustomViewBehind中內容不多,主要有兩大塊內容

1.滾動
研究主體內容部分的時候已經提到過,當前端內容部分拖動顯示/關閉選單時,選單也有一個隨之滾動的效果~

2.效果繪製
包括陰影,淡入淡出效果等


繪製部分比較複雜需要以後有時間專門介紹,先介紹滾動相關的部分即1
相關方法如下,該方法在CustomViewAbove的scrollTo中呼叫
  1. public void scrollBehindTo(View content, int x, int y) {
  2.       int vis = View. VISIBLE;
  3.       if (mMode == SlidingMenu. LEFT) {
  4.              if (x >= content.getLeft()) vis = View. INVISIBLE;
  5.             scrollTo(( int)((x + getBehindWidth())* mScrollScale), y);
  6.       } else if (mMode == SlidingMenu. RIGHT) {
  7.              if (x <= content.getLeft()) vis = View. INVISIBLE;
  8.             scrollTo(( int)(getBehindWidth() - getWidth() +
  9.                         (x-getBehindWidth())* mScrollScale), y);
  10.       } else if (mMode == SlidingMenu. LEFT_RIGHT) {
  11.              mContent.setVisibility(x >= content.getLeft() ? View.INVISIBLE : View.VISIBLE );
  12.              mSecondaryContent.setVisibility(x <= content.getLeft() ? View.INVISIBLE : View.VISIBLE );
  13.             vis = x == 0 ? View. INVISIBLE : View. VISIBLE;
  14.              if (x <= content.getLeft()) {
  15.                   scrollTo(( int)((x + getBehindWidth())* mScrollScale), y);                     
  16.             } else {
  17.                   scrollTo(( int)(getBehindWidth() - getWidth() +
  18.                               (x-getBehindWidth())* mScrollScale), y);                        
  19.             }
  20.       }
  21.       if (vis == View. INVISIBLE)
  22.             Log. v(TAG, "behind INVISIBLE" );
  23.       setVisibility(vis);
  24. }
複製程式碼

只分析LEFT的情況(請他情況同理)
比如選單的width即getBehindWidth的寬度為300
那從選單關閉狀態一直滾到選單完全開啟狀態,above主體內容x軸上的scroll變化就是0 ~ -300
此時如果mScrollScale即滾動比例為0.3
那按照上面scrollBehindTo中的演算法, behind選單部分x軸上的scroll變化就是
(0+300)*0.3 ~ (-300+300)*0.3即100~0

如果比例換成0.6,那behind的x變化就是180~0


極端情況下
1.mScrollScale=0, behind的x變化為 0~0, 此時會發現behind選單部分在開啟關閉的過程中無任何滾動
2.mScrollScale=1,behind的換標為300~0, 此時的效果就是主體和選單緊挨著以同一個速度滾動


注意,這裡的x為滾動的偏移量,即scrollTo使用的引數,
比如scrollView中,scrollTo(0, 100)會往下滾動到y偏移量100的位置,
此時scrollView裡面的內容視覺上看其實是向上移了~
這裡同理
雖然朝右拖動的時候view看上去是朝右運動,但x軸上滾動偏移量是減少的

------------------------------------------------------------------------------------------------------

最後是demo,自定義底部滑出選單控制元件
根據SlidingMenu修改的,刪除了SlidingMenu中大量程式碼,只保留了最核心的部分方便理解~

缺陷
限定死了behind選單部分的高度和底部運動的比例值等在SlidingMenu中是可以設定的資訊



老樣子,回覆可以免積分
連結:http://pan.baidu.com/s/1bnCPQiF 密碼:pa0v




相關文章