MaterialDesign--(3)DrawerLayout+NavigationView 及源碼分析

先看效果:


drawLayout+NavigationView.gif

導航抽屜:

  • 導航抽屜一般顯示在屏幕最左側,默認情況下是隱藏的,當用戶手紙從邊緣向另一個滑動的時候,會出現一個隱藏的面板,當點擊面板外部或者原先方向滑動的時候,抽屜就消失。
  • 很多 app 都有類似的需求,最經典的是 qq個人信息欄的滑動,后來 github 上開源出了民間的控件 SlideMenu。后來被Google 收錄進 support-v4包里面,命名為 DrawerLayout。
  • NavigationView:是谷歌在側滑的 MaterialDesign 的一種規范,所以提出了一個新的控件,用來規范側滑的基本樣式。
  • 使用 Eclipse 的同學在使用 NavigationView 的時候記得同時引用 RecyclerView 哦,不然會報錯,NavigationView的內部使用了 RecyclerView。

用法:

在創建項目的時候直接選擇 Navigation Drawer Activity 即可,之后我們便可以看到如下布局文件(直接手寫以下文件也行)

 <?xml version="1.0" encoding="utf-8"?>
 <android.support.v4.widget.DrawerLayout
 xmlns:android="http://schemas.android.com/apk/res/android"
 xmlns:app="http://schemas.android.com/apk/res-auto"
 xmlns:tools="http://schemas.android.com/tools"
 android:id="@+id/drawer_layout"
 android:layout_width="match_parent"
 android:layout_height="match_parent"
 android:fitsSystemWindows="true"
 tools:openDrawer="start">

 <include
     layout="@layout/app_bar_main"
     android:layout_width="match_parent"
     android:layout_height="match_parent" />

 <android.support.design.widget.NavigationView
     android:id="@+id/nav_view"
     android:layout_width="wrap_content"
     android:layout_height="match_parent"
     android:layout_gravity="start"
     android:fitsSystemWindows="true"
     app:headerLayout="@layout/nav_header_main"
     app:menu="@menu/activity_main_drawer" />
 </android.support.v4.widget.DrawerLayout>

效果如下:


Navigation.png

最外層是一個 DrawerLayout,包含了兩個子 View。第一個 include 引用的 layout 為主頁內容區域。第二個NavigationView 為側滑區域View。

layout_gravity可以設置為 start 或者 end,分別對應的是從左邊滑出和從右邊滑出。

NavigationView 有兩個 app 屬性,分別是 app:headerLayout和 app:menu。前者是用于控制頭布局,查看資源文件 nav-header-main 可以看到:

NavigationView_header.png

查看 menu文件 activity-main-drawer我們可以看到如下代碼

 <menu xmlns:android="http://schemas.android.com/apk/res/android">

 <group android:checkableBehavior="single">
     <item
         android:id="@+id/nav_camera"
         android:icon="@drawable/ic_menu_camera"
         android:title="Import" />
     <item
         android:id="@+id/nav_gallery"
         android:icon="@drawable/ic_menu_gallery"
         android:title="Gallery" />
     <item
         android:id="@+id/nav_slideshow"
         android:icon="@drawable/ic_menu_slideshow"
         android:title="Slideshow" />
     <item
         android:id="@+id/nav_manage"
         android:icon="@drawable/ic_menu_manage"
         android:title="Tools" />
 </group>

 <item android:title="Communicate">
     <menu>
         <item
             android:id="@+id/nav_share"
             android:icon="@drawable/ic_menu_share"
             android:title="Share" />
         <item
             android:id="@+id/nav_send"
             android:icon="@drawable/ic_menu_send"
             android:title="Send" />
     </menu>
 </item>

 </menu>

對應了側滑欄目的菜單。

Activity 里面的代碼

Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
//使用 toolbar 替換 actionbar,不然 onCreateOptionsMenu無法生效到 toolbar 上
setSupportActionBar(toolbar);
  
  //給 toolbar 設置導航剪頭,并綁定 DrawLayout,在滑動的時候執行動畫
DrawerLayout drawer = (DrawerLayout) findViewById(R.id.drawer_layout);
ActionBarDrawerToggle toggle = new ActionBarDrawerToggle(
        this, drawer, toolbar, R.string.navigation_drawer_open, R.string.navigation_drawer_close);
drawer.setDrawerListener(toggle);
toggle.syncState();
    
//給NavigationView的菜單設置點擊事件,
//點擊事件處理之后調用drawer.closeDrawer(GravityCompat.START)關閉菜單
//onBackPressed()方法里面可以判斷 drawer.isDrawerOpen()來判斷執行動作
NavigationView navigationView = (NavigationView) findViewById(R.id.nav_view);
navigationView.setNavigationItemSelectedListener(this);

Tips:

  • 如果在 xml 里面寫了toolbar,又在 activity 里面setSupportActionBar(toolbar);要記得給主題設置 android:theme="@style/AppTheme.NoActionBar"
  • 如果想要 NavigationView 在 Toolbar 下方,可以在 DrawerLayout外層再包裹一個 LinearLayout,并且添加 Toolabr 節點即可
  • Toolbar上不顯示Home旋轉開關按鈕,上文有注釋,刪除ActionBarDrawerToggle相關代碼即可。
  • 不使用NavigationView,使用DrawerLayout+其他布局。很簡單,把上文中布局文件里面的 DrawerLayout 節點里面的 NavigationView替換成任意 View 或者 ViewGroup。
  • fitsSystemWindows:控制控件是否填充狀態欄的位置,false 為不填充。

源碼分析

----NavigationView----
這是從 design 包的 Value 文件里面拷貝出來的自定義屬性,屬性命名很規范,我就不一個一個解釋了。

<declare-styleable name="NavigationView">
 <attr name="android:background"/>
 <attr name="android:fitsSystemWindows"/>
 <attr name="android:maxWidth"/>
 <attr name="elevation"/>
 <attr format="reference" name="menu"/>
 <attr format="color" name="itemIconTint"/>
 <attr format="color" name="itemTextColor"/>
 <attr format="reference" name="itemBackground"/>
 <attr format="reference" name="itemTextAppearance"/>
 <attr format="reference" name="headerLayout"/>
</declare-styleable>

NavigationView 繼承自 FrameLayout,然后透過配置headerLayout和menu來為其設置頭布局和菜單列表,接下來,我們就來看看源碼實現。

首先看構造方法

 public NavigationView(Context context, AttributeSet attrs, int defStyleAttr) {

 // Custom attributes
    TintTypedArray a = TintTypedArray.obtainStyledAttributes(context, attrs,
            R.styleable.NavigationView, defStyleAttr,
            R.style.Widget_Design_NavigationView);

    ...//省略部分代碼
    
    if (a.hasValue(R.styleable.NavigationView_menu)) {
        inflateMenu(a.getResourceId(R.styleable.NavigationView_menu, 0));
    }

    if (a.hasValue(R.styleable.NavigationView_headerLayout)) {
        inflateHeaderView(a.getResourceId(R.styleable.NavigationView_headerLayout, 0));
    }

    a.recycle();
}

我們可以看到,如果attrs屬性包含 headerLayout 屬性和 menu 屬性,則會去加載。

inflateMenu(int resId)

 public void inflateMenu(int resId) {
    mPresenter.setUpdateSuspended(true);
    getMenuInflater().inflate(resId, mMenu);
    mPresenter.setUpdateSuspended(false);
    mPresenter.updateMenuView(false);
}  

這個方法很簡單,mPresenter.setUpdateSuspended()為防錯處理,暫時不用太糾結;

然后就是getMenuInflater().inflate(resId, mMenu)去解析menu 的 xml 屬性;

mPresenter.updateMenuView(false);這句話調用了更新 MenuView,追進去看代碼

 @Override
public void updateMenuView(boolean cleared) {
    if (mAdapter != null) {
        mAdapter.update();
    }
}  

如果mAdapter不為 null,那么就更新mAdapter;根據代碼經驗,這個mAdapter一般是給 ListView 或者 RecyclerView 用的,查看了一下mAdapter這個類,果然繼承自 RecyclerView.Adapter.

然后我們再看mAdapter 的update()方法

 public void update() {
    prepareMenuItems();
    notifyDataSetChanged();
 }

這里調用了兩個方法,第二個方法我就不說了,看不懂的出門左拐。繼續追prepareMenuItems()

 /**
 * Flattens the visible menu items of {@link #mMenu} into {@link #mItems},
 * while inserting separators between items when necessary.
 */
private void prepareMenuItems() {}

這個方法是 mAdapter 里面的一個私有方法,看方法說明,我們就能知道這個方法就是將mMenu里面的數據轉換成 mAdapter 需要的 NavigationMenuItem 數據,然后再走 Update 方法里面的notifyDataSetChanged()方法將數據刷新到界面上。

inflateHeaderView(@LayoutRes int res)

 public View inflateHeaderView(@LayoutRes int res) {
    return mPresenter.inflateHeaderView(res);
}

不多說了,直接追mPresenter.inflateHeaderView(res);

 public View inflateHeaderView(@LayoutRes int res) {
    View view = mLayoutInflater.inflate(res, mHeaderLayout, false);
    addHeaderView(view);
    return view;
}

public void addHeaderView(@NonNull View view) {
    mHeaderLayout.addView(view);
    // The padding on top should be cleared.
    mMenuView.setPadding(0, 0, 0, mMenuView.getPaddingBottom());
}

直接調用LayoutInflater去inflate一個 LayoutRes 文件得到一個 view,然后添加進 mHeaderLayout里面,源碼里面方法注釋都懶得寫,我也不過多贅述了。

好,NavigationView 核心代碼分析完畢。

----DrawerLayout----

看 activity_main.xml的布局文件我們可以知道,DrawerLayout是 ContentView 和 NavigationView 的父節點,然后根據命名,我們可以大膽的猜測,DrawerLayout就是處理側滑效果的。汗。。。。。。其實就是一個處理側滑的 view

先看類注釋說明吧,看不懂直接看后面的翻譯~~

 /**
  * DrawerLayout acts as a top-level container for window content that allows for
  * interactive "drawer" views to be pulled out from one or both vertical edges of the window.
  *
  * <p>Drawer positioning and layout is controlled using the <code>android:layout_gravity</code>
  * attribute on child views corresponding to which side of the view you want the drawer
  * to emerge from: left or right (or start/end on platform versions that support layout direction.)
  * Note that you can only have one drawer view for each vertical edge of the window. If your
  * layout configures more than one drawer view per vertical edge of the window, an exception will
  * be thrown at runtime.
  * </p>
  *
  * <p>To use a DrawerLayout, position your primary content view as the first child with
  * width and height of <code>match_parent</code> and no <code>layout_gravity></code>.
  * Add drawers as child views after the main content view and set the <code>layout_gravity</code>
  * appropriately. Drawers commonly use <code>match_parent</code> for height with a fixed width.</p>
  *
  * <p>{@link DrawerListener} can be used to monitor the state and motion of drawer views.
  * Avoid performing expensive operations such as layout during animation as it can cause
  * stuttering; try to perform expensive operations during the {@link #STATE_IDLE} state.
  * {@link SimpleDrawerListener} offers default/no-op implementations of each callback method.</p>
  *
  * <p>As per the <a href="{@docRoot}design/patterns/navigation-drawer.html">Android Design
  * guide</a>, any drawers positioned to the left/start should
  * always contain content for navigating around the application, whereas any drawers
  * positioned to the right/end should always contain actions to take on the current content.
  * This preserves the same navigation left, actions right structure present in the Action Bar
  * and elsewhere.</p>
  *
  * <p>For more information about how to use DrawerLayout, read <a
  * href="{@docRoot}training/implementing-navigation/nav-drawer.html">Creating a Navigation
  * Drawer</a>.</p>
  */

類注釋說明很長,我用我三級的蹩腳英語結合翻譯工具給大家簡單翻譯一下

  • 可以作為一個從左右兩邊拉出抽屜效果的頂層容器
  • 抽屜的位置取決于 layout_gravity屬性。注意:每個垂直邊最多只能有一個抽屜,否則會在運行的時候拋出異常
  • 使用 DrawerLayout 的時候,主要的內容 view 必須放在第一個位置,寬和高為 match_parent 并且不能有 layout_gravity 屬性;抽屜 view 設置在主內容 view 之后并且必須設置 layout_gravity,抽屜 view 的高度為 match_parent,寬度設為固定值。
  • DrawerListener可以用來監聽抽屜的狀態和滑動,避免在滑動過程中執行高消耗的行為,STATE_IDLE狀態下可以進行性能消耗比較大的動作。

接下來,我們來看 DrawerLayout 是怎么來控制抽屜滑動的。
在 DrawerLayout 的構造方法里面,我找到了一個熟悉的類--ViewDragHelper,熟悉 ViewDragHelper 這個類的童鞋看到這里可以不用往下看了,對,沒錯,DrawerLayout 的內部實現就是基于 ViewDragHelper。

mLeftCallback = new ViewDragCallback(Gravity.LEFT);
mLeftDragger = ViewDragHelper.create(this, TOUCH_SLOP_SENSITIVITY, mLeftCallback);
mLeftDragger.setEdgeTrackingEnabled(ViewDragHelper.EDGE_LEFT);
mLeftDragger.setMinVelocity(minVel);
mLeftCallback.setDragger(mLeftDragger);

這里是控制左邊抽屜拖動的關鍵代碼,與之相同的還有右邊抽屜的處理。
這里就是用了 ViewDragHelper 這個類來處理 contentView 的觸摸滑動來拖動抽屜。
這里,我就簡單講一下ViewDragHelper這個類吧

 /**
  * ViewDragHelper is a utility class for writing custom ViewGroups. It offers a number
  * of useful operations and state tracking for allowing a user to drag and reposition
  * views within their parent ViewGroup.
  */

ViewDragHelper是一個編寫自定義 ViewGroup 的實用類,它提供一個用于追蹤view拖動事件的參數。
翻譯得有點拗口,簡單點就是在 ViewGroup 里面監聽一個 View 的拖動。

ViewGroup 的使用很簡單,就三步
1.調用靜態方法create(ViewGroup forParent, float sensitivity, Callback cb)創建實力,第一個參數傳 ViewGroup 本身,第二個參數是拖動的敏感度,一般用1F 即可,第三個參數后文單獨說。
2.在 onTouch 和 onInterceptTouchEvent 方法里面做如下處理

@Override
public boolean onInterceptTouchEvent(MotionEvent event)
{
    return mDragger.shouldInterceptTouchEvent(event);
}

@Override
public boolean onTouchEvent(MotionEvent event)
{
    mDragger.processTouchEvent(event);
    return true;
}

3.實現 ViewDragHelper.Callback類,

 /**
 * Called when the drag state changes. See the <code>STATE_*</code> constants
 * for more information.
 * 當ViewDragHelper狀態發生變化時回調(IDLE,DRAGGING,SETTING[自動滾動時])
 * @param state The new drag state
 *
 * @see #STATE_IDLE
 * @see #STATE_DRAGGING
 * @see #STATE_SETTLING
 */
public void onViewDragStateChanged(int state) {}

/**
 * Called when the captured view's position changes as the result of a drag or settle.
 * 當captureview的位置發生改變時回調
 * @param changedView View whose position changed
 * @param left New X coordinate of the left edge of the view
 * @param top New Y coordinate of the top edge of the view
 * @param dx Change in X position from the last call
 * @param dy Change in Y position from the last call
 */
public void onViewPositionChanged(View changedView, int left, int top, int dx, int dy) {}

/**
 * Called when a child view is captured for dragging or settling. The ID of the pointer
 * currently dragging the captured view is supplied. If activePointerId is
 * identified as {@link #INVALID_POINTER} the capture is programmatic instead of
 * pointer-initiated.
 * 當captureview被捕獲時回調
 * @param capturedChild Child view that was captured
 * @param activePointerId Pointer id tracking the child capture
 */
public void onViewCaptured(View capturedChild, int activePointerId) {}

/**
 * Called when the child view is no longer being actively dragged.
 * The fling velocity is also supplied, if relevant. The velocity values may
 * be clamped to system minimums or maximums.
 *
 * <p>Calling code may decide to fling or otherwise release the view to let it
 * settle into place. It should do so using {@link #settleCapturedViewAt(int, int)}
 * or {@link #flingCapturedView(int, int, int, int)}. If the Callback invokes
 * one of these methods, the ViewDragHelper will enter {@link #STATE_SETTLING}
 * and the view capture will not fully end until it comes to a complete stop.
 * If neither of these methods is invoked before <code>onViewReleased</code> returns,
 * the view will stop in place and the ViewDragHelper will return to
 * {@link #STATE_IDLE}.</p>
 * 手指釋放的時候回調
 * @param releasedChild The captured child view now being released
 * @param xvel X velocity of the pointer as it left the screen in pixels per second.
 * @param yvel Y velocity of the pointer as it left the screen in pixels per second.
 */
public void onViewReleased(View releasedChild, float xvel, float yvel) {}

/**
 * Called when one of the subscribed edges in the parent view has been touched
 * by the user while no child view is currently captured.
 * 當觸摸到邊界時回調。
 * @param edgeFlags A combination of edge flags describing the edge(s) currently touched
 * @param pointerId ID of the pointer touching the described edge(s)
 * @see #EDGE_LEFT
 * @see #EDGE_TOP
 * @see #EDGE_RIGHT
 * @see #EDGE_BOTTOM
 */
public void onEdgeTouched(int edgeFlags, int pointerId) {}

/**
 * Called when the given edge may become locked. This can happen if an edge drag
 * was preliminarily rejected before beginning, but after {@link #onEdgeTouched(int, int)}
 * was called. This method should return true to lock this edge or false to leave it
 * unlocked. The default behavior is to leave edges unlocked.
 * true的時候會鎖住當前的邊界,false則unLock。
 * @param edgeFlags A combination of edge flags describing the edge(s) locked
 * @return true to lock the edge, false to leave it unlocked
 */
public boolean onEdgeLock(int edgeFlags) {
    return false;
}

/**
 * Called when the user has started a deliberate drag away from one
 * of the subscribed edges in the parent view while no child view is currently captured.
 * 在邊界拖動時回調
 * @param edgeFlags A combination of edge flags describing the edge(s) dragged
 * @param pointerId ID of the pointer touching the described edge(s)
 * @see #EDGE_LEFT
 * @see #EDGE_TOP
 * @see #EDGE_RIGHT
 * @see #EDGE_BOTTOM
 */
public void onEdgeDragStarted(int edgeFlags, int pointerId) {}

/**
 * Called to determine the Z-order of child views.
 * 這個沒看懂,沒用過
 * @param index the ordered position to query for
 * @return index of the view that should be ordered at position <code>index</code>
 */
public int getOrderedChildIndex(int index) {
    return index;
}

/**
 * Return the magnitude of a draggable child view's horizontal range of motion in pixels.
 * This method should return 0 for views that cannot move horizontally.
 * 獲取目標 view 水平方向拖動的距離
 * @param child Child view to check
 * @return range of horizontal motion in pixels
 */
public int getViewHorizontalDragRange(View child) {
    return 0;
}

/**
 * Return the magnitude of a draggable child view's vertical range of motion in pixels.
 * This method should return 0 for views that cannot move vertically.
 * 獲取目標 view 垂直方向拖動的距離
 * @param child Child view to check
 * @return range of vertical motion in pixels
 */
public int getViewVerticalDragRange(View child) {
    return 0;
}

/**
 * Called when the user's input indicates that they want to capture the given child view
 * with the pointer indicated by pointerId. The callback should return true if the user
 * is permitted to drag the given view with the indicated pointer.
 *
 * <p>ViewDragHelper may call this method multiple times for the same view even if
 * the view is already captured; this indicates that a new pointer is trying to take
 * control of the view.</p>
 *
 * <p>If this method returns true, a call to {@link #onViewCaptured(android.view.View, int)}
 * will follow if the capture is successful.</p>
 * 如果返回 true,則捕獲該 view 的拖動事件。通常寫法 return child == targeView;
 * @param child Child the user is attempting to capture
 * @param pointerId ID of the pointer attempting the capture
 * @return true if capture should be allowed, false otherwise
 */
public abstract boolean tryCaptureView(View child, int pointerId);

/**
 * Restrict the motion of the dragged child view along the horizontal axis.
 * The default implementation does not allow horizontal motion; the extending
 * class must override this method and provide the desired clamping.
 * 控制 child移動的水平邊界
 * @param child Child view being dragged
 * @param left Attempted motion along the X axis
 * @param dx Proposed change in position for left
 * @return The new clamped position for left
 */
public int clampViewPositionHorizontal(View child, int left, int dx) {
    return 0;
}

/**
 * Restrict the motion of the dragged child view along the vertical axis.
 * The default implementation does not allow vertical motion; the extending
 * class must override this method and provide the desired clamping.
 * 控制 child 移動的垂直邊界
 * @param child Child view being dragged
 * @param top Attempted motion along the Y axis
 * @param dy Proposed change in position for top
 * @return The new clamped position for top
 */
public int clampViewPositionVertical(View child, int top, int dy) {
    return 0;
}

就到這里吧,ViewDragHelper 的用法其實很簡單,DrawerLayout 里面也是這三個步驟,追過加了一些邏輯處理而已,詳細用法可以參看鴻洋大神的 blog 《Android ViewDragHelper完全解析 自定義ViewGroup神器》,看完之后再回過頭來自己去捋一捋 DrawerLayout 里面的邏輯

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容