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或以后版本的应用程序可以通过包含支持库来使用该小部件。有关在项目中包括支持库的更多信息,请参考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);