Android 沉浸式状态栏的实现

一提到沉浸式状态栏,第一个浮现在脑海里的词就是“碎片化”。碎片化是让 Android 开发者很头疼的问题,相信没有哪位开发者会不喜欢“write once, run anywhere”的感觉,碎片化让我们不得不耗费精力去校验代码在各个系统版本、各个机型上是否有效。因此以前我一直把沉浸式状态栏看作一块难啃的骨头,�但是该面对的问题迟早还是要面对,所以,不如就此开始吧。

沉浸式状态栏的实现

方法一:通过设置 Theme 主题设置状态栏透明

因为 API21 之后(也就是 android 5.0 之后)的状态栏,会默认覆盖一层半透明遮罩。且为了保持4.4以前系统正常使用,故需要三份 style 文件,即默认的values(不设置状态栏透明)、values-v19、values-v21(解决半透明遮罩问题)。

//valuse
<style name="TranslucentTheme" parent="AppTheme">
</style>

// values-v19。v19 开始有 android:windowTranslucentStatus 这个属性
<style name="TranslucentTheme" parent="Theme.AppCompat.Light.NoActionBar">
        <item name="android:windowTranslucentStatus">true</item>
        <item name="android:windowTranslucentNavigation">true</item>
</style>

// values-v21。5.0 以上提供了 setStatusBarColor()  方法设置状态栏颜色。
<style name="TranslucentTheme" parent="Theme.AppCompat.Light.NoActionBar">
    <item name="android:windowTranslucentStatus">false</item>
    <item name="android:windowTranslucentNavigation">true</item>
    <!--Android 5.x开始需要把颜色设置透明,否则导航栏会呈现系统默认的浅灰色-->
    <item name="android:statusBarColor">@android:color/transparent</item>
</style>
设置状态栏为透明

由图可见,设置之后布局的内容延伸到了状态栏。但有些场景下,我们还是需要状态栏那块位置存在的(然而不存在的)。有三种解决方法:

法一:设置 fitsSystemWindows 属性

引用一下官方对该属性的解释吧:

android:fitsSystemWindows

Boolean internal attribute to adjust view layout based on system windows such as the status bar. If true, adjusts the padding of this view to leave space for the system windows. Will only take effect if this view is in a non-embedded activity.

当该属性设置 true 时,会在屏幕最上方预留出状态栏高度的 padding。

在布局的最外层设置 android:fitsSystemWindows="true" 属性。当然,也可以通过代码设置:

/**
* 设置页面最外层布局 FitsSystemWindows 属性
* @param activity
* @param value
*/
public static void setFitsSystemWindows(Activity activity, boolean value) {
  ViewGroup contentFrameLayout = (ViewGroup) activity.findViewById(android.R.id.content);
  View parentView = contentFrameLayout.getChildAt(0);
  if (parentView != null && Build.VERSION.SDK_INT >= 14) {
      parentView.setFitsSystemWindows(value);
  }
}

通过该设置保留状态栏高度的 paddingTop 后,再设置状态栏的颜色。就可以达到设想的效果。但这种方式实现有些问题,例如我们想设置状态栏为蓝色,只能通过设置最外层布局的背景为蓝色来实现,然而一旦设置后,整个布局就都变成了蓝色,只能在下方的布局内容里另外再设置白色背景,而这样就存在过度绘制了。而且设置了 fitsSystemWindows=true 属性的页面,在点击 EditText 调出 软键盘时,整个视图都会被顶上去。

法二:布局里添加占位状态栏

法一:在根布局加入一个占位状态栏,这样虽然整个内容页面时顶到头的,但是因为在内容布局里添加了一个占位状态栏,所以效果与设想的一致。

<View
  android:id="@+id/statusBarView"
  android:background="@color/blue"
  android:layout_width="match_parent"
  android:layout_height="wrap_content"></View>

通过反射获取状态栏高度:

/**
* 利用反射获取状态栏高度
* @return
*/
public int getStatusBarHeight() {
  int result = 0;
  //获取状态栏高度的资源id
  int resourceId = getResources().getIdentifier("status_bar_height", "dimen", "android");
  if (resourceId > 0) {
      result = getResources().getDimensionPixelSize(resourceId);
  }
  return result;
}

设置占位视图高度

View statusBar = findViewById(R.id.statusBarView);
ViewGroup.LayoutParams layoutParams = statusBar.getLayoutParams();
layoutParams.height = getStatusBarHeight();

当然,除了从布局文件中添加这一方式之外,一样可以在代码中添加。比较推荐使用代码添加的方式,方便封装使用。

/**
 * 添加状态栏占位视图
 *
 * @param activity
 */
private void addStatusViewWithColor(Activity activity, int color) {
    ViewGroup contentView = (ViewGroup) activity.findViewById(android.R.id.content);    
    View statusBarView = new View(activity);
    ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
            getStatusBarHeight(activity));
    statusBarView.setBackgroundColor(color);
    contentView.addView(statusBarView, lp);
}
添加占位状态栏

法三:代码中设置 paddingTop 并添加占位状态栏

手动给根视图设置一个 paddingTop ,高度为状态栏高度,相当于手动实现了 fitsSystemWindows=true 的效果,然后再在根视图加入一个占位视图,其高度也设置为状态栏高度。

//设置 paddingTop
ViewGroup rootView = (ViewGroup) mActivity.getWindow().getDecorView().findViewById(android.R.id.content);
rootView.setPadding(0, getStatusBarHeight(mActivity), 0, 0);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    //5.0 以上直接设置状态栏颜色
    activity.getWindow().setStatusBarColor(color);
} else {
    //根布局添加占位状态栏
    ViewGroup decorView = (ViewGroup) mActivity.getWindow().getDecorView();
    View statusBarView = new View(activity);
    ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
            getStatusBarHeight(activity));
    statusBarView.setBackgroundColor(color);
    decorView.addView(statusBarView, lp);
}

个人认为最优解应该是第三种方法,通过这种方法达到沉浸式的效果后面也可以很方便地拓展出渐变色的状态栏。

方法二:代码中设置

通过在代码中设置,实现方法一中在 Theme 主题样式里设置的属性,便于封装。

 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
    int flagTranslucentStatus = WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS;
    int flagTranslucentNavigation = WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION;
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        Window window = getWindow();
        WindowManager.LayoutParams attributes = window.getAttributes();
        attributes.flags |= flagTranslucentNavigation;
        window.setAttributes(attributes);
        getWindow().setStatusBarColor(Color.TRANSPARENT);
    } else {
        Window window = getWindow();
        WindowManager.LayoutParams attributes = window.getAttributes();
        attributes.flags |= flagTranslucentStatus | flagTranslucentNavigation;
        window.setAttributes(attributes);
    }
}

但是从图片中也看到了,该方案会导致一个问题就是导航栏颜色变灰。
经测试,在 5.x 以下导航栏透明是可以生效的,但 5.x 以上导航栏会变灰色(正常情况下我们期望导航栏保持默认颜色黑色不变),但因为设置了FLAG_TRANSLUCENT_NAVIGATION,所以即使代码中设置 getWindow().setNavigationBarColor(Color.BLACK); 也是不起作用的。但如果不设置该 FLAG ,状态栏又无法被置为隐藏和设置透明。

方案二:全屏模式的延伸

通过设置 FLAG ,让应用内容占用系统状态栏的空间,经测试该方式不会影响对导航栏的设置。

/**
 * 通过设置全屏,设置状态栏透明
 *
 * @param activity
 */
private void fullScreen(Activity activity) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            //5.x开始需要把颜色设置透明,否则导航栏会呈现系统默认的浅灰色
            Window window = activity.getWindow();
            View decorView = window.getDecorView();
            //两个 flag 要结合使用,表示让应用的主体内容占用系统状态栏的空间
            int option = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                    | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
            decorView.setSystemUiVisibility(option);
            window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
            window.setStatusBarColor(Color.TRANSPARENT);
            //导航栏颜色也可以正常设置
//                window.setNavigationBarColor(Color.TRANSPARENT);
        } else {
            Window window = activity.getWindow();
            WindowManager.LayoutParams attributes = window.getAttributes();
            int flagTranslucentStatus = WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS;
            int flagTranslucentNavigation = WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION;
            attributes.flags |= flagTranslucentStatus;
//                attributes.flags |= flagTranslucentNavigation;
            window.setAttributes(attributes);
        }
    }
}
可以正常设置导航栏颜色

验证其他使用场景

侧滑菜单

使用 AS 自动创建 Navigation Drawer Activity ,布局结构为:

  • DrawerLayout
    • include :内容布局,默认使用 ToolBar
    • NavigationView :侧滑布局

这里只调用了 fullScreen(), 测试一下运行结果如何:

侧滑菜单

可以看到都有不尽如人意的地方,4.4 系统中内容视图是可以正常延伸到状态栏中,但侧滑菜单中却在上方出现了白条,而在 6.0 中侧滑菜单上会有半透明遮罩。针对 6.0 侧滑菜单半透明遮罩问题,通过设置为 NavigationView 设置属性 app:insetForeground="#00000000" 即可解决。针对 4.4 侧滑菜单白条问题,经过测试,通过对最外层布局设置 setFitsSystemWindows(true)setClipToPadding(false) 可以解决,所以这里对之前的 fitsSystemWindows 方法稍加修改:

 /**
 * 设置页面最外层布局 FitsSystemWindows 属性
 *
 * @param activity
 */
private void fitsSystemWindows(Activity activity) {
    ViewGroup contentFrameLayout = (ViewGroup) activity.findViewById(android.R.id.content);
    View parentView = contentFrameLayout.getChildAt(0);
    if (parentView != null && Build.VERSION.SDK_INT >= 14) {
        //布局预留状态栏高度的 padding
        parentView.setFitsSystemWindows(true);
        if (parentView instanceof DrawerLayout) {
            DrawerLayout drawer = (DrawerLayout) parentView;
            //将主页面顶部延伸至status bar;虽默认为false,但经测试,DrawerLayout需显示设置
            drawer.setClipToPadding(false);
        }
    }
}

这样是解决了上述的问题,既然延伸内容没问题了,那就开开心心地像上面一样调用 addStatusViewWithColor() 方法增加个占位状态栏,解决一下内容顶到头的问题吧:

4.4 系统,增加占位状态栏异常

可以看到,效果依然不是我们想要的,虽然占位状态栏是有了,但是却也覆盖到了侧滑菜单上,并且即使设置了 android:fitsSystemWindows="true" 也并没有什么卵用,内容布局依然顶到了头部。这里有两种解决方法:1. 第一种方案是网上提到比较多的,改变 ToolBar 的高度,并增加状态栏高度的 paddingTop,这也是
ImmersionBar 库采用的方案。2. 第二种方案其实思路与第一种差不多,就是将原有的内容布局从 DrawerLayout 中移除,并添加到线性布局(布局中已有占位状态栏),之后再将这个线性布局添加到 DrawerLayout 中成为新的内容布局,此谓狸猫换太子。

/**
 * 是否是最外层布局为 DrawerLayout 的侧滑菜单
 * @param drawerLayout 是否最外层布局为 DrawerLayout
 * @param contentId 内容视图的 id
 * @return
 */
public StatusBarUtils setIsDrawerLayout(boolean drawerLayout, int contentId) {
    mIsDrawerLayout = drawerLayout;
    mContentResourseIdInDrawer = contentId;
    return this;
}

/**
 * 添加状态栏占位视图
 *
 * @param activity
 */
private void addStatusViewWithColor(Activity activity, int color) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        if (isDrawerLayout()) {
            //要在内容布局增加状态栏,否则会盖在侧滑菜单上
            ViewGroup rootView = (ViewGroup) activity.findViewById(android.R.id.content);
            //DrawerLayout 则需要在第一个子视图即内容试图中添加padding
            View parentView = rootView.getChildAt(0);
            LinearLayout linearLayout = new LinearLayout(activity);
            linearLayout.setOrientation(LinearLayout.VERTICAL);
            View statusBarView = new View(activity);
            ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                    getStatusBarHeight(activity));
            statusBarView.setBackgroundColor(color);
            //添加占位状态栏到线性布局中
            linearLayout.addView(statusBarView, lp);
            //侧滑菜单
            DrawerLayout drawer = (DrawerLayout) parentView;
            //内容视图
            View content = activity.findViewById(mContentResourseIdInDrawer);
            //将内容视图从 DrawerLayout 中移除
            drawer.removeView(content);
            //添加内容视图
            linearLayout.addView(content, content.getLayoutParams());
            //将带有占位状态栏的新的内容视图设置给 DrawerLayout
            drawer.addView(linearLayout, 0);
        } else {
            //设置 paddingTop
            ViewGroup rootView = (ViewGroup) mActivity.getWindow().getDecorView().findViewById(android.R.id.content);
            rootView.setPadding(0, getStatusBarHeight(mActivity), 0, 0);
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                //直接设置状态栏颜色
                activity.getWindow().setStatusBarColor(color);
            } else {
                //增加占位状态栏
                ViewGroup decorView = (ViewGroup) mActivity.getWindow().getDecorView();
                View statusBarView = new View(activity);
                ViewGroup.LayoutParams lp = new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
                        getStatusBarHeight(activity));
                statusBarView.setBackgroundColor(color);
                decorView.addView(statusBarView, lp);
            }
        }
    }
}

一番操作后,效果如下:

改进 addStatusViewWithColor() 后的效果

对于内容视图未使用到 ToolBar 的情况方案二依然可以适用。

ActionBar

上述代码在使用 ActionBar 时可以完美适配吗?测试后效果如下图所示

6.0 状态栏黑边

可以看到,通过添加指定颜色的占位状态来达到沉浸效果的方案,在 4.4 系统上效果是正常的,但是在 6.0 上,在状态栏和 Actionbar 之间会有阴影,这个阴影是主题的效果。不知道大家还记不记得 Theme 主题里的几个设计颜色的属性:

各属性颜色

colorPrimary 指定 ActionBar 的颜色,colorPrimaryDark 指定状态栏颜色,经过测试,在主题里将二者设为统一颜色,状态栏和 ActionBar 之间不会有黑边。自然,我们除了在 Theme 主题里设置,还可以直接在代码里通过上文提到过的代码修改 5.x 以上系统的状态栏颜色:

Window window = activity.getWindow();
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
window.setStatusBarColor(Color.BLUE);

但是因为 setStatusBarColor() 方法的参数无法传入 Drawble ,所以这种方式是无法实现渐变色状态栏的效果的。所以还是应该聚焦在怎么解决 ActionBar 阴影的问题,上面说了,既然这个阴影是 Theme 的效果,那就肯定有移除这种效果的方法,一种解决方法是更改主题为 ActionBar 不带阴影的主题样式:

<style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
    <!-- Customize your theme here. -->
    <item name="colorPrimary">@color/colorPrimary</item>
    <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
    <item name="colorAccent">@color/colorAccent</item>
    <item name="android:windowContentOverlay">@null</item>
    //更改 ActionBar 风格样式
    <item name="actionBarStyle">@style/ActionBarStyleWithoutShadow</item>
</style>

//ActionBar 不带阴影的主题样式
<style name="ActionBarStyleWithoutShadow" parent="android:Theme.Holo.ActionBar">
    <item name="background">@color/blue</item>
</style>

还有第二种更简单的方式,那就是直接在代码里设置去除阴影:

/**
 * 去除 ActionBar 阴影
 */
public StatusBarUtils clearActionBarShadow() {
    if (Build.VERSION.SDK_INT >= 21) {
        ActionBar supportActionBar = ((AppCompatActivity) mActivity).getSupportActionBar();
        if (supportActionBar != null) {
            supportActionBar.setElevation(0);
        }
    }
    return this;
}

并且因为内容是位于 ActionBar 之下的,我们还必须给内容视图是指一个 paddingTop,高度为状态栏高度+ActionBar 高度,才可以使内容正常显示。我们给 ActionBar 设置一个渐变色试试看:

//drawble 文件夹内新建 shape 渐变色
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="rectangle">
    <gradient
        android:angle="0"
        android:centerX="0.7"
        android:endColor="@color/shape2"
        android:startColor="@color/shape1"
        android:centerColor="@color/shape3"
        android:type="linear" />
</shape>

//ActionBar 设置渐变背景色
getSupportActionBar().setBackgroundDrawable(getResources().getDrawable(R.drawable.shape));

//占位状态栏 设置渐变背景色
View statusBarView = new View(activity);
...
//增加占位状态栏方法同上,只是在设置 statusBarView 背景上有 color 和 drawble 之分
statusBarView.setBackground(drawable);

if (isActionBar()) {
    //要增加内容视图的 paddingTop,否则内容被 ActionBar 遮盖
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        ViewGroup rootView = (ViewGroup) mActivity.getWindow().getDecorView().findViewById(android.R.id.content);
        rootView.setPadding(0, getStatusBarHeight(mActivity) + getActionBarHeight(mActivity), 0, 0);
    }
}
渐变色状态栏

至此,尝试适配了几种比较常见的使用场景的沉浸式状态栏,效果也都还比较符合预期。真正去处理这个问题时会发现其实问题也没有想象中的那么复杂。最后附上 Github 源码

Stay hungry. Stay foolish.

下篇博客再见。

推荐阅读更多精彩内容