Compose 中的視窗插邊

Android 平台負責繪製系統 UI,例如狀態列和導覽列。無論使用者使用哪個應用程式,都會顯示此系統 UI。WindowInsets 會提供系統 UI 的相關資訊,確保應用程式會在正確區域繪製,且 UI 不會遭到系統 UI 遮蓋。

跨無邊框繪製在系統長條後方
圖 1 無邊框地在系統列後方繪製

根據預設,應用程式的 UI 僅限於在系統 UI 中配置,例如狀態列和導覽列。這樣可以確保應用程式的內容不會遭到系統 UI 元素遮蓋。

但是,建議應用程式選擇在也會顯示系統 UI 的區域顯示,這樣可以提供更順暢的使用者體驗,並能充分利用可用的視窗空間。這也允許應用程式與系統 UI 建立動畫,特別是在顯示及隱藏螢幕鍵盤時。

選擇在這些地區顯示,並在系統 UI 後方顯示內容稱為「無邊框」。在這個頁面中,您將瞭解不同類型的插邊、如何選擇採用無邊框設計,以及如何使用插邊 API 為 UI 建立動畫效果,並避免對應用程式的各個部分造成模糊處理。

插邊基礎知識

當應用程式採用無邊框設計時,您必須確保系統 UI 不會遮蓋重要內容和互動。舉例來說,如果按鈕放在導覽列後方,使用者可能就無法點選按鈕。

您可以透過插邊指定系統 UI 的大小和位置相關資訊。

系統 UI 的每個部分都有對應的插邊類型,用來說明其大小和位置。舉例來說,狀態列插邊會提供狀態列的大小和位置,而導覽列插邊則提供導覽列的大小和位置。每種插邊都含有四個像素尺寸:頂端、左側、右側和底部。這些維度會指定系統 UI 從應用程式視窗對應側邊延伸的距離。為避免與該類型的系統 UI 重疊,應用程式 UI 必須依照該大小插邊。

您可以透過 WindowInsets 取得下列內建 Android 插邊類型:

WindowInsets.statusBars

說明狀態列的插邊。這些是頂端系統 UI 列,內含通知圖示和其他指標。

WindowInsets.statusBarsIgnoringVisibility

顯示這些方塊的時間的狀態列插邊。如果狀態列目前為隱藏 (因為進入沉浸模式模式),主要狀態列插邊會顯示空白,但這些插邊不會空白。

WindowInsets.navigationBars

說明導覽列的插邊。這些方塊是裝置左側、右側或底部的系統 UI 列,用於描述工作列或導覽圖示。這些資訊可根據使用者偏好的瀏覽方式以及與工作列互動的方式,在執行階段進行變更。

WindowInsets.navigationBarsIgnoringVisibility

導覽列顯示的時機。如果導覽列目前為隱藏狀態 (因為進入沉浸式全螢幕模式),則主要導覽列插邊會是空白的,但這些插邊並非空白。

WindowInsets.captionBar

在任意形式視窗 (例如頂端標題列) 中,說明系統 UI 視窗裝飾的插邊。

WindowInsets.captionBarIgnoringVisibility

字幕列插邊表示顯示時機。如果字幕列目前為隱藏狀態,主要字幕列插邊會是空白的,但這些插邊不會空白。

WindowInsets.systemBars

系統列插邊的聯集,包括狀態列、導覽列和字幕列。

WindowInsets.systemBarsIgnoringVisibility

顯示這些項目的系統列插邊。如果系統資訊列目前為隱藏狀態 (因為進入沉浸式全螢幕模式),主要系統列插邊會是空白的,但這些插邊不會空白。

WindowInsets.ime

說明螢幕底部鍵盤佔用的空間。

WindowInsets.imeAnimationSource

這些插邊說明螢幕鍵盤在目前的鍵盤動畫之前佔用的空間。

WindowInsets.imeAnimationTarget

說明螢幕鍵盤在目前的鍵盤動畫「之後」佔用的空間。

WindowInsets.tappableElement

一種插邊類型,可針對導覽 UI 提供更詳細的相關資訊,並給予系統 (而非應用程式) 處理「輕觸」的空間大小。對於具有手勢操作的透明導覽列,部分應用程式元素可透過系統操作機制 UI 輕觸。

WindowInsets.tappableElementIgnoringVisibility

可輕觸的元素插邊 (顯示時)。如果系統目前在隱藏可輕觸的元素 (因為進入沉浸式全螢幕模式),則主要可輕觸的元素插邊會是空白的,但這些插邊並非空白。

WindowInsets.systemGestures

代表系統攔截導覽手勢的插邊數量。應用程式可以透過 Modifier.systemGestureExclusion 手動指定處理部分手勢。

WindowInsets.mandatorySystemGestures

部分系統手勢,一律由系統處理,但無法透過 Modifier.systemGestureExclusion 選擇停用。

WindowInsets.displayCutout

插邊代表所需的間距,以免與螢幕凹口 (凹口或針孔) 重疊。

WindowInsets.waterfall

代表瀑布顯示曲線區域的插邊。刊登序列螢幕會在螢幕邊緣顯示弧形區域,而裝置螢幕會開始沿著裝置兩側自動環繞。

這些類型總結了三種「安全」插邊類型,確保內容不會經過遮蔽:

這些「安全」插邊類型會根據基礎平台插邊,以不同方式保護內容:

插邊設定

如要讓應用程式完全控管繪製內容的位置,請按照下列設定步驟操作。如果沒有進行這些步驟,應用程式可能會在系統 UI 後方繪製黑色或純色,或與軟體鍵盤同步建立動畫。

  1. 呼叫 Activity.onCreate 中的 enableEdgeToEdge()。這項呼叫會要求應用程式在系統 UI 後方顯示。您的應用程式將控制這些插邊如何用於調整 UI。
  2. 在活動的 AndroidManifest.xml 項目中設定 android:windowSoftInputMode="adjustResize"。這項設定可讓應用程式透過插邊接收軟體輸入法編輯器的大小,方便您在應用程式中顯示及消失時,引導內容正確排放。

    <!-- in your AndroidManifest.xml file: -->
    <activity
      android:name=".ui.MainActivity"
      android:label="@string/app_name"
      android:windowSoftInputMode="adjustResize"
      android:theme="@style/Theme.MyApplication"
      android:exported="true">
    

Compose API

當 Activity 接管所有插邊後,您可以使用 Compose API,確保內容不會遭到遮蓋,且可互動的元素不會與系統 UI 重疊。這些 API 也會同步處理應用程式的版面配置與插邊變更。

舉例來說,這是將插邊套用至整個應用程式內容的最基本方法:

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

    enableEdgeToEdge()

    setContent {
        Box(Modifier.safeDrawingPadding()) {
            // the rest of the app
        }
    }
}

這個程式碼片段會將 safeDrawing 視窗插邊套用為應用程式整個內容的邊框間距。雖然這樣可以確保可互動元素不會與系統 UI 重疊,但也代表沒有任何應用程式能在系統 UI 後方繪製,從而實現無邊框效果。如要充分利用整個視窗,您需要微調插邊在個別畫面或元件中的套用位置。

所有這些插邊類型都會自動建立動畫,並將 IME 動畫向後移植至 API 21。更棒的是,當插邊值有所變更時,所有使用這些插邊的版面配置也會自動建立動畫效果。

使用這些插邊類型調整可組合項版面配置的主要方式有兩種:邊框間距修飾符和插邊大小修飾符。

邊框間距修飾符

Modifier.windowInsetsPadding(windowInsets: WindowInsets) 會套用指定的視窗插邊做為邊框間距,運作方式與 Modifier.padding 一樣。舉例來說,Modifier.windowInsetsPadding(WindowInsets.safeDrawing) 會在 4 個邊,將安全繪圖插邊套用做為邊框間距。

還有一些內建公用程式方法,適用於最常見的插邊類型。 Modifier.safeDrawingPadding() 是其中一種方法,相當於 Modifier.windowInsetsPadding(WindowInsets.safeDrawing)。其他插邊類型也有類似的修飾符。

插邊大小修飾符

以下修飾符可將元件大小設為插邊大小,藉此套用一定數量的視窗插邊:

Modifier.windowInsetsStartWidth(windowInsets: WindowInsets)

套用 windowInsets 的起始邊做為寬度 (例如 Modifier.width)

Modifier.windowInsetsEndWidth(windowInsets: WindowInsets)

套用 windowInsets 的邊長做為寬度 (例如 Modifier.width)

Modifier.windowInsetsTopHeight(windowInsets: WindowInsets)

套用 windowInsets 的頂端做為高度 (例如 Modifier.height)

Modifier.windowInsetsBottomHeight(windowInsets: WindowInsets)

套用 windowInsets 的底部做為高度 (例如 Modifier.height)

如要調整佔用插邊空間的 Spacer 大小,這些修飾符特別實用:

LazyColumn(
    Modifier.imePadding()
) {
    // Other content
    item {
        Spacer(
            Modifier.windowInsetsBottomHeight(
                WindowInsets.systemBars
            )
        )
    }
}

插邊用量

插邊邊框間距修飾符 (windowInsetsPaddingsafeDrawingPadding 等輔助程式) 會自動使用以邊框間距套用的插邊部分。隨著組合樹狀結構更深層,巢狀插邊的邊框間距修飾符和插邊大小修飾符知道插邊的邊框間距修飾符已使用了部分插邊,避免重複使用相同部分,這會造成過多的額外空間。

如果插邊大小已取用,插邊大小修飾符也會避免多次使用相同部分的插邊。不過,由於這些物件會直接變更大小,因此不會自行消耗插邊。

因此,為邊框間距修飾符建立巢狀結構時,系統會自動變更套用至每個可組合項的邊框間距量。

看看和之前一樣的LazyColumn範例,LazyColumn 都是由 imePadding 修飾符調整大小。在 LazyColumn 中,最後一個項目的大小是系統列底部的高度:

LazyColumn(
    Modifier.imePadding()
) {
    // Other content
    item {
        Spacer(
            Modifier.windowInsetsBottomHeight(
                WindowInsets.systemBars
            )
        )
    }
}

IME 關閉時,imePadding() 修飾符不會套用任何邊框間距,因為 IME 沒有高度。由於 imePadding() 修飾符並未套用任何邊框間距,因此不會使用任何插邊,而 Spacer 的高度會是系統列底部的大小。

輸入法編輯器開啟時,IME 插邊會根據輸入法編輯器的大小進行動畫處理,且 imePadding() 修飾符會開始套用底部邊框間距,在輸入法編輯器開啟時調整 LazyColumn 的大小。當 imePadding() 修飾符開始套用底部邊框間距時,也會開始耗用該數量的插邊。因此,由於 imePadding() 修飾符已套用系統列間距的一部分,因此 Spacer 的高度會開始減少。當 imePadding() 修飾符套用大於系統長條的底部邊框間距數量後,Spacer 的高度為零。

輸入法編輯器關閉後,變更就會反向發生:當 imePadding() 套用的時間低於系統列底部時,Spacer 就會開始從 0 高度展開,直到最後直到 IME 完全模擬後,Spacer 才與系統列底部的高度相符。

圖 2.使用 TextField 的邊緣到邊緣延遲欄

這個行為可透過所有 windowInsetsPadding 修飾符之間的通訊完成,可透過幾種其他方式影響。

Modifier.consumeWindowInsets(insets: WindowInsets) 也會使用與 Modifier.windowInsetsPadding 相同的方式使用插邊,但不會將使用的插邊做為邊框間距套用。這與插邊大小修飾符搭配時很實用,可向同層級表示已經使用一定數量的插邊:

Column(Modifier.verticalScroll(rememberScrollState())) {
    Spacer(Modifier.windowInsetsTopHeight(WindowInsets.systemBars))

    Column(
        Modifier.consumeWindowInsets(
            WindowInsets.systemBars.only(WindowInsetsSides.Vertical)
        )
    ) {
        // content
        Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
    }

    Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
}

Modifier.consumeWindowInsets(paddingValues: PaddingValues) 的行為與使用 WindowInsets 引數的版本非常類似,但需要任意使用的 PaddingValues 。如果是由其他機制 (例如一般 Modifier.padding 或固定高度空格字元) 提供其他機制,則當子項提供邊框間距或間距時,這就能通知子項:

@OptIn(ExperimentalLayoutApi::class)
Column(Modifier.padding(16.dp).consumeWindowInsets(PaddingValues(16.dp))) {
    // content
    Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}

如果需要使用原始視窗插邊而不消耗,請直接使用 WindowInsets 值,或使用 WindowInsets.asPaddingValues() 傳回不受消耗影響的插邊 PaddingValues。不過,基於以下注意事項,建議您盡可能使用視窗插邊邊框間距修飾符和視窗插邊大小修飾符。

插邊和 Jetpack Compose 階段

Compose 會使用基礎 AndroidX 核心 API 來更新插邊並製作動畫效果,插邊會使用基礎平台 API 管理插邊。基於這個平台的行為,插邊與 Jetpack Compose 階段有特殊關係。

插邊值會在組合階段「之後」更新,但在版面配置階段「之前」。這表示讀取組合中的插邊值通常會使用遲到一個影格的插邊值。本頁說明的內建修飾詞旨在延遲使用插邊的值,直到版面配置階段為止,確保插入值會在更新時的同一影格上使用。

使用 WindowInsets 的鍵盤輸入法編輯器動畫

您可以將 Modifier.imeNestedScroll() 套用至捲動容器,以便在捲動至容器底部時自動開啟及關閉 IME。

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

        WindowCompat.setDecorFitsSystemWindows(window, false)

        setContent {
            MaterialTheme {
                MyScreen()
            }
        }
    }
}

@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MyScreen() {
    Box {
        LazyColumn(
            modifier = Modifier
                .fillMaxSize() // fill the entire window
                .imePadding() // padding for the bottom for the IME
                .imeNestedScroll(), // scroll IME at the bottom
            content = { }
        )
        FloatingActionButton(
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(16.dp) // normal 16dp of padding for FABs
                .navigationBarsPadding() // padding for navigation bar
                .imePadding(), // padding for when IME appears
            onClick = { }
        ) {
            Icon(imageVector = Icons.Filled.Add, contentDescription = "Add")
        }
    }
}

動畫顯示的是上下捲動的 UI 元素,以便使用鍵盤

圖 1 IME 動畫

Material 3 元件的插邊支援

為方便使用,許多內建 Material 3 可組合項 (androidx.compose.material3) 會根據 Material Design 規格,根據可組合項在應用程式中的放置方式自行處理插邊。

插邊處理可組合項

以下是可自動處理插邊的 Material 元件清單。

應用程式列

內容容器

Scaffold

根據預設,Scaffold 會提供插邊做為參數 paddingValues 以使用和使用。Scaffold 不會將插邊套用至內容;您應自行負責。舉例來說,如要在 Scaffold 中使用 LazyColumn 使用這些插邊:

Scaffold { innerPadding ->
    // innerPadding contains inset information for you to use and apply
    LazyColumn(
        // consume insets as scaffold doesn't do it by default
        modifier = Modifier.consumeWindowInsets(innerPadding),
        contentPadding = innerPadding
    ) {
        items(count = 100) {
            Box(
                Modifier
                    .fillMaxWidth()
                    .height(50.dp)
                    .background(colors[it % colors.size])
            )
        }
    }
}

覆寫預設插邊

您可以變更傳遞至可組合項的 windowInsets 參數,設定可組合項的行為。這個參數可以改為套用不同類型的視窗插邊,也可以傳送空白執行個體以停用:WindowInsets(0, 0, 0, 0)

舉例來說,如要停用 LargeTopAppBar 的插邊處理功能,請將 windowInsets 參數設為空白例項:

LargeTopAppBar(
    windowInsets = WindowInsets(0, 0, 0, 0),
    title = {
        Text("Hi")
    }
)

與 View 系統插邊互通

如果畫面在同一個階層中同時具有 View 和 Compose 程式碼,您可能需要覆寫預設插邊。在這種情況下,您必須明確指出在哪些情況下應使用插邊,哪些應忽略插邊。

舉例來說,如果最外層版面配置是 Android View 版面配置,則應在 View 系統中使用插邊,並針對 Compose 忽略這些插邊。或者,如果最外層版面配置是可組合項,則應在 Compose 中使用插邊,並據此填充 AndroidView 可組合項。

根據預設,每個 ComposeView 都會耗用 WindowInsetsCompat 消耗層級的所有插邊。如要變更這項預設行為,請將 ComposeView.consumeWindowInsets 設為 false

資源

  • Now in Android - 完全以 Kotlin 和 Jetpack Compose 建構的 Android 應用程式,功能齊全。