17.構建導航Drawer
問題
應用程式需要頂層導航選單,而為了符合最新的Google設計指南,要實現一個這樣的選單,該選單以動畫方式從螢幕的一側滑進和滑出。
解決方案
(API Level 7)
整合DrawableLayout小部件以管理從螢幕左側或右側滑入的選單檢視,Android支援庫中提供了該小部件。DrawerLayout是一個容器小部件,它使用指定的Gravity值LEFT或RIGHT(如果支援RTL佈局,還可以是START/END)管理其層次結構中每個最初的子檢視,將其作為動畫形式的內容Drawer。預設情況下,每個檢視都是隱藏的,但當呼叫openDrawer()方法或手指從適當的側面滑入螢幕時,這些檢視會從相應的側面以動畫形式進入螢幕。為表明Drawer的存在,如果在適當的螢幕側面按下手指,DrawerLayout也會檢視相應的檢視。
DrawerLayout支援多個Drawer,每個Drawer對應一種Gravity設定,它們可以放置在佈局層次結構中的任意位置。唯一的軟性規則是,它們應該在佈局中的主內容檢視之後新增(即放置在佈局XML中的檢視元素之後)。否則,檢視的Z軸順序將阻止Drawer顯示。
還可以通過ActionBarDrawerToggle元素實現與Action Bar的整合。ActionBarDrawerToggle小部件監控Action Bar中Home按鈕區域的點選動作並切換“主”Drawer(帶有Gravity.LEFT或Gravity.START設定的Drawer)的可見性。
要點:
DrawerLayout僅在Android庫中提供;它不是任意平臺級別中原生SDK的一部分。然而,目標平臺為API Level 4或以後版本的應用程式可以通過包含支援庫來使用該小部件。有關在專案中包括支援庫的更多資訊,請參考 ofollow,noindex">https://developer.android.com/tools/support-library/index.html 。
實現機制
雖然不一定要與DrawerLayout一起使用ActionBar,但這是最常見的用例。下面的示例顯示瞭如何使用DrawerLayout建立導航Drawer以及如何執行Action Bar整合。
下面的示例建立帶有兩個導航Drawer的應用程式:左側的主Drawer帶有可供選擇的選項列表,右側的輔助Drawer帶有一些額外的互動式內容。從主Drawer的列表中選擇一個條目會修改主要內容檢視的背景顏色。
在以下清單程式碼中,我們有一個包含DrawerLayout的佈局。請注意,因為此小部件不是核心元素,所以必須在XML中使用其完全限定的類名。
res/layout/activity_main.xml
<?xml version="1.0" encoding="utf-8"?> <android.support.v4.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/container_drawer" android:layout_width="match_parent" android:layout_height="match_parent" > <!-- 主內容窗格 --> <FrameLayout android:id="@+id/container_root" android:layout_width="match_parent" android:layout_height="match_parent"> <!-- 在此放置主內容--> </FrameLayout> <!-- 主Drawer內容 --> <!-- 可以是任意View或ViewGroup內容 標準Drawer寬度是240dp 必須設定Gravity值 需要在內容之上顯示純色背景 --> <ListView android:id="@+id/drawer_main" android:layout_width="240dp" android:layout_height="match_parent" android:layout_gravity="start" android:background="#FFF" /> <!-- 可以建立額外的Drawer 例如這個Drawer將隨著從螢幕右側輕掃進入而顯示 --> <LinearLayout android:id="@+id/drawer_right" android:layout_width="240dp" android:layout_height="match_parent" android:layout_gravity="end" android:orientation="vertical" android:background="#CCC"> <Button android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Click Here!" /> <TextView android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:text="Tap Anywhere Else, Drawer will Hide" /> </LinearLayout> </android.support.v4.widget.DrawerLayout>
我們已包括兩個檢視,它們在應用程式中充當Drawer,一個螢幕在左側,另一個螢幕在右側;通過設定android:layout_gravity屬性來控制它們的對齊。DrawerLayout執行剩餘的工作,它通過檢查Gravity值來對映每個檢視,因此我們不需要以其他方式連結它們。在接觸Activity之前,需要知道我們的專案還包含一個資源;我們建立了一個選項選單來在Action Bar中顯示一些動作(參見以下程式碼清單)。
res/menu/main.xml
<menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <item android:id="@+id/action_delete" android:title="@string/action_delete" app:showAsAction="ifRoom" android:icon="@android:drawable/ic_menu_delete"/> <item android:id="@+id/action_settings" android:title="@string/action_settings" android:orderInCategory="100" app:showAsAction="never"/> </menu>
最終,我們就有了以下程式碼清單的Activity。除了Drawerlayout之外,該例還包含一個ActionBarDrawerToggle,用於提供與ActionBar的Home按鈕的整合。
整合DrawerLayout的Activity
public class NativeActivity extends ActionBarActivity implements AdapterView.OnItemClickListener { private static final String[] ITEMS = {"White", "Red", "Green", "Blue"}; private static final int[] COLORS = {Color.WHITE, 0xffe51c23, 0xff259b24, 0xff5677fc}; private DrawerLayout mDrawerContainer; /* 佈局中的根內容窗格*/ private View mMainContent; /* 主(左側)滑動Drawer*/ private ListView mDrawerContent; /*ActionBar的開關物件 */ private ActionBarDrawerToggle mDrawerToggle; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); mDrawerContainer = (DrawerLayout) findViewById(R.id.container_drawer); mDrawerContent = (ListView) findViewById(R.id.drawer_main); mMainContent = findViewById(R.id.container_root); //開關指示器也必須是Drawer偵聽器, // 因此擴充套件該偵聽器以偵聽事件自身 mDrawerToggle= new ActionBarDrawerToggle( this,//Host Activity mDrawerContainer,//Container to use R.string.drawer_open, //Content description strings R.string.drawer_close ) { @Override public void onDrawerOpened(View drawerView) { super.onDrawerOpened(drawerView); //更新選項選單 supportInvalidateOptionsMenu(); } @Override public void onDrawerStateChanged(int newState) { super.onDrawerStateChanged(newState); //更新選項選單 supportInvalidateOptionsMenu(); } @Override public void onDrawerClosed(View drawerView) { super.onDrawerClosed(drawerView); //更新選項選單 supportInvalidateOptionsMenu(); } }; ListAdapter adapter = new ArrayAdapter<String>(this, android.R.layout.simple_list_item_1, ITEMS); mDrawerContent.setAdapter(adapter); mDrawerContent.setOnItemClickListener(this); //設定開關指示器Drawer的事件偵聽器 mDrawerContainer.setDrawerListener(mDrawerToggle); //在ActionBar中啟動Home按鈕動作 getSupportActionBar().setDisplayHomeAsUpEnabled(true); getSupportActionBar().setHomeButtonEnabled(true); } @Override protected void onPostCreate(Bundle savedInstanceState) { super.onPostCreate(savedInstanceState); //在框架還原任意例項狀態之後同步Drawer狀態 mDrawerToggle.syncState(); } @Override public void onConfigurationChanged(Configuration newConfig) { super.onConfigurationChanged(newConfig); //在更改任意配置時更新狀態 mDrawerToggle.onConfigurationChanged(newConfig); } @Override public boolean onCreateOptionsMenu(Menu menu) { // 建立Action Bar動作 getMenuInflater().inflate(R.menu.main, menu); return true; } @Override public boolean onPrepareOptionsMenu(Menu menu) { //基於主Drawer的狀態顯示動作選項 boolean isOpen = mDrawerContainer.isDrawerVisible(mDrawerContent); menu.findItem(R.id.action_delete).setVisible(!isOpen); menu.findItem(R.id.action_settings).setVisible(!isOpen); return super.onPrepareOptionsMenu(menu); } @Override public boolean onOptionsItemSelected(MenuItem item) { //首先讓Drawer在事件處有一個缺口 //從而處理Home按鈕事件 if (mDrawerToggle.onOptionsItemSelected(item)) { //如果這是一個Drawer開關,我們需要更新選項選單 // 但必須等到下一次迴圈遍歷Drawer狀態改變時再更新 mDrawerContainer.post(new Runnable() { @Override public void run() { //更新選項選單 supportInvalidateOptionsMenu(); } }); return true; } //...像往常一樣在此處理其他選項選擇... switch (item.getItemId()) { case R.id.action_delete: //刪除動作 return true; case R.id.action_settings: //設定動作 return true; default: return super.onOptionsItemSelected(item); } } //根據主Drawer列表中的條目處理點選事件 @Override public void onItemClick(AdapterView<?> parent, View view, int position, long id) { //更新主內容的背景色 mMainContent.setBackgroundColor(COLORS[position]); //手動關閉Drawer mDrawerContainer.closeDrawer(mDrawerContent); } }
初始化Activity時,我們建立ActionBarDrawerToggle例項並將其設定為DrawerLayout的DrawerListener。這是必需的步驟,從而ActionBarDrawerToggle才可以偵聽事件,但這也意味著,除非我們擴充套件ActionBarDrawerToggle以重寫偵聽器的方法(在此已完成該操作),否則無法在應用程式中偵聽這些事件。ActionBarDrawerToggle也連結駐留它的Activity以及它應該控制的DrawerLayout。
整合ActionBarDrawerToggle需要相當數量的樣板程式碼,因為它不會直接關聯到Activity的任何生命週期方法。需要從適當的Activity回撥中呼叫syncState()、onConfigurationChanged()和onOptionsItemSelected()方法,從而讓開關小部件可以接收輸入以及連同Activity例項一起維護狀態。為了觸發Action Bar中的Home按鈕事件,還必須通過呼叫setHomeButtonEnabled()來啟用Home按鈕。最後,新增setDisplayHomeAsUpEnabled()以使圖示(預設為箭頭)顯示在Home徽標的旁邊;Drawer開關使用自己的版本定製該圖示。
DrawerLayout被設計為當主內容檢視接收觸控事件(即使用者在Drawer外部觸控)時開啟和關閉Drawer。佈局內的觸控事件(例如觸控主列表中的條目或輔助Drawer中的按鈕)要求我們在必要時手動關閉Drawer。在註冊到列表的OnItemClickListener內部,我們在更改內容檢視的背景顏色之後呼叫closeDrawer()以執行Drawer的關閉操作。值得注意的是,即使使用者點選Drawer內不可互動的檢視(如TextView),這些觸控事件也會按順序傳遞給下一個子檢視。如果這個子檢視是主內容檢視(最常見的情況),則Drawer會像使用者觸控其外部一樣關閉。
注意openDrawer()和closeDrawer()這樣的方法如何獲取檢視引數。因為DrawerLayout可以管理多個Drawer,我們必須告訴它操作哪個Drawer小部件。如果應用程式沒有指向Drawer檢視自身的引用,也可以使用與Drawer關聯的Gravity引數觸發這些方法。
回顧一下,我們擴充套件了ActionBarDrawToggle以重寫Drawer的事件偵聽器方法。在每個方法的內部呼叫invalidateOptionsMenu(),該方法僅僅告訴Activity更新選單並再次呼叫其設定方法。同樣回顧一下,我們使用XML選單建立了一些顯示在ActionBar內部的動作,而在onPrepareOptionsMenu()內部,我們根據Drawer的可見性狀態控制是否顯示這些動作。這樣,這些動作只有在主Drawer未顯示時才會出現。在每個事件回撥中使選單無效的作用是可以基於Drawer中的改動更新選單可見性。
下圖顯示瞭如何點選ActionBar中的Home按鈕來展開主Drawer,從而顯示選項列表;還要注意的是,當Drawer開啟時,這些動作會消失。下圖說明了隱藏在邊緣的輔助Drawer從螢幕的一側滑入,然後完全開啟。

帶有主Drawer的Activity
完成實際工作的類
DrawerLayout中提供的拖動和邊緣滑入行為實際上是支援庫中提供的另一個類的工作:ViewDragHelper。如果需要基於使用者拖動執行任何自定義檢視操作,該類就會非常有幫助。
ViewDragHelper是觸控事件處理程式(類似於GestureDetector),因此它需要從檢視中提供事件。一般情況下,在檢視的onTouchEvent()中接收的每個事件必須直接交給ViewDragHelper中的processTouchEvent()進行處理。
@Override public boolean onTouchEvent(MotionEvent event) { mHelper.processTouchEvent(event); }
例項化ViewDragHelper時,必須傳遞ViewDragHelper.Callback的例項,將其作為輔助類傳遞給應用程式的所有事件的處理程式。其中最重要的方法是tryCaptureView(),當輔助類開始監控給定檢視上的拖動時就會呼叫該方法;該方法返回true會造成檢視被“捕獲”,這意味著其位置將跟隨手勢中隨後的觸控事件而移動。
如果使用一個或多個有效的邊緣標誌呼叫了setEdgeTrackingEnabled(),則ViewDragHelper也支援從檢視邊緣滑入。當邊緣事件發生時,會在Callback上觸發onEdgeTouched()和onEdgeDragStarted()方法。
最後一個提示是:單個ViewDragHelper被設計為一次僅捕獲和管理一個檢視。如果嘗試使用同一個ViewDragHelper例項同時滑動兩個檢視,就會出現問題。例如,DrawerLayout對它支援的每個Drawer使用一個ViewDragHelper,從而避免這種特殊的問題。
在Toolbar上繪製
Google設計指南中對此模式的改編要求Drawer在Action Bar的頂部滑動。當Action Bar作為視窗裝飾的一部分時,這一行為是無法實現的,但是如果將Action Bar替換成Toolbar,就可以輕鬆實現。作為參考,下圖顯示了開啟時不同的Drawer。

包含Toolbar Drawer的Activity
與以前的Toolbar示例一樣,我們必須確保Activity使用禁用視窗ActionBar的主題,如以下程式碼清單所示。
Toolbar Activity的部分Androidmanifest.xml
<activity android:name=".ToolbarActivity" android:label="@string/title_toolbar"android:theme="@style/Theme.AppCompat.Light.NoActionBar"> <intent-filter> <action android:name="android.intent.action.MAIN"/> <category android:name="android.intent.category.LAUNCHER"/> </intent-filter> </activity>
這就要求我們包括修改後的佈局,該佈局具有在層次結構中定義的Toolbar元素,如以下程式碼清單所示。
res/layout/activity_toolbar.xml
<?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" android:id="@+id/container_drawer" android:layout_width="match_parent" android:layout_height="match_parent" > <!-- 主內容窗格 --> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <!-- 使用 Toolbar 代替 Action Bar,從而檢視可在其頂部繪製--> <android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_height="wrap_content" android:layout_width="match_parent" android:minHeight="?attr/actionBarSize" android:background="?attr/colorPrimary" app:theme="@style/ThemeOverlay.AppCompat.Dark.ActionBar"/> <FrameLayout android:id="@+id/container_root" android:layout_width="match_parent" android:layout_height="match_parent"> <!-- 在此放置檢視內容 --> </FrameLayout> </LinearLayout> <!--主Drawer內容 --> <!-- 可以是任意檢視或ViewGroup內容。標準Drawer寬度為240dp。必須設定重力, 它必須為“left”或“start”。需要在內容頂部顯示純色背景 --> <ListView android:id="@+id/drawer_main" android:layout_width="240dp" android:layout_height="match_parent" android:layout_gravity="start" android:background="#FFF" /> <!-- 可以建立額外的Drawer,例如此處的Drawer將顯示為從螢幕右側輕掃 --> <LinearLayout android:id="@+id/drawer_right" android:layout_width="240dp" android:layout_height="match_parent" android:layout_gravity="end" android:orientation="vertical" android:background="#CCC"> <Button android:layout_width="match_parent" android:layout_height="wrap_content" android:text="Click Here!" /> <TextView android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:text="Tap Anywhere Else, Drawer will Hide" /> </LinearLayout> </android.support.v4.widget.DrawerLayout>
此Activity程式碼與前一個Drawer示例基本相同,不同之處在於onCreate()中的兩行程式碼,這些程式碼向Activity註冊佈局中的Toolbar。
Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar);