一种简单的 Android 沉浸式 UI 设计思路

Android

最近几天,给掌上重邮重新做了一套 Material Design 的沉浸式 UI (从此再也不和那堆做 iOS Design 的产品和视觉混了),项目地址请戳这里

下面就用做这个项目的经验总结一下沉浸式 UI 吧!

概览

沉浸式 UI 指的是在 Android 视图中将最顶上的通知栏(StatusBar)和最底下的导航栏(NavigationBar)融入自己的视图的做法,更通俗一点地说就是 透明的通知栏 + 透明的导航栏 。这种 UI 可以增大自己的 APP 在屏幕上占据的比例,给用户带来更宽广的视角以及屏幕变大了的错觉

支持沉浸式 UI 的 Android 版本

基本上 Android 4.4 就开始支持沉浸式 UI 了,而 Android 5.0 开启了 Material Design 时代,然后沉浸式的效果基本上固定了,但是这两个版本看起来还是有那么一些区别的。实际上做起来也会有一些区别(主要是 Android 4.4 有坑)。

Android 4.4 上的效果:

Android 5.0 以后的效果:

NoActionBar 主题 + Toolbar + AppBarLayout

既然要使用 StatusBar 的位置,那就不能使用 ActionBar 了,在 values/styles.xml 选择一个 NoActionBar 主题。就像这样

<style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
    <item name="colorPrimary">@color/colorPrimary</item>
    <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
    <item name="colorAccent">@color/colorAccent</item>
</style>

Toolbar 是 Android 5.0 新增加的控件,使用方法可以参考 Jude95 写的 Toolbar的使用

由于还要调整 Toolbar 上方的大小,因此我们还要借助 AppBarLayout ,AppBarLayout 是 Android Design Support Library 中的控件,需要添加依赖 com.android.support:design ,可以直接把 Toolbar 放到它里面,并对 AppBarLayout 而不是 Toolbar 设置背景颜色。

然后我们就可以复用这个 Toolbar ,将整个 AppBarLayout 复制出来作为一个单独的文件,要用的时候 include 即可

toolbar.xml

<android.support.design.widget.AppBarLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/app_bar"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="?attr/colorPrimary">

    <android.support.v7.widget.Toolbar
        android:id="@+id/toolbar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:minHeight="?attr/actionBarSize"
        android:theme="@style/AppTheme.AppBarOverlay"
        app:popupTheme="@style/AppTheme.PopupOverlay"/>

</android.support.design.widget.AppBarLayout>

完成这一步之后, StatusBar 和 NavigationBar 仍然是黑色的,下面我们让它变成透明的。

WindowTranslucent 主题,定义透明 StatusBar 和 NavigationBar

首先是 3 个属性

    <!--透明状态栏-->
    <item name="android:windowTranslucentStatus">true</item>
    <!--透明导航栏-->
    <item name="android:windowTranslucentNavigation">true</item>
    <!--使状态栏,导航栏可绘制-->
    <item name="android:windowDrawsSystemBarBackgrounds">true</item>

前两个是 Android 4.4 (SDK19) 加入的,最后那个是 Android 5.0 (SDK21) 加入的,所以我们可以这样适配。

values/styles.xml - 4.3 及之前,不支持沉浸式 UI ,可以直接继承 AppTheme ,什么都不做

<style name="AppTheme.WindowTranslucent.Base" parent="AppTheme"/>
<style name="AppTheme.WindowTranslucent" parent="AppTheme.WindowTranslucent.Base"/>

values-v19/styles.xml - Android 4.4 ,支持沉浸式 UI ,没有 windowDrawsSystemBarBackgrounds 属性,继承 Base 然后加上这两条就好了。

<style name="AppTheme.WindowTranslucent" parent="AppTheme.WindowTranslucent.Base">
    <item name="android:windowTranslucentStatus">true</item>
    <item name="android:windowTranslucentNavigation">true</item>
</style>

values-v21/styles.xml - Android 5.0 及以后,加入了 windowDrawsSystemBarBackgrounds

<style name="AppTheme.WindowTranslucent" parent="AppTheme.WindowTranslucent.Base">
    <item name="android:windowTranslucentStatus">true</item>
    <item name="android:windowTranslucentNavigation">true</item>
    <item name="android:windowDrawsSystemBarBackgrounds">true</item>
</style>

现在,运行 App ,在 Android 4.4 及以后的系统上,StatusBar 和 NavigationBar 就变成透明的了。

然而,UI 跑到了 StatusBar 和 NavigationBar 下面,这是理所当然的, 因为你可以自由使用这两块区域了,整个屏幕都成了你的绘制区域。

然而你很快就会发现一些问题,有些 View 被 StatusBar 和 NavigationBar 挡住了而无法点击,“嵌入”在 StatusBar 中的 Toolbar 显得很怪异,因此必须做出一些改变了。

改变

由于绘制区域会覆盖整个屏幕,包括 StatusBar 和 NavigationBar 的位置,总的来说,有这些地方需要适配一下。

  • AppBarLayout

  • SnackBar (然而我想说 SnackBar 完全不适合这个嘛。。。)

  • DrawerLayout

  • 可滚动 View 的最后一个 Item

当然还有些地方是不适合做沉浸式的,这种情况的话请设置没有上面提到的三个属性的 Theme ,创建项目时自动生成的 AppTheme 就是一个不错的选择。

  • 有底部输入框的界面,多见于聊天和回复

  • BottomNavigation (这完全就是做相反的事情嘛。。。)

为了适配上面提到的这些改变,下面给出了一些方案。

StatusBarHeight 与 NavigationBarHeight

首要的事情就是获取 StatusBar 和 NavigationBar 的高度,这里提供两个方法。

Utils.java

public static int getStatusBarHeight(Context context) {
  int result = 0;
  int resourceId = context.getResources().getIdentifier("status_bar_height", "dimen", "android");
  if (resourceId > 0) {
    result = context.getResources().getDimensionPixelSize(resourceId);
  }
  return result;
}

public static int getNavigationBarHeight(Context context) {
  int result = 0;
  int resourceId = context.getResources().getIdentifier("navigation_bar_height", "dimen", "android");
  if (resourceId > 0) {
    result = context.getResources().getDimensionPixelSize(resourceId);
  }
  return result;
}

这两个方法都是直接从 Jude95/Utils 里复制来的。经常用到的话直接依赖这个库的就好。

检查虚拟键是否存在

可以肯定的一点是所有 Android 手机都有 StatusBar (其实平板和电视都没有 StatusBar 的,2.x 时代还见过有些系统能把 StatusBar 放在下面,不过这毕竟是少数,本文就不过多讨论了),但是仍然有很多手机没有 NavigationBar (啊咧,国产品牌好像都是这样的)。而且,虽然这些手机没有 NavigationBar ,但是仍然能获取到 NavigationBarHeight ,这显然不是我们想要的结果,所以我们还是要判断一下有没有 NavigationBar 。

Utils.java

public static boolean hasSoftKeys(Context ctx) {
    boolean hasSoftwareKeys;
    WindowManager manager = (WindowManager) ctx.getSystemService(Context.WINDOW_SERVICE);
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
        Display d = manager.getDefaultDisplay();

        DisplayMetrics realDisplayMetrics = new DisplayMetrics();
        d.getRealMetrics(realDisplayMetrics);

        int realHeight = realDisplayMetrics.heightPixels;
        int realWidth = realDisplayMetrics.widthPixels;

        DisplayMetrics displayMetrics = new DisplayMetrics();
        d.getMetrics(displayMetrics);

        int displayHeight = displayMetrics.heightPixels;
        int displayWidth = displayMetrics.widthPixels;

        hasSoftwareKeys = (realWidth - displayWidth) > 0 || (realHeight - displayHeight) > 0;
    } else {
        boolean hasMenuKey = ViewConfiguration.get(ctx).hasPermanentMenuKey();
        boolean hasBackKey = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_BACK);
        hasSoftwareKeys = !hasMenuKey && !hasBackKey;
    }
    return hasSoftwareKeys;
}

这个方法也是从 Jude95/FitSystemWindowLayout 复制来的。(诶好像整篇文章没一行我自己的代码一样)

自适应的 Margin , StatusBarMarginView 和 NavigationBarMarginView

其实有了上面这些工具,随便改改 View 的 onMeasure 方法就能造出这种东西了。

StatusBarMarginView.java

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec), Utils.getStatusBarHeight(getContext()));
    } else {
        setMeasuredDimension(0, 0);
    }
}

NavigationBarMarginView.java

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && Utils.hasSoftKeys(getContext())) {
        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec), Utils.getNavigationBarHeight(getContext()));
    } else {
        setMeasuredDimension(0, 0);
    }
}

然后,把这两个东西插入到需要的地方,比如上面提到过的 AppBarLayout 里面,DrawerLayout 里面,ScrollView 里面,还有作为 RecyclerView 的最后一个元素,这个稍后再说,我们先把 App 跑起来。

现在的布局在 Android 5.0 上看起来已经相当不错了。。然而,当我们把它放到 Android 4.4 上时,一开始一切安好,直到我拉开 DrawerLayout 。。。

这就很尴尬了。。。

适配 Android 4.4 - android:fitsSystemWindows=”false”

这就是 Android 4.4 的蛋疼之处了。。。

NavigationView 在 Android 4.4 中默认使用 android:fitsSystemWindows=”true”,因为在 Android 4.4 中,即使你不设置上面提到的 WindowTranslucent 主题,给 NavigationView 设置 android:fitsSystemWindows=”false” 它也要往外跑,于是黑色的 StatusBar 就盖在了你的 NavigationView 上,违和感更重。

给 NavigationView 设置 android:fitsSystemWindows="false" ,就像这样

<android.support.design.widget.NavigationView
    android:id="@+id/main_navigation"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    android:layout_gravity="start"
    android:fitsSystemWindows="false"
    app:menu="@menu/menu_navigation_normal"
    app:headerLayout="@layout/head_navigation"/>

问题终于解决了。

现在看起来很完美了,然而,当用户把手机横过来,开启横屏模式,整个界面又崩坏了。。。

适配横屏

问题出在当用户使用横屏之后,NavigationBar 跑到了屏幕的右边,把右边的 UI 挡住了,用户无法点击,更严重的是,底下的 NavigationBarMarginView 反倒留下了一道难看的空白。所以还是得处理一下横屏的问题。

最简单的解决方案是直接禁用横屏时 NavigationBar 的透明效果(当然更简单的方法是直接禁止用户使用横屏模式),所以还是得改 values/styles.xml ,安卓允许在定义资源文件时使用屏幕的朝向作为限定符,因此实现起来就十分方便。

values/styles.xml - 不变

<style name="AppTheme.WindowTranslucent.Base" parent="AppTheme"/>
<style name="AppTheme.WindowTranslucent" parent="AppTheme.WindowTranslucent.Base"/>

values-port-v19/styles.xml - 这个文件定义 Android 4.4 的竖屏状态,内容与之前的 values-v19/styles.xml 相同

<style name="AppTheme.WindowTranslucent" parent="AppTheme.WindowTranslucent.Base">
    <item name="android:windowTranslucentStatus">true</item>
    <item name="android:windowTranslucentNavigation">true</item>
</style>

values-v19/styles.xml - 这个文件定义 Android 4.4 的默认( 竖屏)状态, NavigationBar 不设透明。

<style name="AppTheme.WindowTranslucent" parent="AppTheme.WindowTranslucent.Base">
    <item name="android:windowTranslucentStatus">true</item>
    <item name="android:windowTranslucentNavigation">false</item>
</style>

values-port-v21/styles.xml - 这个文件定义 Android 5.0 之后系统的竖屏状态,内容与之前的 values-v21/styles.xml 相同

<style name="AppTheme.WindowTranslucent" parent="AppTheme.WindowTranslucent.Base">
    <item name="android:windowTranslucentStatus">true</item>
    <item name="android:windowTranslucentNavigation">true</item>
    <item name="android:windowDrawsSystemBarBackgrounds">true</item>
</style>

values-v21/styles.xml - 这个文件定义 Android 5.0 之后的默认( 竖屏)状态, NavigationBar 不设透明。

<style name="AppTheme.WindowTranslucent" parent="AppTheme.WindowTranslucent.Base">
    <item name="android:windowTranslucentStatus">true</item>
    <item name="android:windowTranslucentNavigation">false</item>
    <item name="android:windowDrawsSystemBarBackgrounds">true</item>
</style>

另外,横屏时 NavigationBarMarginView 不能继续占据位置,因为下面已经没有 NavigationBar 了。所以要判断一下屏幕的朝向。

Utils.java

public static boolean isScreenOrientationPortrait(Context context) {
    Configuration config = context.getResources().getConfiguration();
    return config.orientation == Configuration.ORIENTATION_PORTRAIT;
}

NavigationBarMarginView.java

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && Utils.hasSoftKeys(getContext()) && Utils.isScreenOrientationPortrait(getContext())) {
        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec), Utils.getNavigationBarHeight(getContext()));
    } else {
        setMeasuredDimension(0, 0);
    }
}

完成这些,把屏幕横过来, NavigationBar 不再是透明的,问题解决了。

适配可滚动视图

最简单的就是 ScrollView 了,只需要把 NavigationBarMarginView 放到它里面作为最底部的 View 就好。

但是,还有一些可滚动的 View ,我们无法直接给它添加 View 。下面提供一些适配方案。

为了给每个 RecyclerView 的最底下添加一个 NavigationBarMarginView ,我们创建一个 NavigationBarMarginRecyclerViewAdapter 直接继承于 RecyclerView.Adapter<RecyclerView.ViewHolder> ,然后增加一个 itemCount ,利用 viewType 控制使得创建这个 ViewHolder 时返回一个包含 NavigationBarMarginView 的 viewType ,然后所有需要的 RecyclerView 使用的 Adapter 都继承 NavigationBarMarginRecyclerViewAdapter ,这种方法的具体实现看这里

Android Design Support Library 里有个 NavigationView ,我们常常用它来做一个可滚动的 Material Design 分隔的 Drawer ,它的 header 里可以直接放一个 StatusBarMarginView ,然而它的 menu 里不能直接放 NavigationBarMarginView ,这时只需要在初始化的时候判断是否要放置 NavigationBarMarginView ,然后增加一个禁用的 Item 就好

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT && Utils.hasSoftKeys(getContext()) && Utils.isScreenOrientationPortrait(getContext())) {
    mNavigationView.getMenu().add("").setEnabled(false);
}

参考

Jude95 的 Android沉浸式UI实现及原理 以及他的 FitSystemWindowLayout