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/