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_VISIBLE
和View.STATUS_BAR_HIDDEN
两个Flag用于控制Status Bar的显示与隐藏。
在Android4.0中,View.STATUS_BAR_VISIBLE
改为View.SYSTEM_UI_FLAG_VISIBLE
,View.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_FULLSCREEN
和SYSTEM_UI_FLAG_HIDE_NAVIGATION
的修补。前文已经说过,在使用这两个flag后,用户的某些行为会使得系统强制清除这些flag。这并不是用户想要的,因此配合View.SYSTEM_UI_FLAG_IMMERSIVE
和View.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_STABLE
和SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
会被自动添加FLAG_TRANSLUCENT_NAVIGATION
:当使用这这个个flag时SYSTEM_UI_FLAG_LAYOUT_STABLE
和SYSTEM_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; } } } }
目前这套方案用于微信读书,应该是相当稳定的方案了,使用较为灵活。