Android 沉浸式狀態列的實現

揚州慢發表於2019-02-27

一提到沉浸式狀態列,第一個浮現在腦海裡的詞就是“碎片化”。碎片化是讓 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.

下篇部落格再見。

相關文章