Android沉浸式状态栏
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会传递给下列几个方法:
-
fitSystemWindows(Rect insets)
: 这个是老版本提供的接口,现在已经被弃用,仅用于API 19 -
onApplyWindowInsets(WindowInsets insets)
: 这应该是标准的方式了,然而在魅蓝M1上竟然会出现找不到WindowInsets这个类的crash - 使用
ViewCompat.setOnApplyWindowInsetsListener
添加的Listener: 这种setListener的方式比较灵活,并且传值是WindowInsetsCompat
类型,在魅蓝M1等机型都可以跑通,是上乘之选。
此外有几个关键点需要重点关注:
- 一旦有一个View消耗了WindowInsets,那么WindowInsets的dispatch就结束了。所以一般只在Activity的最外层View调用
setFitsSystemWindows(true)
- 系统处理WindowInsets的手段本质是设置padding,因此这会让你View原本的padding失效
- 一般而言,只有一个View消耗WindowInsets,但这是系统行为,我们可以在
onApplyWindowInsets
里主动调用dispatchApplyWindowInsets
使得其可以继续传递。
第三点的意义在于,如果我们需要多个View受WindowInsets影响时,我们可以自己去传递WindowInsets,一般封装者也会提供一个WindowInsetsLayout
, 让直接子元素的fitSystemWindows都生效。@XiNGRZ在Mantou Earth有一个很好的实现(点我查看)。使用这个Layout可以满足大部分需求,但也存在一个小漏洞:使用onApplyWindowInsets
在魅蓝M1上会crash(前文已经指出原因)
业务上可能会对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);
}
}
/**
* 每次设置SystemUiVisibility要保证其它必须的flag不能丢
*/
private static int changeStatusBarModeRetainFlag(Window window, int out) {
out = retainSystemUiFlag(window, out, View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN);
out = retainSystemUiFlag(window, out, View.SYSTEM_UI_FLAG_FULLSCREEN);
out = retainSystemUiFlag(window, out, View.SYSTEM_UI_FLAG_HIDE_NAVIGATION);
out = retainSystemUiFlag(window, out, View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY);
out = retainSystemUiFlag(window, out, View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN);
out = retainSystemUiFlag(window, out, View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION);
return out;
}
public static int retainSystemUiFlag(Window window, int out, int type) {
int now = window.getDecorView().getSystemUiVisibility();
if ((now & type) == type) {
out |= type;
}
return out;
}
最后是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);
if (!child.getFitsSystemWindows()) {
continue;
}
Rect childInsets = new Rect(insets);
computeInsetsWithGravity(child, childInsets);
child.setPadding(childInsets.left, childInsets.top, childInsets.right, childInsets.bottom);
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);
if (!child.getFitsSystemWindows()) {
continue;
}
Rect childInsets = new Rect(
insets.getSystemWindowInsetLeft(),
insets.getSystemWindowInsetTop(),
insets.getSystemWindowInsetRight(),
insets.getSystemWindowInsetBottom());
computeInsetsWithGravity(child, childInsets);
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;
}
}
}
}
目前这套方案用于微信读书,应该是相当稳定的方案了,使用较为灵活。
转载自:http://blog.cgsdream.org/2017/03/16/android-translcent-statusbar/
网友评论