Android Compose的Window Insets

Android Compose的Window Insets

除了app的內容區域外, 還有一些其他的固定元素會顯示在手機屏幕上, 頂部的狀態欄, 劉海, 底部的導航欄, 還有輸入法鍵盤,
它們都是系統的UI, 也叫Insets.

如圖所示:

insets introduction

頂部的狀態欄通常被用來展示通知, 設備狀態等;
底部導航欄通常顯示三個導航按鈕: back, home, recent.
它們兩個合稱爲system bars.

Android的Insets類描述的是偏移尺寸信息, 確實我們開發中更關注的也就是這些系統UI的尺寸信息.

本文介紹用Compose做UI之後, 藉助於Accompanist Insets: https://google.github.io/accompanist/insets/.
幾種常見的和Insets相關的情形是如何做的.

內容區域

Going Edge-to-Edge

新創建一個用Compose寫的app, 默認是一個沒有Inset處理的普通App.

那能不能讓app的內容顯示在這些system bars區域, 做成edge-to-edge的形式?
當然是可以的.

這裏澄清兩個概念:

  • edge-to-edge: app的內容在system bars後面繪製, system bars仍然以半透明的形式存在.
  • 不同於"沉浸式"(immersive mode), 沉浸式需要將system bars隱藏, app內容完全全屏, 多用於看視頻, 畫畫等場景.

內容區域延伸到system bars

內容延伸到status bar和navigation bar區域很容易, 只需要加一行代碼:

WindowCompat.setDecorFitsSystemWindows(window, false)

這個值默認是true, 表示默認行爲: app的內容會自動找到內嵌區域繪製.
設置爲false之後, app的內容就會延伸到system bars下層.

區別見下圖: 左邊爲默認顯示, 右邊爲添加了這個flag爲false的設置之後的情況:

going edge-to-edge

嗯, 內容是繪製出去了, 但是卻被遮擋了.

這時候就需要用到systemuicontroller來改顏色:
加上這麼幾行就可以改自己喜歡的顏色:

val systemUiController = rememberSystemUiController()
val useDarkIcons = MaterialTheme.colors.isLight

SideEffect {
    systemUiController.setSystemBarsColor(
        color = Color.Green.copy(alpha = 0.1f),
        darkIcons = useDarkIcons
    )
}

這裏改的是system bars, 也即status bar和navigation bar都改了. 也有單獨只改一個的方法.
爲了demo, 把顏色設置成透明的綠(如左圖);
正常應用場景有可能得用Color.Transparent(如右圖).

system bar color

延伸卻內嵌

緊接做了幾個頁面的UI之後, 發現有的內容遮蓋在狀態欄和底部, 體驗不是很好.
能不能把有文字內容的部分讓出來呢?

於是, 添加了這個依賴: Insets for Jetpack Compose

簡單兩行就把上下的距離留了出來:

ProvideWindowInsets {
    Sample1(modifier = Modifier.systemBarsPadding())
}

等等, 這麼一處理, 如果忽略system bars顏色的設置.
和最開始默認的情形看起來是一模一樣.

那麼我們是不是可以直接刪掉WindowCompat.setDecorFitsSystemWindows(window, false)這行, 用默認設置就好了?

  • 是. 如果你的需求真的是這樣.
  • 不是. 如果你需要把app背景繪製出去; 如果你還有輸入框的處理.

如果需求想要的是背景延伸出去, 文字內嵌.
分別給上下兩個元素加了不同的padding:

Column(
        modifier = modifier.fillMaxSize()
                .background(color = Color.Blue.copy(alpha = 0.3f)),
        verticalArrangement = Arrangement.SpaceBetween
) {
    Text(
            modifier = Modifier.fillMaxWidth()
                    .background(color = Color.Yellow.copy(alpha = 0.5f))
                    .statusBarsPadding(),
            text = "Top Text",
            style = MaterialTheme.typography.h2
    )
    Text(text = "Content", style = MaterialTheme.typography.h2)
    Text(
            modifier = Modifier.fillMaxWidth()
                    .navigationBarsPadding()
                    .background(color = Color.Yellow.copy(alpha = 0.5f)),
            text = "Bottom Text",
            style = MaterialTheme.typography.h2
    )
}

運行以後如下圖中右邊所示:

add insets padding

注意這裏modifier的順序, 上下延伸出去的顏色是不同的, 下面延伸出去的其實是Column的顏色.

左邊是把insets padding加在整體佈局的情況, 如果用的是system bars的話, 和默認UI效果是一樣的.

具體根據需求定製即可.

LazyColumn的padding和content padding

有一個非常長的LazyColumn, 在edge-to-edge的設計下應該怎麼顯示呢?
這裏有三種選擇:

  1. List完全全屏: LazyColumn {}
  2. List留出上下padding: LazyColumn(modifier = Modifier.systemBarsPadding()) {}
  3. List留出Content padding:
LazyColumn(
    contentPadding = rememberInsetsPaddingValues(
        insets = LocalWindowInsets.current.systemBars,
        applyTop = true,
        applyBottom = true,
    )
) {}

其實1和2的行爲非常類似, 只是顯示區域大小的區別.
content padding只是在第一個item的上面和最後一個item的下面加上padding,
在滾動的中間過程中內容是可以全屏的, 只有到頭或者到底了纔會顯示出padding.

LazyColumn padding

content padding用動圖更能說明情況:

Content padding

內容區域處理總結

Insets這個庫提供了這麼幾個Modifier:

  • Modifier.statusBarsPadding()
  • Modifier.navigationBarsPadding()
  • Modifier.systemBarsPadding()
  • Modifier.imePadding()
  • Modifier.navigationBarsWithImePadding()
  • Modifier.cutoutPadding()
    可以直接在佈局中用上, 就獲取了應該有的padding, 比如statusBarPadding是top, navigationBarsPadding是bottom.
    這都不用開發者自己想.

如果這些都不滿足你的需求, 也可以直接用尺寸:

  • Modifier.statusBarsHeight()
  • Modifier.navigationBarsHeight()
  • Modifier.navigationBarsWidth()
    或者更直接地用LocalWindowInsets.current自己獲取想要inset類型的相關尺寸.

輸入框元素和鍵盤

on-screen keyboard, 又叫IME (Input Method Editor),
一般點擊輸入框會彈出, IME也是一種Inset.

輸入框被鍵盤遮擋問題

當輸入框處於屏幕上半屏的時候, 基本不用考慮鍵盤遮擋的問題.
但是當輸入框在屏幕下半屏, 我們需要在鍵盤彈出來的時候讓輸入框完全顯示出來而不被蓋住.

解決這個問題需要這麼幾個東西:

  • Activity的android:windowSoftInputMode="adjustResize", 表示在鍵盤彈出時, Activity會改變佈局大小, 這種改變是擠壓型的.
  • Modifier.imePadding的使用, 給佈局加上一個恰好等於鍵盤高度的bottom padding. 通常是給輸入框的父佈局, 加在哪一層視情況而定.
  • 如果上面兩個都設置了仍然不能把輸入框完全顯示出來, 可能需要再加入點強力的喚醒行爲.

根據這個issue下的這條comment,
可以用這個Modifier, 在這個ui獲取到焦點的時候, 自己把自己bring into view.

@ExperimentalComposeUiApi
fun Modifier.bringIntoViewAfterImeAnimation(): Modifier = composed {
    val imeInsets = LocalWindowInsets.current.ime
    var focusState by remember { mutableStateOf<FocusState?>(null) }
    val relocationRequester = remember { RelocationRequester() }

    LaunchedEffect(
        imeInsets.isVisible,
        imeInsets.animationInProgress,
        focusState,
        relocationRequester
    ) {
        if (imeInsets.isVisible &&
            !imeInsets.animationInProgress &&
            focusState?.isFocused == true) {
            relocationRequester.bringIntoView()
        }
    }

    relocationRequester(relocationRequester)
        .onFocusChanged { focusState = it }
}

這個ReloactionRequest已經deprecated了, Compose新版的叫BringIntoViewRequester.

IME padding計算和佈置

.imePadding()的值是變化的, 在沒有鍵盤的情況下是0, 等有鍵盤的時候變爲鍵盤高度.

計算鍵盤彈出的高度要注意:

  • 最簡單的情況直接用.imePadding()完事, 佈局的bottom padding自動和IME貼合.
  • 如果整體已經有了navigation bar的高度, 可以考慮用.navigationBarsWithImePadding(), 它是取IME和navigation bar高度的最大值.
  • 如果鍵盤上方出現了白條, 說明padding算多了, 要麼是佈局中已經有inner padding, 要麼就是已經加過navigationBarsPadding. 這時候可以自己做一個減法處理.
    比如這個:
LazyColumn(
    contentPadding = PaddingValues(
        bottom = with(LocalDensity.current) {
            LocalWindowInsets.current.ime.bottom.toDp() - innerPadding.bottom
        }.coerceAtLeast(0.dp)
    )
) { /* ... */ }

.imePadding放在哪裏, 關係到什麼樣的區域會被顯示出來, 被包裹的區域會顯示在鍵盤上方.

來舉個例子, 有個帶輸入框的界面.

我們給它整體設置一個.navigationBarsWithImePadding(), 表示沒鍵盤的時候, 底部留navigation bar的高度, 有鍵盤的時候留鍵盤的高度:

Column(
    modifier = Modifier.fillMaxSize().statusBarsPadding().navigationBarsWithImePadding()
        .background(color = Color.Cyan.copy(alpha = 0.2f)),
    verticalArrangement = Arrangement.SpaceBetween
) {
    Text(
        modifier = Modifier.fillMaxWidth()
            .background(color = Color.Yellow.copy(alpha = 0.5f)),
        text = "Top Text",
        style = MaterialTheme.typography.h2
    )
    Text(text = "Content", style = MaterialTheme.typography.h2)
    MyTextField("Text Field 1")
    MyTextField("Text Field 2")
    Text(
        modifier = Modifier.fillMaxWidth()
            .background(color = Color.Yellow.copy(alpha = 0.5f)),
        text = "Bottom Text",
        style = MaterialTheme.typography.h2
    )
}

鍵盤彈出時, Bottom Text也會被頂上去, 這是因爲imePadding作用於整塊的佈局.

如果我們這樣改, 只包裹輸入框的部分, 那麼鍵盤就不會把底部的UI頂上去:

    Column(
        modifier = Modifier.fillMaxSize().statusBarsPadding()
            .background(color = Color.Cyan.copy(alpha = 0.2f)),
        verticalArrangement = Arrangement.SpaceBetween
    ) {
        Text(
            modifier = Modifier.fillMaxWidth()
                .background(color = Color.Yellow.copy(alpha = 0.5f)),
            text = "Top Text",
            style = MaterialTheme.typography.h2
        )
        Text(text = "Content", style = MaterialTheme.typography.h2)

        Text(
            modifier = Modifier.fillMaxWidth()
                .background(color = Color.Yellow.copy(alpha = 0.5f)),
            text = "Bottom Text",
            style = MaterialTheme.typography.h2
        )
    }

兩種效果見圖:
IME handling

鍵盤部分總結延伸

總結: 輸入框鍵盤的處理包括了:

  • adjustResize.
  • 設置合理的bottom padding: 在哪裏設置, 需要設置多少.
  • 讓View主動bring自己到可見位置.

Insets庫裏還提供了鍵盤隨着滾動消失和出現的例子. 感興趣可以看下.

accompanist insets使用總結

accompanist insets庫幫我們做了兩部分內容:

  • 獲取各種insets信息然後用CompositionLocalProvider提供.
  • Provider內部, 通過Modifier獲取直接可用的modifier或者尺寸, 也可以直接獲取.

但是這個庫用起來也有一些需要注意的地方, 比如:

  • 如果忘記設置WindowCompat.setDecorFitsSystemWindows(window, false), 得到的值都是0.
  • ProvideWindowInsets的參數: consumeWindowInsets這個值默認是true, 建議設置爲false, 方便內層的ui繼續用這些inset的值.
@Composable
fun ProvideWindowInsets(
    consumeWindowInsets: Boolean = true,
    windowInsetsAnimationsEnabled: Boolean = true,
    content: @Composable () -> Unit
)
  • 如果在佈局中嵌套使用ProvideWindowInsets, 可能就無法按照預期工作, (不知道是不是暫時性的issue).

References

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章