Android Compose的Window Insets
- 2022 年 1 月 29 日
- 筆記
- Android, compose, Jetpack Compose
Android Compose的Window Insets
除了app的內容區域外, 還有一些其他的固定元素會顯示在手機屏幕上, 頂部的狀態欄, 劉海, 底部的導航欄, 還有輸入法鍵盤,
它們都是系統的UI, 也叫Insets.
如圖所示:
頂部的狀態欄通常被用來展示通知, 設備狀態等;
底部導航欄通常顯示三個導航按鈕: back, home, recent.
它們兩個合稱為system bars.
Android的Insets類描述的是偏移尺寸信息, 確實我們開發中更關注的也就是這些系統UI的尺寸信息.
本文介紹用Compose做UI之後, 藉助於Accompanist Insets: //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的設置之後的情況:
嗯, 內容是繪製出去了, 但是卻被遮擋了.
這時候就需要用到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
(如右圖).
延伸卻內嵌
緊接做了幾個頁面的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
)
}
運行以後如下圖中右邊所示:
注意這裡modifier的順序, 上下延伸出去的顏色是不同的, 下面延伸出去的其實是Column的顏色.
左邊是把insets padding加在整體布局的情況, 如果用的是system bars的話, 和默認UI效果是一樣的.
具體根據需求定製即可.
LazyColumn的padding和content padding
有一個非常長的LazyColumn, 在edge-to-edge的設計下應該怎麼顯示呢?
這裡有三種選擇:
- List完全全屏:
LazyColumn {}
- List留出上下padding:
LazyColumn(modifier = Modifier.systemBarsPadding()) {}
- List留出Content padding:
LazyColumn(
contentPadding = rememberInsetsPaddingValues(
insets = LocalWindowInsets.current.systemBars,
applyTop = true,
applyBottom = true,
)
) {}
其實1和2的行為非常類似, 只是顯示區域大小的區別.
content padding只是在第一個item的上面和最後一個item的下面加上padding,
在滾動的中間過程中內容是可以全屏的, 只有到頭或者到底了才會顯示出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
)
}
兩種效果見圖:
鍵盤部分總結延伸
總結: 輸入框鍵盤的處理包括了:
- 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
- Lay out your app within window insets
- Accompanist Insets: //google.github.io/accompanist/insets/
- Sample: //github.com/google/accompanist/tree/main/sample/src/main/java/com/google/accompanist/sample/insets
- Video: Animating your keyboard using WindowInsets