Android 沉浸式状态栏必知必会

5,854 阅读11分钟
原文链接: blog.cgsdream.org

Android状态栏默认是固定的黑底白字,这肯定是不被伟大的设计师所喜爱的,更有甚者,某些时候设计希望内容能够延时到状态栏底部(例如头部是大图的情况)。所幸的是随着Android版本的迭代,开发者对状态栏等控件有了更多的控制。Android一直在尝试引入新的Api来满足开发者的需求,但Api却一直不够完美,接口添加了很多,却都不够简单或者说完美,算上第三方厂商的特色行为,怎一个“乱”字了得

Android完美的沉浸式需要多个接口配合使用才能完成,我们需要去了解android各个版本引入的Api的功能和局限性,因此这篇文章首先会介绍系统的一些接口,然后展示如何封装一些接口用于实现沉浸式。

  • SystemUI
  • StatusBar颜色更改
  • fitSystemWindows
  • 一个完整的封装

SystemUI

在Android2.3以前,对StatusBar的操作有两个:StatusBar的显示与隐藏、Activiy内容延伸到StatusBar下方(全局布局)。

// 全屏布局且隐藏状态栏:
getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);

// 全屏布局,不隐藏状态栏:
getWindow().addFlags(WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN 

| WindowManager.LayoutParams.FLAGLAYOUTNO_LIMITS);

在Android3.0中,View添加了一个重要的方法:setSystemUiVisibility(int),用于控制一些窗口装饰元素的显示,并添加了View.STATUS_BAR_VISIBLEView.STATUS_BAR_HIDDEN两个Flag用于控制Status Bar的显示与隐藏。

在Android4.0中,View.STATUS_BAR_VISIBLE改为View.SYSTEM_UI_FLAG_VISIBLEView.STATUS_BAR_HIDDEN更名为View.SYSTEM_UI_FLAG_LOW_PROFILE。由于引进了NavigationBar,因此也添加了一个flag:SYSTEM_UI_FLAG_HIDE_NAVIGATION

  • View.SYSTEM_UI_FLAG_LOW_PROFILE: 同时影响StatusBar和NavigationBar,但并不会使得SystemUI消失,而只会使得背景很浅,并且去掉SystemUI的一些图标或文字。
  • View.SYSTEM_UI_FLAG_HIDE_NAVIGATION: 会隐藏NavigationBar,但是由于NavigationBar是非常重要的,因此只要有用户交互,系统就会清除这个flag使NavigationBar就会再次出现。

在Android4.1中,又引入了以下几个flag:

  • View.SYSTEM_UI_FLAG_FULLSCREEN: 这个标志与WindowManager.LayoutParams.FLAG_FULLSCREEN作用相同,但是如果你从屏幕下滑或者一些其它操作,会使得StatusBar重新显示。
  • View.SYSTEM_UI_FLAG_LAYOUT_STABLE: 与其它flag配合使用,防止系统栏隐藏时内容区域发生变化。
  • View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN: Activity全屏显示,但状态栏不会被隐藏覆盖,状态栏依然可见,Activity顶端布局部分会被状态遮住
  • View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION: 使内容布局到NavigationBar之下,可以配合SYSTEM_UI_FLAG_HIDE_NAVIGATION使用防止跳动

在Android4.4(API 19)又增加了两个flag:

  • View.SYSTEM_UI_FLAG_IMMERSIVE
  • View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY

这两个flag主要是对SYSTEM_UI_FLAG_FULLSCREENSYSTEM_UI_FLAG_HIDE_NAVIGATION的修补。前文已经说过,在使用这两个flag后,用户的某些行为会使得系统强制清除这些flag。这并不是用户想要的,因此配合View.SYSTEM_UI_FLAG_IMMERSIVEView.SYSTEM_UI_FLAG_IMMERSIVE_STICKY就可以阻止系统的强制清除行为。

View.SYSTEM_UI_FLAG_IMMERSIVE只作用与SYSTEM_UI_FLAG_FULLSCREEN,而View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY同时作用于两个

综上,我们可以给出全屏布局和隐藏状态栏的新方案

//仅仅只是全屏布局:
//getWindow().getDecorView().setSystemUiVisibility(
    View.SYSTEM_UI_FLAG_LAYOUT_STABLE
    | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
    | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);

//全屏布局并且隐藏状态栏与导航栏
getWindow().getDecorView().setSystemUiVisibility(
    View.SYSTEM_UI_FLAG_FULLSCREEN
    | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
    | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
    | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
    | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
    | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);

在Android4.4还为WindowManager.LayoutParams添加了两个flag:

  • FLAG_TRANSLUCENT_STATUS: 当使用这个flag时SYSTEM_UI_FLAG_LAYOUT_STABLESYSTEM_UI_FLAG_LAYOUT_FULLSCREEN会被自动添加
  • FLAG_TRANSLUCENT_NAVIGATION:当使用这这个个flag时SYSTEM_UI_FLAG_LAYOUT_STABLESYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION会被自动添加。

StatusBar颜色更改

StatusBar的颜色更改分为两部分,一个是背景颜色的修改,一个是字体颜色的修改。

首先先说说背景颜色的修改,在Android 5.0之前,状态栏颜色并不可定制,5.0之后才可定制。首先,我们可以在主题里通过colorPrimaryDark来指定背景色,其次,我们可以调用 window.setStatusBarColor(@ColorInt int color) 来修改状态栏颜色,但是让这个方法生效有一个前提条件:

你必须给window添加FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS并且取消FLAG_TRANSLUCENT_STATUS

此外,设置FLAG_TRANSLUCENT_STATUS也会影响到StatusBar的背景色,但并没有固定的表现:

  • 对于6.0以上的机型,设置此flage会使得StatusBar完全透明
  • 对于5.x的机型,大部分是使背景色半透明,小米和魅族以及其它少数机型会全透明
  • 对于4.4的机型,小米和魅族是透明色,而其它系统上就只是黑色到透明色的渐变。

我们知道了改背景色后,我们再来看看字体和图标颜色的更改。默认字体和图标是白色,如果在浅色背景上就会看不到状态栏信息了,因此体验会很糟糕。但可惜的是android6.0才官方支持更改字体和图标的颜色。

在Android6以后,我们只要给SystemUI加上SYSTEM_UI_FLAG_LIGHT_STATUS_BAR这个flag,就可以让字体和图标变为黑色。虽然官方已经支持了,但国内有些机型的版本号确实是6.0,但并不能更改字体和图标颜色,例如联想的ZUK Z1机型

当然,国内的魅族和小米走在前沿,从Android4.4开始就已经更改字体和图标颜色了,但并没有直接的接口用,必须通过反射的方式去更改字体颜色

针对小米的方案:

/**
 * 设置状态栏字体图标为深色,需要 MIUIV6 以上
 *
 * @param window 需要设置的窗口
 * @param dark   是否把状态栏字体及图标颜色设置为深色
 * @return boolean 成功执行返回 true
 */
public static boolean MIUISetStatusBarLightMode(Window window, boolean dark) {
    boolean result = false;
    if (window != null) {
        Class clazz = window.getClass();
        try {
            int darkModeFlag;
            Class layoutParams = Class.forName("android.view.MiuiWindowManager$LayoutParams");
            Field field = layoutParams.getField("EXTRA_FLAG_STATUS_BAR_DARK_MODE");
            darkModeFlag = field.getInt(layoutParams);
            Method extraFlagField = clazz.getMethod("setExtraFlags", int.class, int.class);
            if (dark) {
                extraFlagField.invoke(window, darkModeFlag, darkModeFlag);//状态栏透明且黑色字体
            } else {
                extraFlagField.invoke(window, 0, darkModeFlag);//清除黑色字体
            }
            result = true;
        } catch (Exception e) {

        }
    }
    return result;
}

针对魅族的方案:

 /**
 * 设置状态栏图标为深色和魅族特定的文字风格
 * 可以用来判断是否为 Flyme 用户
 *
 * @param window 需要设置的窗口
 * @param dark   是否把状态栏字体及图标颜色设置为深色
 * @return boolean 成功执行返回true
 */
public static boolean FlymeSetStatusBarLightMode(Window window, boolean dark) {
    boolean result = false;
    if (window != null) {
        try {
            WindowManager.LayoutParams lp = window.getAttributes();
            Field darkFlag = WindowManager.LayoutParams.class
                    .getDeclaredField("MEIZU_FLAG_DARK_STATUS_BAR_ICON");
            Field meizuFlags = WindowManager.LayoutParams.class
                    .getDeclaredField("meizuFlags");
            darkFlag.setAccessible(true);
            meizuFlags.setAccessible(true);
            int bit = darkFlag.getInt(null);
            int value = meizuFlags.getInt(lp);
            if (dark) {
                value |= bit;
            } else {
                value &= ~bit;
            }
            meizuFlags.setInt(lp, value);
            window.setAttributes(lp);
            result = true;
        } catch (Exception e) {

        }
    }
    return result;
}

对于小米魅族除外的Android5.x的机器,不能改字体和图标颜色,如果app是浅色皮肤,那么我们就只能给StatusBar设置半透明的背景了,并且FLAG_TRANSLUCENT_STATUS并不可靠(前文已说,表现不一定是半透明背景)

fitSystemWindows

我们首先探讨了内容布局是否全屏以及状态栏的显示与隐藏,其次我们探讨了状态栏颜色的修改问题。那如果我们全屏布局并且显示透明状态栏的时候会怎样?

状态栏与内容会重叠。这既是我们想要的效果,也是我们不想要的内容。如果APP顶部时高斯模糊的图片,与状态栏重叠是设计师希望看到的效果;但是,如果ActionBar和状态栏重叠了,那可就不好看了。 所以重叠与不重叠完全看业务,而库的封装者则需要告诉业务方,如何才能不重叠。

这个时候就是fitSystemWindows出场的时候了。

我们可以给view设置fitSystemWindows属性,其是一个bool值。其既可以在xml里直接设置android:fitsSystemWindows="true",也可以通过View#setFitsSystemWindows(boolean fitSystemWindows)在java代码中设置。不过这一步也仅仅只是设置了一个flag。

Android系统组件例如状态栏、NavBar、键盘所占据的空间称为界面的WindowInsets,Android系统会在特定的时机从根View派发WindowInsets,如果View的fitSystemWindows标志位被设为true的话,WindowInsets会传递给下列几个方法:

  1. fitSystemWindows(Rect insets): 这个是老版本提供的接口,现在已经被弃用,仅用于API 19
  2. onApplyWindowInsets(WindowInsets insets): 这应该是标准的方式了,然而在魅蓝M1上竟然会出现找不到WindowInsets这个类的crash
  3. 使用ViewCompat.setOnApplyWindowInsetsListener添加的Listener: 这种setListener的方式比较灵活,并且传值是WindowInsetsCompat类型,在魅蓝M1等机型都可以跑通,是上乘之选。

此外有几个关键点需要重点关注:

  1. 一旦有一个View消耗了WindowInsets,那么WindowInsets的dispatch就结束了。所以一般只在Activity的最外层View调用setFitsSystemWindows(true)
  2. 系统处理WindowInsets的手段本质是设置padding,因此这会让你View原本的padding失效
  3. 一般而言,只有一个View消耗WindowInsets,但这是系统行为,我们可以在onApplyWindowInsets里主动调用dispatchApplyWindowInsets使得其可以继续传递。

第三点的意义在于,如果我们需要多个View受WindowInsets影响时,我们可以自己去传递WindowInsets,一般封装者也会提供一个WindowInsetsLayout, 让直接子元素的fitSystemWindows都生效。@XiNGRZ在Mantou Earth有一个很好的实现(点我查看)。使用这个Layout可以满足大部分需求,但也存在几个探讨点:

  1. 使用onApplyWindowInsets在魅蓝M1上会crash(前文已经指出原因);
  2. insets.getSystemWindowInsetBottom()因不应该传递下去,正常情况为0,传不传递无所谓,但如果有键盘的话就需要另外考虑了。

业务上可能会对fitSystemWindows有更复杂的应用,很多时候是由于历史业务的原因导致大大小小的坑,这个时候就需要我们很好的把握fitSystemWindows,随机应变,自由适配WindowInsets了。

一个完整的封装

基于上述的种种讨论,我认为一个良好的封装应该提供三个方面的接口:全屏布局+ 状态栏透明(5.x半透明)、 更改状态栏颜色、 一个WindowInsetsLayout。

下面看一下QMUI(内部Android UI库)的实现:

/**
 * 沉浸式状态栏
 * 支持 4.4 以上版本的 MIUI 和 Flyme,以及 5.0 以上版本的其他 Android
 *
 * @param activity
 */
@TargetApi(19)
public static void translucent(Activity activity, @ColorInt int colorOn5x) {
    if(Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT){
        // 版本小于4.4,绝对不考虑沉浸式
        return;
    }
    // 小米和魅族4.4 以上版本支持沉浸式
    if (QMUIDeviceHelper.isMeizu() || QMUIDeviceHelper.isMIUI()) {
        Window window = activity.getWindow();
        window.setFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS,
                WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
        return;
    }

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
        Window window = activity.getWindow();
        window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                | View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && supportTransclentStatusBar6()) {
            // android 6以后可以改状态栏字体颜色,因此可以自行设置为透明
            // ZUK Z1是个另类,自家应用可以实现字体颜色变色,但没开放接口
            window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
            window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
            window.setStatusBarColor(Color.TRANSPARENT);
        } else {
            // android 5不能修改状态栏字体颜色,因此直接用FLAG_TRANSLUCENT_STATUS,nexus表现为半透明
            // update: 部分手机运用FLAG_TRANSLUCENT_STATUS时背景不是半透明而是没有背景了。。。。。
            // window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);

            // 采取setStatusBarColor的方式,部分机型不支持,那就纯黑了,保证状态栏图标可见
            window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
            window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
            window.setStatusBarColor(colorOn5x);
        }
//        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
//            // android4.4的默认是从上到下黑到透明,我们的背景是白色,很难看,因此只做魅族和小米的
//        } else if(Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN_MR1){
//            // 如果app 为白色,需要更改状态栏颜色,因此不能让19一下支持透明状态栏
//            Window window = activity.getWindow();
//            Integer transparentValue = getStatusBarAPITransparentValue(activity);
//            if(transparentValue != null) {
//                window.getDecorView().setSystemUiVisibility(transparentValue);
//            }
    }
}

然后是更改状态栏的颜色:

/**
 * 设置状态栏黑色字体图标,
 * 支持 4.4 以上版本 MIUI 和 Flyme,以及 6.0 以上版本的其他 Android
 *
 * @param activity 需要被处理的 Activity
 */
public static void setStatusBarLightMode(Activity activity) {
    if (mStatuBarType != STATUSBAR_TYPE_DEFAULT) {
        setStatusBarLightMode(activity, mStatuBarType);
        return;
    }
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
        if (MIUISetStatusBarLightMode(activity.getWindow(), true)) {
            mStatuBarType = 1;
        } else if (FlymeSetStatusBarLightMode(activity.getWindow(), true)) {
            mStatuBarType = 2;
        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            Window window = activity.getWindow();
            View decorView = window.getDecorView();
            int systemUi = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
            systemUi = changeStatusBarModeRetainFlag(window, systemUi);
            decorView.setSystemUiVisibility(systemUi);
            mStatuBarType = 3;
        }
    }
}

/**
 * 已知系统类型时,设置状态栏黑色字体图标。
 * 支持 4.4 以上版本 MIUI 和 Flyme,以及 6.0 以上版本的其他 Android
 *
 * @param activity 需要被处理的 Activity
 * @param type     StatusBar 类型,对应不同的系统
 */
private static void setStatusBarLightMode(Activity activity, @StatusBarType int type) {
    if (type == STATUSBAR_TYPE_MIUI) {
        MIUISetStatusBarLightMode(activity.getWindow(), true);
    } else if (type == STATUSBAR_TYPE_FLYME) {
        FlymeSetStatusBarLightMode(activity.getWindow(), true);
    } else if (type == STATUSBAR_TYPE_ANDROID6) {
        Window window = activity.getWindow();
        View decorView = window.getDecorView();
        int systemUi = View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR;
        systemUi = changeStatusBarModeRetainFlag(window, systemUi);
        decorView.setSystemUiVisibility(systemUi);
    }

}

/**
 * 设置状态栏白色字体图标
 * 支持 4.4 以上版本 MIUI 和 Flyme,以及 6.0 以上版本的其他 Android
 */
public static void setStatusBarDarkMode(Activity activity) {
    if (mStatuBarType == STATUSBAR_TYPE_DEFAULT) {
        // 默认状态,不需要处理
        return;
    }
    if (mStatuBarType == STATUSBAR_TYPE_MIUI) {
        MIUISetStatusBarLightMode(activity.getWindow(), false);
    } else if (mStatuBarType == STATUSBAR_TYPE_FLYME) {
        FlymeSetStatusBarLightMode(activity.getWindow(), false);
    } else if (mStatuBarType == STATUSBAR_TYPE_ANDROID6) {
        Window window = activity.getWindow();
        View decorView = window.getDecorView();
        int systemUi = View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
        systemUi = changeStatusBarModeRetainFlag(window, systemUi);
        decorView.setSystemUiVisibility(systemUi);
    }
}

最后是QMUIWindowInsetLayout,这个只是对XiNGRZ的代码作了一些小改动:

public class QMUIWindowInsetLayout extends FrameLayout {

    public QMUIWindowInsetLayout(Context context) {
        this(context, null);
    }

    public QMUIWindowInsetLayout(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public QMUIWindowInsetLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        ViewCompat.setOnApplyWindowInsetsListener(this,
                new android.support.v4.view.OnApplyWindowInsetsListener() {
                    @Override
                    public WindowInsetsCompat onApplyWindowInsets(View v,
                                                                  WindowInsetsCompat insets) {
                        return setWindowInsets(insets);
                    }
                });
    }

    private WindowInsetsCompat setWindowInsets(WindowInsetsCompat insets) {
        if (Build.VERSION.SDK_INT >= 21 && insets.hasSystemWindowInsets()) {
            if (applySystemWindowInsets21(insets)) {
                return insets.consumeSystemWindowInsets();
            }
        }
        return insets;
    }

    @SuppressWarnings("deprecation")
    @Override
    protected boolean fitSystemWindows(Rect insets) {
        if (Build.VERSION.SDK_INT >= 19 && Build.VERSION.SDK_INT < 21) {
            return applySystemWindowInsets19(insets);
        }
        return super.fitSystemWindows(insets);
    }


    @SuppressWarnings("deprecation")
    @TargetApi(19)
    private boolean applySystemWindowInsets19(Rect insets) {
        boolean consumed = false;

        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);


            Rect childInsets = new Rect(insets);

            computeInsetsWithGravity(child, childInsets);

            if (!child.getFitsSystemWindows()) {
                //如果不fitSystemWindow,则不处理top,兼容键盘
                child.setPadding(childInsets.left, 0, childInsets.right, childInsets.bottom);
                continue;
            }

            //如果fitSystemWindow,则处理top
            child.setPadding(0, childInsets.top, 0, 0);

            consumed = true;
        }

        return consumed;
    }

    @TargetApi(21)
    private boolean applySystemWindowInsets21(WindowInsetsCompat insets) {
        boolean consumed = false;

        for (int i = 0; i < getChildCount(); i++) {
            View child = getChildAt(i);

            Rect childInsets = new Rect(
                    insets.getSystemWindowInsetLeft(),
                    insets.getSystemWindowInsetTop(),
                    insets.getSystemWindowInsetRight(),
                    insets.getSystemWindowInsetBottom());

            computeInsetsWithGravity(child, childInsets);

            if (!child.getFitsSystemWindows()) {
                childInsets.top = 0;
                child.setFitsSystemWindows(true);
                ViewCompat.dispatchApplyWindowInsets(child, insets.replaceSystemWindowInsets(childInsets));
                child.setFitsSystemWindows(false);
                continue;
            }

            childInsets.left = 0;
            childInsets.right = 0;
            // 不要将 bottom 设置为 0,否则会导致键盘升起时 View 的高度没有变化,获取不到键盘高度,键盘也无法将界面往上顶
//            childInsets.bottom = 0;
            ViewCompat.dispatchApplyWindowInsets(child, insets.replaceSystemWindowInsets(childInsets));

            consumed = true;
        }

        return consumed;
    }

    @SuppressLint("RtlHardcoded")
    private void computeInsetsWithGravity(View view, Rect insets) {
        LayoutParams lp = (LayoutParams) view.getLayoutParams();

        int gravity = lp.gravity;

        /**
         * 因为该方法执行时机早于 FrameLayout.layoutChildren,
         * 而在 {FrameLayout#layoutChildren} 中当 gravity == -1 时会设置默认值为 Gravity.TOP | Gravity.LEFT,
         * 所以这里也要同样设置
         */
        if (gravity == -1) {
            gravity = Gravity.TOP | Gravity.LEFT;
        }

        if (lp.width != LayoutParams.MATCH_PARENT) {
            int horizontalGravity = gravity & Gravity.HORIZONTAL_GRAVITY_MASK;
            switch (horizontalGravity) {
                case Gravity.LEFT:
                    insets.right = 0;
                    break;
                case Gravity.RIGHT:
                    insets.left = 0;
                    break;
            }
        }

        if (lp.height != LayoutParams.MATCH_PARENT) {
            int verticalGravity = gravity & Gravity.VERTICAL_GRAVITY_MASK;
            switch (verticalGravity) {
                case Gravity.TOP:
                    insets.bottom = 0;
                    break;
                case Gravity.BOTTOM:
                    insets.top = 0;
                    break;
            }
        }
    }

}

目前这套方案用于微信读书,应该是相当稳定的方案了,使用较为灵活。

←支付宝← 赏 →微信 →