1. 程式人生 > 其它 >Android 全面屏體驗

Android 全面屏體驗

一、概述

Android 應用中經常會有一些要求全屏顯隱狀態列導航欄的需求。通過全屏沉浸式的處理可以讓應用達到更好的顯示效果。在 Android 4.1 之前,只能隱藏狀態列, 在 Android4.1之後,Android 提供了一套控制 SystemUI的方式。Android P 增加了異形屏處理,應用需要對異形屏進行適配。Android Q 增加了全面屏手勢導航,應用還需要對全面屏手勢導航進行適配。 在 Android R 開始,Android 增加了 WindowInsetsController 來控制 Window 效果。

二、Android 4.4 之前隱藏狀態列

2.1 通過程式碼設定

針對 Activity 通過 WindowManager 標誌,動態設定,程式碼如下:

public class MainActivity extends Activity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // If the Android version is lower than Jellybean, use this call to hide
        // the status bar.
        if (Build.VERSION.SDK_INT < 16) {
            getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
                    WindowManager.LayoutParams.FLAG_FULLSCREEN);
        }
        setContentView(R.layout.activity_main);
    }
    ...
}

可以使用 FLAG_LAYOUT_IN_SCREEN 將 activity 佈局設定為使用相同的螢幕區域。當你啟用 FLAG_FILLSCREEN 時,可以使用相同的螢幕區域。這將防止在狀態列隱藏和顯示時調整內容的大小。

2.2 XML 配置

如果你要設定 activity 的狀態列一直處於隱藏狀態,那麼首選在 manifest 設定 style 的方式,實現如下:

<application
    ...
    android:theme="@android:style/Theme.Holo.NoActionBar.Fullscreen" >
    ...
</application>

使用 manifest 設定的方式有以下優點:

  • 比程式設計方式更加容易維護,更不容易出錯。
  • 會有一個更加平滑的過渡,因為在例項化activity之前系統就已經擁有了呈現UI所需的資訊。

Android 4.1 之前的全面屏體驗很糟,由於碎片化嚴重,有些機型可能不會呈現預期的效果。

三、Android 4.4 設定全面屏

Android 4.1之後通過 setSystemUiVisibility 來控制 SystemUI,所用到的 flag 如下:
控制SystemBar相關:

SYSTEM_UI_FLAG_FULLSCREEN
SYSTEM_UI_FLAG_HIDE_NAVIGATION
SYSTEM_UI_FLAG_LOW_PROFILE

佈局相關:

SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
SYSTEM_UI_FLAG_LAYOUT_STABLE

沉浸式粘性相關(Android4.4引入):

SYSTEM_UI_IMMERSIVE
SYSTEM_UI_IMMERSIVE_STICKY

3.1 控制 SystemBar

SYSTEM_UI_FLAG_FULLSCREEN

該屬性用來隱藏狀態列

View decorView = getWindow().getDecorView();
int uiOptions = View.SYSTEM_UI_FLAG_FULLSCREEN;
decorView.setSystemUiVisibility(uiOptions);
getSupportActionBar().hide();  // 將ActionBar隱藏掉

通過以上程式碼可以實現隱藏狀態列。為了顯示出全屏效果,同時將 ActionBar 隱藏掉。
僅設定 SYSTEM_UI_FLAG_FULLSCREEN 這一條屬性,顯示效果如下:

  1. 當滑動 system bar、點選 home 鍵 menu 鍵就會清除掉flag,狀態列會重新顯示出來。
  2. 並且佈局也會隨著狀態列的顯隱進行佈局調整進行重繪。

SYSTEM_UI_HIDE_NAVIGATION

該屬性是用來隱藏導航欄

View decorView2 = getWindow().getDecorView();
int uiOptions2 = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
        | View.SYSTEM_UI_FLAG_FULLSCREEN;
decorView2.setSystemUiVisibility(uiOptions2);
getSupportActionBar().hide();

這裡同時隱藏了狀態列和導航欄,效果如下:

  1. 與隱藏狀態列不同的是點選任意佈局中的任意位置都會導致導航欄導航欄重新顯示出來。
  2. 並且佈局也會隨著狀態列導航欄的顯隱進行佈局調整進行重繪。

SYSTEM_UI_LOW_PROFILE

這個屬性的能力是讓SystemBar在視覺上變得模糊,重要性變得更低一點。具體表現是狀態列圖示僅保留電量時間關鍵圖示,並且變暗。導航欄圖示變成三個點或者變暗。這個flag使用的很少。

View decorView7 = getWindow().getDecorView();
int uiOptions7 = View.SYSTEM_UI_FLAG_LOW_PROFILE;
decorView7.setSystemUiVisibility(uiOptions7);

3.2 佈局相關

在新的Android4.1以及之後新的SystemUI設定裡,僅單獨設定隱藏狀態列和導航欄的flag會導致佈局重繪,為了在顯隱狀態列和導航欄的時候保持佈局的穩定的顯示效果,就需要以下屬性了。

SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN

可以讓佈局延伸到狀態列的位置。在狀態列在隱藏和顯示之前切換的時候,佈局穩定的顯示在狀態列後面(如果顯示狀態列,則佈局在狀態列後面,隱藏狀態列佈局也不變)。

View decorView3 = getWindow().getDecorView();
int uiOptions3 = View.SYSTEM_UI_FLAG_FULLSCREEN
        | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
        | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
decorView3.setSystemUiVisibility(uiOptions3);
getSupportActionBar().hide();

以上程式碼顯示出來的效果和上一段程式碼相比,佈局延伸到了狀態列的位置:

  1. 當滑動systembar、點選home鍵menu鍵就會清除掉flag。狀態列會重新顯示出來。
  2. 佈局不會隨著狀態列的顯隱進行調整變化

SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION

可以讓佈局延伸到導航欄的位置。可以讓導航欄在隱藏和顯示之前切換的時候,佈局穩定的顯示在導航欄後面(如果顯示導航欄,則佈局在導航欄後面,隱藏導航欄也不變)。

View decorView4 = getWindow().getDecorView();
int uiOptions4 = View.SYSTEM_UI_FLAG_FULLSCREEN
        | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
        | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
        | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
        | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
decorView4.setSystemUiVisibility(uiOptions4);
getSupportActionBar().hide();

以上程式碼的顯示效果是狀態列和導航欄隱藏,佈局延伸到了狀態列和導航欄的位置:

  1. 點選任意佈局就會清除掉flag。狀態列導航欄會重新顯示出來。
  2. 佈局不會隨著狀態列導航欄的顯隱進行調整變化。

SYSTEM_UI_FLAG_LAYOUT_STABLE

該flag的作用是保持佈局穩定,避免在顯隱狀態列導航欄的時候發生佈局的變化。可以輔助以下 SYSTEM_UI_FLAG_LAYOUT_FULLSCREENSYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION 兩個flag的使用,讓應用保持全屏的情況下,佈局不隨狀態列導航欄顯隱發生變化。也可以不配合這兩個flag使用,也能達到保持佈局穩定的效果,不過不能實現全屏,會留出狀態列和導航欄的位置。

View decorView3 = getWindow().getDecorView();
int uiOptions3 = View.SYSTEM_UI_FLAG_FULLSCREEN
        | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
        | View.SYSTEM_UI_FLAG_LAYOUT_STABLE;
decorView3.setSystemUiVisibility(uiOptions3);
getSupportActionBar().hide();

以上程式碼顯示出來的效果和上一段程式碼相比,佈局延伸到了狀態列的位置:

  1. 當滑動systembar、點選home鍵menu鍵就會清除掉flag。狀態列會重新顯示出來。
  2. 佈局不會隨著狀態列的顯隱進行調整變化

在設定了SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN、SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION flag的情況,如果把狀態列導航欄顏色設定為透明,則會有透明的狀態列導航欄覆蓋在佈局上的效果。這也證明了即便佈局狀態列導航欄出來了,佈局也確實延伸到了狀態列導航欄的位置。

3.3 沉浸式粘性相關

以上flag的組合設定中一直存在一個問題(在點選Home鍵、menu鍵等操作會導致flag被清除,導航欄一點選介面就會導致flag被清除,效果消失的問題。)。其實我們大部分情況都希望效果能夠穩定的顯示,而不是在簡單操作之後就會消失掉。下面兩個屬性就是為這個問題工作的。
SYSTEM_UI_IMMERSIVE

在以上flag設定的基礎上設定該屬性,可以保證在點選home鍵、menu鍵時不會失去狀態。但是如果手動調出systembar的時候,設定的相關flag還是會被清除掉。

View decorView5 = getWindow().getDecorView();
int uiOptions5 = View.SYSTEM_UI_FLAG_FULLSCREEN
        | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
        | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
        | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
        | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
        | View.SYSTEM_UI_FLAG_IMMERSIVE;
decorView5.setSystemUiVisibility(uiOptions5);
getSupportActionBar().hide();

以上程式碼的顯示效果:

  1. 隱藏狀態列、導航欄。
  2. 佈局延伸到了狀態列、導航欄的位置。
  3. 佈局穩定顯示,不會因為狀態列的顯隱來調整佈局。
  4. 當手動調出狀態列導航欄的時候,flag才會被清除。

SYSTEM_UI_IMMERSIVE_STICKY

設定這個屬性後。當狀態列隱藏的時候,手動調出狀態列導航欄,顯示一會兒隨後就會隱藏掉。設定該屬性後不會清除flag,該屬性是比較常用的一種。但是離開頁面肯定是會導致flag被清除掉的,以上所有flag設定都會有這種情況。

View decorView6 = getWindow().getDecorView();
int uiOptions6 = View.SYSTEM_UI_FLAG_FULLSCREEN
        | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
        | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
        | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
        | View.SYSTEM_UI_FLAG_LAYOUT_STABLE
        | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
decorView6.setSystemUiVisibility(uiOptions6);
getSupportActionBar().hide();

以上程式碼的顯示效果:

  1. 隱藏狀態列、導航欄
  2. 佈局延伸到了狀態列、導航欄的位置。
  3. 佈局穩定顯示,不會因為狀態列的顯隱來調整佈局。
  4. 手動調出的狀態列導航欄會半透明顯示覆蓋在介面上,隨後還會隱藏掉。
  5. 如果離開頁面還是會導致flag被清除,效果消失。

3.4 全面屏說明

3.4.1 flag 被清除問題

為實現全面屏顯示效果,設定不同的 flag 控制window, 這些 flag 是可能被清除的。
想要主動清除flag,也可以直接呼叫setSystemUiVisibility(0);

為了解決 flag 被清除的問題,重設的位置可以放在onWindowFocusChanged中。常見的做法是在 onWindowFocusChanged 回撥中重新設定。

class MainActivity : AppCompatActivity() {
    ...

    override fun onWindowFocusChanged(hasFocus: Boolean) {
        if (hasFocus) {
            window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                    or View.SYSTEM_UI_FLAG_FULLSCREEN
                    or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                    or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
                    or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
                    or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
        }
    }
    ...
}

3.4.2 全面屏體驗

真正需要隱藏狀態列和導航欄的場景其實不多,遊戲、視訊等應用可能需要,大多數時候我們可能只需要將檢視內容延伸到狀態列和導航欄後面(是否真的有必要?),以達到更具視覺衝擊力的體驗。
這時候,我們需要將狀態列和導航欄顏色設定為透明。Android Api 21 提供了相應介面,示例程式碼如下:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    window.statusBarColor = Color.TRANSPARENT
    window.navigationBarColor = Color.TRANSPARENT
}

另外是否真的有必要將檢視內容延伸到狀態列和導航欄後面,以及可能產生的互動衝突該如何解決?
由於 Android 10 支援全面屏手勢導航,由於導航欄自身的大小和突出程度已經非常小了,一般建議把內容延伸到導航欄後面。
Android 9 及以下的裝置,導航欄後繪製內容是可選的,根據情況酌情選擇。

而在狀態列後面繪製內容,完全取決於開發者,如果內容是一張背景圖片或者地圖類應用等等,則可以將檢視內容延伸到狀態列後面,如果UI頁面頂部是一個 Toolbar, 明顯就不太合適了。

關於 Android 10 上的全面屏體驗,第五節將做詳細介紹。

3.4.3 令人困惑的 fitsSystemWindows 屬性

根據官方文件,如果某個View 的fitsSystemWindows 設為true,那麼該View的padding屬性將由系統設定,使用者在佈局檔案中設定的
padding會被忽略。系統會為該View設定一個paddingTop,值為statusbar的高度。fitsSystemWindows預設為false。

重要說明

  1. 只有將檢視內容延伸到狀態列或者導航欄後面(設定View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN 或者 View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION flag)時,fitsSystemWindows才會起作用。不然 systemBar 的空間輪不到使用者處理,這時會由ContentView的父控制元件處理,如果用HierarchyView 工具檢視,將會看到,ContentView 的父控制元件的paddingTop 或者 paddingBottom 將會被設定。
  2. 如果多個view同時設定了fitsSystemWindows,只有第一個會起作用。

四、Android P 異形屏適配

Android的異形屏,包括劉海屏,水滴屏、挖孔屏,從Android 9.0 (API Level 28)開始Android官方也出了劉海屏的適配支援,下面是官方對異形屏的適配方案。

4.1 官方對異形屏裝置的要求

為了確保一致性和應用相容性,官方對搭載 Android 9 的裝置有以下要求:

  • 一條邊緣最多隻能包含一個劉海。
  • 一臺裝置不能有兩個以上的劉海。
  • 裝置的兩條較長邊緣上不能有劉海。
  • 在未設定特殊標誌的豎屏模式下,狀態列的高度必須至少與劉海的高度持平。
  • 預設情況下,在全屏模式或橫屏模式下,整個劉海區域必須顯示黑邊。

4.2 不隱藏系統狀態列的情形

如果應用所有介面均不隱藏狀態列,也就是應用不與系統狀態列重疊,那麼就無需處理異形屏適配,系統狀態列會自動調整佔據了異形切口的位置。
在豎屏和橫屏情況下是如下效果:

4.3 隱藏系統狀態列的情形

4.3.1 配置應用如何處理異形切口區域

隱藏了系統狀態列,意味著應用的內容將擴充到系統狀態列原有的位置,系統提供了控制是否在異形切口區域顯示內容的配置,該配置是 Window 級別的屬性。屬性名為 layoutInDisplayCutoutMode, 它有三個可選值,分別是:

  • WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT
    這是預設行為。官方說明是在豎屏模式下,內容會呈現到劉海區域中;但在橫屏模式下,內容會顯示黑邊。
  • WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
    在豎屏模式和橫屏模式下,內容都會呈現到劉海區域中。
  • WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER
    內容從不呈現到劉海區域中。

該屬性配置方式有兩種:

1.通過xml配置,通過主題樣式檔案配置:
在主題樣式檔案中通過 android:windowLayoutInDisplayCutoutMode 定義,示例程式碼如下:

<style name="Theme.DisplayCutoutDemo" parent="...">
    ...
    <item name="android.windowLayoutInDisplayCutoutMode">shortEdges</item>
</style>

2.通過程式碼定義

window.decorView.systemUiVisibility = (
                View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
                or View.SYSTEM_UI_FLAG_FULLSCREEN  // 隱藏狀態列
                or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION  // 隱藏導航欄

                or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN  // 允許檢視內容延伸到狀態列區域
                or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION  // 允許檢視延伸到導航欄區域
                or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                )

    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            val lp = window.attributes
            lp.layoutInDisplayCutoutMode =
                WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
            window.attributes = lp
        }

因為 Android 系統不允許檢視內容跟系狀態列和導航欄區域重疊,要讓檢視內容強制延伸至系統狀態列和導航欄區域,需對 systemUiVisibility 做相應的設定。

4.3.2 異形切口區域的檢視適配

如果將檢視內容擴充套件到了異形切口,異形切口會遮擋部分內容。如果遮擋的部分不影響體驗,則不需要處理。但是如果遮擋的部分是文字內容,或者有使用者可互動的操作,則需要進行適配。具體適配的方式就是獲取異形切口的位置和大小,進行處理。系統提供介面 WindowInsets.getDisplayCutout() 來獲取 DisplayCutout 物件,該物件秒速了螢幕中所有切口位置和大小。

如何獲取 DisplayCutout 物件
獲取 DisplayCutout 物件,首先要獲取 WindowInsets 物件。 WindowInsets 物件並沒有直接獲取方法,只能通過監聽或者回調方法獲取,獲取 WindowInsets 物件有兩種方法:

  1. 在自定義View內部重寫 View.onApplyWindowInsets(WindowInsets) 方法獲取
class MyView(context: Context): View(context) {
    override fun onApplyWindowInsets(insets: WindowInsets?): WindowInsets {
        return super.onApplyWindowInsets(insets)
    }
}

注意事項:如果是重寫View.onApplyWindowInsets(WindowInsets)方法獲取,請確保 WindowInsets 在之前沒有被消耗,沒有給 View 的父級或者 View 設定 View.OnApplyWindowInsetsListener 監聽。

  1. 通過給 View 設定 View.onApplyWindowInsetsListener 監聽獲取
window.decorView.apply{
    window.decorView.apply {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
                setOnApplyWindowInsetsListener { v, insets ->
                    insets
                }
            }
        }
}

如何獲取螢幕異形切口區域
有了 WindowInsets 物件,我們可以通過 WindowInsets.getDisplayCutout() 獲取切口相關資訊。
在 API Level 28 中,該物件只能支援獲取螢幕的安全區域,通過以下方法獲取各邊安全間距:

WindowInsets.getSafeInsetBottom()
WindowInsets.getSafeInsetLeft()
WindowInsets.getSafeInsetRight()
WindowInsets.getSafeInsetTop()

在 API Level 29 開始,還支援獲取具體的切口位置和大小資訊,方法如下:

WindowInsets.getBoundingRectBottom()
WindowInsets.getBoundingRectLeft()
WindowInsets.getBoundingRectRight()
WindowInsets.getBoundingRectTop() 

獲取各邊的切口資訊,知道切口資訊,可以更加精準地控制顯示。

從 API 21 開始,可以通過 WindowInsets 這個物件獲取狀態列和導航欄的高度,這些介面分別是

 WindowInsets.getStableInsetBottom() 
 WindowInsets.getStableInsetLeft()
 WindowInsets.getStableInsetRight()
 WindowInsets.getStableInsetTop()

在 API Level 30 開始這幾個介面被廢棄,使用下面方法替代:

WindowInsets.getInsetsIgnoringVisibility (int typeMask)

用這種方法獲取的時候,即使狀態列和導航欄處於隱藏狀態,也不影響獲取。

示例

window.decorView.apply {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT_WATCH) {
        setOnApplyWindowInsetsListener { v, insets ->
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
                insets.displayCutout?.apply {
                    binding.tvTest.setPadding(safeInsetLeft, safeInsetTop, safeInsetRight, safeInsetBottom)
                }
            }
            insets
        }
    }
}

適配前後效果圖如下:

4.3.3 異形屏適配的注意事項

  1. 不要讓異形切口區域遮蓋任何重要的文字、控制元件或其他資訊。
  2. 不要將任何需要精細輕觸識別的互動式元素放置或延伸到異形切口區域,異形切口區域中的輕觸靈敏度可能要比其他區域低一些。
  3. 避免對狀態列高度進行硬編碼,否則可能會導致內容重疊或被切斷。
  4. 如果的應用需要進入全屏模式,使用shortEdges 模式,退出全屏是,使用 never 模式。
  5. 在全屏模式下,在使用視窗座標與螢幕座標時需要注意,因為在顯示黑邊的情況下,視窗不會佔據整個螢幕。因此根據螢幕原點(螢幕左上角)得到的座標與根據視窗原點(視窗左上角)得到的座標不再相同。可以根據需要使用 View.getLocationOnScreen() 介面將螢幕座標轉換為檢視座標。在處理 MotionEvent 時,應當使用 MotionEvent.getX()MotionEvent.getY() 來避免類似的座標問題。不要使用 MotionEvent.getRawX()MotionEvent.getRawY()

五、Android Q 全面屏及手勢導航

Android 10 中添加了新的系統導航模式,使用者可以通過手勢互動執行後退、返回至主屏以及開啟裝置助手等操作。通過使用手勢互動來執行系統導航,應用可以使用到更多的螢幕空間。這有助於為使用者打造更加沉浸的體驗。

5.1 Android 10 的全面屏

在 Android 10 的實現全面屏,分為三步:
1. 請求全屏佈局
第三節我們說過 Android 10 上建議將檢視內容延伸到導航欄,這裡我們主要看導航欄。這部分內容和第三節一致,我們只需要使用檢視的 setSystemUiVisibility() 方法,主要關注:

view.systemUiVisibility = 
    // Tells the system that the window wishes the content to
    // be laid out at the most extreme scenario. See the docs for more information on the specifics
    View.SYSTEM_UI_FLAG_LAYOUT_STABLE or
    // Tells the system that the window wishes the content to be laid out as if the navigation bar was hidden
    View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION

這裡不再贅述。

效果如下:

2. 更改系統欄顏色
Android 10 上,我們只需要將系統欄顏色設定為完全透明即可:
通過 XML 設定如下:

<style name="Theme.MyApp">
   <item name="android:navigationBarColor">
       @android:color/transparent
   </item>

   <!-- Optional, if drawing behind the status bar too -->
   <item name="android:statusBarColor">
       @android:color/transparent
   </item>
</style>

或者通過程式碼設定

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    window.statusBarColor = Color.TRANSPARENT
    window.navigationBarColor = Color.TRANSPARENT
}

此時系統會執行兩個操作:

  1. 動態顏色適配
    系統欄裡的內容會根據其後面的內容改變顏色。如果拖拽條位於淺色內容前方,它將變為深色,在深色內容前方時則變為淺色。
  2. 半透明遮蓋
    當應用宣告 targetSdkVersion 為 29 時,系統也可以在系統欄後面放置一層半透明遮蓋。 SDK 28 或更低版本,系統不會顯示遮蓋,而是提供透明的導航欄。
    當然不同的裝置廠商可能會根據情況禁用動態顏色適配。比如手機系統性能不足以支援動態色彩適配。

在 Android 9 或者更早的版本則需要自己給導航欄設定一個半透明遮罩。示例程式碼:

<!-- values/themes.xml -->
<style name="Theme.MyApp">
    <item name="android:navigationBarColor">
        #B3FFFFFF
    </item>
</style>

<!-- values-night/themes.xml -->
<style name="Theme.MyApp">
    <item name="android:navigationBarColor">
        #B3000000
    </item>
</style>

5.2 邊襯區(Insets)

前面提到,將檢視內容延伸到系統欄後面之後,內容可能會有重疊,造成視覺衝突,如何處理這種視覺衝突?
先熟悉一個術語——邊襯區(insets),Insets 區域負責描述螢幕的哪些部分會與系統 UI 相交 (intersect),例如導航或狀態列。如果應用的控制元件出現在了這些區域內,就可能被系統 UI 遮蓋。我們可以使用 insets 區域來嘗試解決視覺衝突,如把檢視從螢幕邊緣向內移動到一個合適的位置。

在 Android 上,Insets 區域由 WindowInsets 類表示,在 AndroidX 中則使用 WindowInsetsCompat。在 Android 10 系統中處理應用佈局時,開發者需要知曉 5 個獲取 insets 區域的方法。

5.2.1 系統視窗邊襯區

方法: getSystemWindowInsets()

系統視窗區域是最常用到的。自 API 1 以來,它們就以各種形式存在著,並且每當系統 UI 重疊顯示在您的應用上方時,這個方法就會被呼叫。常見的例子是下拉狀態列和導航欄,或者彈出螢幕軟鍵盤 (IME)。

示例:
xml程式碼:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#008B8B"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/btn_test"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="button test"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

我們讓系統欄顏色透明,並且檢視內容延伸到系統欄後面,java 程式碼:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    window.statusBarColor = Color.TRANSPARENT
    window.navigationBarColor = Color.TRANSPARENT
}

window.decorView.systemUiVisibility = (
        View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
        or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
        or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
        )

該佈局,呈現的效果如下:

現在我們的 button 被遮擋住了,點選 button ,很可能會觸發 recent task, 視覺和互動上都出現了衝突,解決方案當然是把我們的 button 向上移動動一段距離,給它的父容器設定一個 padding 。但是要注意,使用者有可能設定虛擬按鍵導航或者成全屏手勢導航,這個 padding 不能硬編碼,應該是通過 insets 動態獲取的導航欄的高度。與前面講異形屏處理一樣。insets 物件無法直接獲取,只能通過回撥的方式。

ViewCompat.setOnApplyWindowInsetsListener(binding.btnTest) { v, insets ->
    v.updatePadding(bottom = insets.systemWindowInsetBottom)
    insets
}

效果如下:

簡而言之,系統視窗區域 insets 最適合那些需要點選的控制元件,可以確保系統欄不遮蓋住它們。

5.2.2 可點選區域

方法:getTappableElementInsets()

接下來是 Android 10 中新增的可點選區域 insets。它們與上面的系統視窗區域 insets 非常相似。可點選區域 insets 用來界定可觸發系統點選行為 (tap) 的最小區域。注意,使用可點選區域裡的數值進行佈局時,依然可能導致自己的控制元件與系統 UI 在視覺上重疊,這一點與系統視窗區域 insets 不同,使用後者的值對自己的控制元件進行位移後能確保不會與系統/導航欄發生視覺重疊。
實測在虛擬三鍵導航情況下,區別不太明顯,但在全屏手勢導航下,區別可見:
處理系統視窗區域 insets,

ViewCompat.setOnApplyWindowInsetsListener(binding.btnTest) { v, insets ->
    v.updatePadding(bottom = insets.systemWindowInsetBottom)
    insets
}

處理可點選區域 insets,

ViewCompat.setOnApplyWindowInsetsListener(binding.btnTest) { v, insets ->
    v.updatePadding(bottom = insets.tappableElementInsets.bottom)
    insets
}

效果圖分別如下:

從實用的角度出發,建議使用系統視窗區域 insets,它可以更好地滿足幾乎所有需要使用可點選區域 insets 的用例。

5.2.3 系統手勢邊襯區

方法: getSystemGestureInsets() & getMandatorySystemGestureInsets()

這是在 Android 10 中新增的, Android 10 全屏手勢導航, 允許使用者通過手勢動作:

  1. 從螢幕左/右邊緣向中間滑動,相當於後退按鈕 (Back)。
  2. 從螢幕底部開始向上滑動,可以讓使用者切換最近使用的應用 (Recent)。

在系統手勢區域中,系統手勢操作優先於應用自己的手勢操作。系統手勢區域有兩個獲取方法。其中 getMandatorySystemGestureInsets() 只包含強制性系統手勢區域,是系統手勢區域的子集。

在 Android 10 上,系統手勢區域如下:

如果有需要滑動操作的控制元件出現在了系統手勢區域內,就可以使用對應的數值來將這些控制元件挪開。常見的例子包括底部導航選單、遊戲裡的滑動互動、ViewPager 等。
強制系統手勢邊襯區是系統手勢邊襯區的子集,之所以稱之為 "強制區域",是因為應用無法修改這些區域。強制系統手勢邊襯區只包含那些系統保留的區域,在這些區域內系統手勢操作永遠優先。在 Android 10 上,當前唯一的強制區域是螢幕底部的主屏手勢區域,系統保留這個區域就可以讓使用者在任何時候都可以退出當前應用。

5.2.4 穩定顯示邊襯區

方法: getStableInsets()

這個方法與手勢導航關係不大, 和系統視窗邊襯區類似,穩定顯示區域是系統 UI 可能在您的應用上顯示的位置。在有些顯示模式下 (比如放鬆模式和沉浸模式),系統 UI 可能會根據情況在可見與不可見之間切換 (如遊戲、照片瀏覽、視訊播放器等)。這時使用穩定顯示區域就可以確保自己的控制元件不會被 "突然出現" 的系統 UI 擋住。

5.3 處理邊襯區衝突

處理邊襯區衝突,其實在上一節已經講到,主要是利用 windowInsets 來獲取邊襯區域,將有衝突的控制元件移出邊襯區域。

前面講異形屏適配的時候講到, WindowInsets 物件並沒有直接獲取方法,只能通過監聽或者回調方法獲取,獲取 WindowInsets 物件。
比如,我們想給某個控制元件增加一些邊距,讓它不被導航欄遮擋:

ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
    v.updatePadding(bottom = insets.systemWindowInsets.bottom)
    // Return the insets so that they keep going down the view hierarchy
    insets
}

我們僅將系統視窗區域的底部邊距值賦給了控制元件的底邊距。

注意: 如果您要在 ViewGroup 上執行此操作,則可能要對其進行設定 android:clipToPadding="false"。這是因為預設情況下,所有檢視都會在填充區域內裁剪圖形。

5.4 使用 Jetpack

使用 insets 時,建議始終用 Jetpack 中的 WindowInsetsCompat 類,它可以相容低版本 SDK。 Compat 版本在所有 API 級別都提供的了一致的 API 和行為。

注意 API 的一些區別:

view.setOnApplyWindowInsetsListener { v, insets ->
    ...
    insets
}
ViewCompat.setOnApplyWindowInsetsListener(view) { v, insets ->
    ...
    insets
}

5.5 處理手勢衝突

5.5.1. 無需處理

如果檢視控制元件,與手勢導航不存在衝突,則可以不處理。

5.5.2 將該檢視/控制元件移出手勢互動區域

這是我們前面提到的解決方案。視情況而定。

5.5.3 使用手勢區域排除 API

"應用可以從系統手勢區域中切出一部分用來響應自己的手勢互動"。這就是 Android 10 中新引入的手勢區域排除 API。

應用可以通過 Android 10 中新增的系統手勢區域排除 API 來讓系統邊緣的一部分割槽域不響應系統手勢。系統提供了兩種不同的功能來 "切出" 互動區域: View.setSystemGestureExclusionRects()Window.setSystemGestureExclusionRects()。使用哪種取決於應用: 如使用的是 Android View,則建議首選 View API,否則請使用 Window API。

這兩個 API 之間的主要區別在於,Window API 會以視窗 (Window) 座標系計算矩形。如果使用的是 View API,則會以檢視的座標系進行操作。View API 會幫助解決座標空間之間換算的問題。

示例
一個自定義View,手勢在上面滑動會繪製對應的軌跡。
xml 檔案如下:

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#008B8B"
    tools:context=".MainActivity">

    <com.lee.windowinsetdemo.MyView
        android:layout_width="match_parent"
        android:layout_height="400dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        android:background="#B0B0B0"
        />

</androidx.constraintlayout.widget.ConstraintLayout>

Activity 程式碼如下:

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        binding = ActivityMainBinding.inflate(layoutInflater)

        setContentView(binding.root)

        window.decorView.systemUiVisibility = (
                View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                        or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
                        or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                )

    }
}

此時執行起來應該是這樣的

下面問題是,如果我在自定義 View 上從螢幕邊緣向中間畫的時候畫不上,會觸發系統的返回。我們需要將系統手勢導航區域排除掉我們需要繪畫的區域。建立一個類似於下面的方法,該方法會在 onLayout() 和/或 onDraw() 時被呼叫。

private val gestureExclusionRects = mutableListOf<Rect>()

private fun updateGestureExclusion() {
    // Skip this call if we're not running on Android 10+
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        val rootWindowInsets = rootWindowInsets ?: return
        val gestureInsets = rootWindowInsets.systemGestureInsets

        gestureExclusionRects.clear()
        // Add an exclusion rect for the left gesture edge
        gestureExclusionRects += Rect(0, 0, gestureInsets.left, height)
        // Add an exclusion rect for the right gesture edge
        gestureExclusionRects += Rect(width - gestureInsets.right, 0, width, height)

        ViewCompat.setSystemGestureExclusionRects(this, gestureExclusionRects)
    }
}

override fun onDraw(canvas: Canvas?) {
        super.onDraw(canvas)
        updateGestureExclusion()
        ...
    }

這是會發現,自定義 View 下半部分可以由邊緣向螢幕中間滑動繪製了,上半部分卻依然響應系統的返回操作。此處似乎有玄機?

手勢區域排除的限制
手勢區域排除 API 看起來是解決所有手勢衝突的完美方案,但實際上並非如此。由於這個 API 會一定程度上破壞使用者習慣的操作,因此係統做出了限制: 螢幕的每個邊緣最多隻能被應用切除 200dp。

為什麼要有限制?
手勢區域排除使得應用的手勢比 "返回" 等系統操作更重要。如果一個應用能夠讓螢幕的整個邊緣都不響應系統手勢,就會讓使用者感到困惑,這個應用也極有可能被使用者解除安裝。開發者需要儘量確保使用者使用一致的操作來與系統進行互動,如從邊緣向內滑動進行返回。注意是在整個裝置上,而不僅僅是在一個應用中保持一致性。

系統導航必須始終保持一致性和可用性。

為什麼是200dp?
手勢區域排除 API 只有在萬不得已的情況下才可以使用,Google 開發人員計算了可能需要應用這套機制的觸控物件的面積。觸控物件的最小推薦尺寸是 48dp。取 4個觸控物件,即 4 × 48dp = 192dp。再加入一點富餘量,即為 200dp。
1. 如果開發者要求在邊緣上切出 200dp 以上的區域,系統只會兌現您的要求中位於最下方的 200dp。
2. 如果檢視不在螢幕內,系統僅計算螢幕範圍內的切出矩形。如果檢視只有一部分顯示在螢幕內,則僅計算所請求矩形的螢幕內可見部分。

示例程式碼中,我有意將自定義View 的高度設定為 400dp, 這也就是為什麼剛好下半部分系統手勢區域排除生效,上半部分不生效的原因。那麼問題又來了,我就是要整個整個View都可以繪畫,這隻給下半部分響應從邊緣向內的繪製,上半部分不能繪製,這算哪樣?答案請看下一節。

5.5.3 沉浸模式下使用手勢區域排除 API

前面提到沉浸模式是一種讓內容全屏呈現的方式,用來隱藏系統欄,從而確保應用擁有最大的螢幕空間。此外,它還提供了防誤操作的功能 (比如意外使用手勢離開應用),特別適合在遊戲中採用

- 沉浸模式分為兩種:

1. 非粘性沉浸模式: 使用者可以通過在系統欄上滑動來退出沉浸模式。

2. 粘性沉浸模式: 使用者可以通過在系統欄上滑動來暫時退出沉浸模式。在經過一小段時間後 (只有幾秒) 會重新自動回到沉浸模式。

- 這兩種模式都有兩種狀態:

1. 系統欄隱藏: 在此狀態下,返回主螢幕手勢和後退手勢均被禁用。使用者必須首先從邊緣向內側滑動才能讓系統欄顯示。

2. 系統欄顯示: 在此狀態下,返回主螢幕手勢和後退手勢可以正常工作。

- 沉浸模式的粘性與非粘性
非粘性 (non-sticky) 沉浸模式非常適合需要全屏顯示但不需要在螢幕邊緣附近使用精確滑動手勢的 UI。常見的例子包括全屏視訊播放和照片瀏覽等。就手勢導航而言,在此模式下,無論系統欄是否可見,每個邊緣能排除的區域高度仍舊限制為 200dp。

粘性 (sticky) 沉浸模式適合那些強烈需要使用整個螢幕,並要求在整個螢幕區域內進行觸控輸入的 UI。常見的例子是繪圖應用,以及使用滑動操作的遊戲。
1. 使用粘性沉浸模式的應用會有很強的互動性,因此手勢區域排除 API 的限制會被移除,但僅限於系統欄隱藏的時候。這意味著應用可以根據需要完全佔用螢幕左 / 右邊緣。
2. 在系統欄可見時,系統則會忽略所有排除的手勢區域,讓使用者可以返回,而不會受到來自應用的干擾。在粘性沉浸模式下,系統欄僅在短時間內可見,因此不會影響應用的正常互動。

螢幕底部的主屏手勢區域依舊會正常存在,是無法排除的 "強制" 手勢區域。處於粘性沉浸模式的應用可能會佔用兩個垂直邊緣的整個長度,因此螢幕底部的主手勢區域可能是使用者撥出系統欄並退出應用的唯一方法。

我們改進之前的示例,我們需要知道檢視當前是否處於沉浸模式之中:

override fun onDraw(canvas: Canvas?) {
    super.onDraw(canvas)
    updateGestureExclusion()
    ...
}

override fun onWindowSystemUiVisibilityChanged(visibility: Int) {
    super.onWindowSystemUiVisibilityChanged(visibility)

    // Update our gesture exclusions rects if we’re
    // running on Android 10+
    if (Build.VERSION.SDK_INT >= 29) {
        updateGestureExclusionRects()
    }
}

private fun updateGestureExclusion() {
    if ((windowSystemUiVisibility and SYSTEM_UI_FLAG_HIDE_NAVIGATION) != 0) {
        // Root window insets are null, which happens if this is called
        // before we're attached and laid out. Ignore the call for now.
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            val rootWindowInsets = rootWindowInsets ?: return
            val gestureInsets = rootWindowInsets.systemGestureInsets

            gestureExclusionRects.clear()
            // Add an exclusion rect for the left gesture edge
            gestureExclusionRects += Rect(0, 0, gestureInsets.left, height)
            // Add an exclusion rect for the right gesture edge
            gestureExclusionRects += Rect(width - gestureInsets.right, 0, width, height)

            ViewCompat.setSystemGestureExclusionRects(this, gestureExclusionRects)
        }
    } else {
        // If the navigation bar is showing, we don't want to exclude any edges.
        ViewCompat.setSystemGestureExclusionRects(this, emptyList())
    }
}

override fun onWindowSystemUiVisibilityChanged(visible: Int) {
        super.onWindowSystemUiVisibilityChanged(visible)
        if (Build.VERSION.SDK_INT >= 29) {
            updateGestureExclusion()
        }
    }

六、Android R 上新 API

如果你的應用適配 R 及以上版本,在使用 setSystemUiVisibility() 方法時,會提示你,該方法過時了,對應前面講到的 flag 也都標記為過時。Android官方在api30之後提供 WindowInsetsController,用於控制 window 的控制類,實現 window 控制的簡單化。
先上程式碼:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
    window.decorView.windowInsetsController?.hide(WindowInsets.Type.statusBars())
    window.decorView.windowInsetsController?.hide(WindowInsets.Type.navigationBars())
    window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
    window.setDecorFitsSystemWindows(false)
}

上面程式碼什麼意思?
其實,windowInsetsController 用對應的 show()hide() 方法,來對 statusbar 和 navigationBar 進行控制,看程式碼一眼就能看明白是什麼意思。比之前版本的 flag 更為直觀。

隱藏狀態列和導航欄:

// window.decorView.windowInsetsController?.hide(WindowInsets.Type.systemBars())
window.decorView.windowInsetsController?.hide(WindowInsets.Type.statusBars())
window.decorView.windowInsetsController?.hide(WindowInsets.Type.navigationBars())

佈局穩定,不隨狀態列和導航欄的顯示隱藏變化
floating windows:

// WindowManager.LayoutParams.setFitInsetsTypes(WindowInsets.Type.systemBars())
WindowManager.LayoutParams.setFitInsetsTypes(WindowInsets.Type.statusBars())
WindowManager.LayoutParams.setFitInsetsTypes(WindowInsets.Type.navigationBars())

non-floating windows:

window.setDecorFitsSystemWindows(false)

這裡要注意,懸浮窗和非懸浮窗是有區別的。

粘性與非粘性

// 非粘性
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_DEFAULT

// 粘性
window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE 

為了實現全面屏:

override fun onWindowFocusChanged(hasFocus: Boolean) {
    if (hasFocus) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            // window.decorView.windowInsetsController?.hide(WindowInsets.Type.statusBars())
            // window.decorView.windowInsetsController?.hide(WindowInsets.Type.navigationBars())
            window.decorView.windowInsetsController?.hide(WindowInsets.Type.systemBars())
            window.decorView.windowInsetsController?.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
            window.setDecorFitsSystemWindows(false)
        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                    or View.SYSTEM_UI_FLAG_FULLSCREEN
                    or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                    or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
                    or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
                    or View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY)
        } else {
            // 4.1 以下處理
            ...
        }
    }
}
作者:SharpCJ     作者部落格:http://joy99.cnblogs.com/     本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連線。