Insets in Jetpack Compose 🖼️
什么是 Insets?
Insets 这个单词,直译过来是“插入物”、“插图”、“插页”的意思。在 Android 开发里面,Insets 通常指的是视图(View)的内边距或插入区域,亦可称作边衬区(几个中文叫法,没一个是让人好理解的 😅...),你可以简单地将 Insets 理解为视图的“内边距”或“边缘留白”。它是为了描述在视图的边界内插入的空白区域,确保内容与边界之间有一定的距离,从而提高用户界面的美观性和可用性。
在本篇文章,我们主要关注 Window Insets,这些是与窗口相关的内边距,例如:系统状态栏、导航栏和键盘等区域。
边衬区设置
默认情况下,Activity 的内容不会被绘制到状态栏或导航栏区域,因为系统会将 content 做一定的偏移调整。
我们可以调用 WindowCompat.setDecorFitsSystemWindows(window, false) 来告诉系统:请你不要自作主张偏移调整我的 content,你把 WindowInsets 的信息告诉我就行了,我会自行处理。
// WindowCompat.java
public final class WindowCompat {
public static void setDecorFitsSystemWindows(
Window window,
boolean decorFitsSystemWindows
)
}
注意别忘了将状态栏和导航栏的颜色设置为透明,不然用户看见的仍然只是一团黑白。
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ WindowCompat.setDecorFitsSystemWindows(window, false)
+ window.statusBarColor = Color.TRANSPARENT
+ window.navigationBarColor = Color.TRANSPARENT
setContent {
Box(modifier = Modifier.background(Yellow).fillMaxSize())
}
}
📢 好消息是,Jetpack 库已经封装了一个 API:enableEdgeToEdge(),只需一行代码就能实现上面的效果,而且对各个系统版本做了兼容处理(比如导航栏遮罩),还会根据主题模式切换状态栏图标的颜色,所以更推荐使用 👍
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- WindowCompat.setDecorFitsSystemWindows(window, false)
- window.statusBarColor = Color.TRANSPARENT
- window.navigationBarColor = Color.TRANSPARENT
+ enableEdgeToEdge()
setContent {
Box(modifier = Modifier.background(Yellow).fillMaxSize())
}
}
注意需要引入:
implementation("androidx.activity:activity:<latest-version>")
或
implementation("androidx.activity:activity-ktx:<latest-version>")
另外,为了让 Window 将 IME 输入法的区域视为 Window Insets 的一部分,需要将 Activity 的 windowSoftInputMode 设置为 adjustResize:
<!-- AndroidManifest.xml -->
<activity
+ android:windowSoftInputMode="adjustResize"
</activity>
Compose Insets APIs
最初,系统会以一种粗暴的方式帮我们处理 Window Insets:直接不让 Activity 在系统栏区域内绘制内容。但我们为了追求更完美的视觉体验,要求系统别这么干,所以处理 Window Insets 这项任务就得我们开发者自己干了。
将内容绘制到系统栏后,一个不可避免的场景是,我们不希望某些重要内容被遮挡,或者可交互组件与系统界面重叠,比如下面的代码,左上角和右下角各自放置了一个按钮,现在它们被绘制在系统栏背后,非常影响交互体验😵:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
Box(modifier = Modifier.background(Yellow).fillMaxSize()) {
Button(
modifier = Modifier.align(Alignment.TopStart),
content = { Text(text = "Button 1") },
onClick = {}
)
Button(
modifier = Modifier.align(Alignment.BottomEnd),
content = { Text(text = "Button 2") },
onClick = {}
)
}
}
}
Insets Padding Modifiers
为避免这种情况,我们需要获取 Window Insets 的信息(上下左右分别占了多少空间),然后对相关元素的位置或边距进行调整,使其不被系统 UI 遮挡:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
Box(modifier = Modifier.background(Yellow).fillMaxSize()) {
Button(
modifier = Modifier
.align(Alignment.TopStart)
+ .windowInsetsPadding(WindowInsets.statusBars),
content = { Text(text = "Button 1") },
)
Button(
modifier = Modifier
.align(Alignment.BottomEnd)
+ .windowInsetsPadding(WindowInsets.navigationBars),
content = { Text(text = "Button 1") },
)
}
}
}
windowInsetsPadding() 修饰符类似于 padding(),但参数不是 Dp,而是 WindowInsets。它会将给定的 WindowInsets 作为边距应用到组件上。
fun Modifier.windowInsetsPadding(insets: WindowInsets): Modifier
关于 WindowInsets,其实前面已经提过了,它就是窗口的内边距(边缘留白),包含了上下左右 4 块矩形区域:
查看 WindowInsets 的源码,发现它有 4 个方法分别获取 left、right、top、bottom,返回的其实就是左右留白区域的宽,和上下留白区域的高:
interface WindowInsets {
fun getLeft(density: Density, layoutDirection: LayoutDirection): Int
fun getTop(density: Density): Int
fun getRight(density: Density, layoutDirection: LayoutDirection): Int
fun getBottom(density: Density): Int
}
上面例子里,我们接触到了两种类型的 WindowInsets,分别是 WindowInsets.statusBars 和 WindowInsets.navigationBars。
WindowInsets.statusBars 仅包含上贴边的状态栏,那么 top 就是状态栏高度,而左右以及底部的 3 块区域都为空,所以 left、right、bottom 都为 0。当我们将 WindowInsets.statusBars 传递给 windowInsetsPadding() 时,自然就会为组件添加一定的上边距。WindowInsets.navigationBars 同理,不再赘述。
一个 WindowInsets 可以由多个 WindowInsets 相加得到。有一种类型 WindowInsets 叫 WindowInsets.systemBars,它包含两块留白区域:状态栏和导航栏。
准确来说,system bars 包含 status bars、navigation bars 和 caption bar 三部分,但是 caption bar 在一般的 Android 开发中用不到,它是指电脑窗口标题栏部分,通常包含应用程序的标题和窗口控制按钮(如最小化、最大化、关闭按钮),在 ChromeBook 设备上才会接触到,这里我们暂时忽略它。
那么对于前面的问题,我们可以换一种思路,不给按钮添加外边距,而是给 Box 容器添加内边距,这样也能达到同样的效果:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
Box(
modifier = Modifier
.background(Yellow)
+ .windowInsetsPadding(WindowInsets.systembars)
.fillMaxSize()
) {
Button(
modifier = Modifier
.align(Alignment.TopStart)
.windowInsetsPadding(WindowInsets.statusBars),
content = { Text(text = "Button 1") }
)
Button(
modifier = Modifier
.align(Alignment.BottomEnd)
.windowInsetsPadding(WindowInsets.navigationBars),
content = { Text(text = "Button 2") }
)
}
}
}
其实下面 3 种写法的效果都是一样的:
Modifier.windowInsetsPadding(WindowInsets.systembars)
// 便捷写法
Modiffier.systemBarsPadding()
// 手动相加状态栏和导航栏两种 WindowInsets
Modifier.windowInsetsPadding(WindowInsets.statusBars.add(WindowInsets.navigationBars))
我们用 windowInsetsPadding(WindowInsets.systembars) 给 Box 添加了内边距,但是前面给按钮添加外边距的代码并没有删掉,神奇的是运行结果居然没问题!... 🤔
Inset Size Modifiers & Inset Consumption
聪明的你肯定已经猜到了些什么,这是因为不同的 windowInsetsPadding() 修饰符之间会利用 ModifierLocal 进行通信 🕊️。外层的 windowInsetsPadding() 修饰符将某块 WindowInsets 区域消耗后,内层的 windowInsetsPadding() 修饰符能感知到,不会重复消费同一块 WindowInsets 区域。
要深入了解
windowInsetsPadding()修饰符之间如何进行通信,可以翻阅源码 👈
我们再来看一个例子:
LazyColumn(
modifier = Modifier
.windowInsetsPadding(WindowInsets.ime) // 也可以写成 imePadding()
.fillMaxSize()
) {
item {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.systemBars))
}
items(20) { i ->
TextField(value = i.toString(), onValueChange = {})
}
item {
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
}
}
我稍微解释一下这段代码,首先用 Spacer 在列表的头尾分别添加一定的边缘留白,目的是让列表首尾的内容不要嵌入到状态栏和导航栏里,为了指定他俩的高度,这里使用了 windowInsetsTopHeight() 和 windowInsetsBottomHeight() 修饰符,从名字就可以看出其作用,它们会将给定的 WindowInsets 的 top 或 bottom 大小作为组件的高度。
其次,因为列表的每一项都是输入框,在展开输入法时,我们希望键盘能够把列表顶起来,不然的话键盘会遮住列表的最后几项,为此,我们将 WindowInsets.ime 作为外边距应用到 LazyColumn 上。也就是说键盘展开时,列表会添加上相应的底部外边距,自然就被顶起来了。
在输入法收起时,拉到列表最后,发现最后一个输入框确实没有被导航栏遮挡,证明尾部的 Spacer 确实起作用了。然后展开输入法,重新将列表拉到最后,发现最后一个输入框是紧贴在输入法上方的 🤔🤔...... 底部的 Spacer 消失了!
IME(输入法)收起时,windowInsetsPadding(WindowInsets.ime) 没有应用任何边距,不会消耗任何边衬区的空间;底部的 Spacer 的高度被指定为 WindowInsets.systemBars 的 bottom,与导航栏等高。
我们点击输入框,IME 逐渐展开,windowInsetsPadding(WindowInsets.ime) 开始消耗边衬区的空间,LazyColumn 的外边距逐渐增大,由于导航栏和输入法类型的边衬区有重叠的部分,所以外部 LazyColumn 的 windowInsetsPadding(WindowInsets.ime) 使用了多少边衬区,内部 Spacer 的 windowInsetsBottomHeight(WindowInsets.systemBars) 会相应地减少它所占据的边衬区,直至为 0。同理,IME 收起过程则相反,不再赘述。
windowInsetsPadding() 和 windowInsetsBottomHeight() 这种互相配合的行为就是利用 ModifierLocal 进行通信而完成的。
需要注意的是,下面 4 种 Inset Size 修饰符的行为和 Insets Padding 修饰符有一定的差异:Insets Padding 修饰符会将自己使用边衬区的信息往内层传递,其他的 Inset Size 或 Insets Padding 修饰符收到信息后,就不会再重复消耗同一块区域了。但是,Inset Size 修饰符使用边衬区,它是不会告诉(传递)给内层的。简单来说,Insets Padding 修饰符既发信也收信,但 Inset Size 修饰符只收信不发信。
| Inset Size 修饰符 |
|---|
windowInsetsStartWidth() |
windowInsetsEndWidth() |
windowInsetsTopHeight() |
windowInsetsBottomHeight() |
这可能会引发一些问题,来看下面的例子:
Column(
Modifier
.verticalScroll(rememberScrollState())
.fillMaxSize()
) {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.systemBars))
Column {
repeat(20) { i -> TextField(value = i.toString(), onValueChange = {}) }
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
}
首先,代码有两层 Column,外层 Column 里面已经用 Inset Size 修饰符处理了头尾部的边衬区了,目的是让中间内容的首尾不要嵌入到状态栏和导航栏里。内层的 Column 有 20 个输入框,为了在输入法展开时,也能滑到最后那个输入框,所以在最后一个输入框后面添加了 Spacer,将其高度设置为输入法占据的边衬区 bottom 大小。
运行,展开输入法,往下滑动到最后,问题来了......
外层的 windowInsetsBottomHeight() 虽然使用了边衬区,但它不会往内部传递信息:“啊这块地方我用啦,你们别白费心思了。” 所以内层的 windowInsetsBottomHeight() 在展开输入法的时候,原封不动地将 Spacer 高度设置为输入法占据的边衬区 bottom 大小,实际上应该减去导航栏高度才对,因为外层已经使用了这部分空间,但是没人告诉它。
要解决这个问题也很简单,使用 consumeWindowInsets() 修饰符,就可以像 Insets Padding 修饰符一样宣誓主权:这块 WindowInsets 的区域我要了!📢
Modifier.consumeWindowInsets(insets: WindowInsets): Modifier
Column(
Modifier
+ .consumeWindowInsets(WindowInsets.systemBars.only(WindowInsetsSides.Vertical))
.verticalScroll(rememberScrollState())
.fillMaxSize()
) {
Spacer(Modifier.windowInsetsTopHeight(WindowInsets.systemBars))
Column {
repeat(20) { i -> TextField(value = i.toString(), onValueChange = {}) }
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}
Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
}
上面我们将 WindowInsets.systemBars.only(WindowInsetsSides.Vertical) 传递给 consumeWindowInsets() 修饰符,意思是只消耗了 WindowInsets.systemBars 的上下两块区域。
顺便提一嘴,
consumeWindowInsets()修饰符还有另一个版本,接收 PaddingValues 作为参数:Modifier.consumeWindowInsets(paddingValues: PaddingValues)顺便提二嘴,
WindowInsets有一个拓展方法asPaddingValues():WindowInsets.asPaddingValues(): PaddingValues
键盘动画
Compose 中有一个 imeNestedScroll() 修饰符,我们可以将其应用于滚动容器,它的作用是:在 Android 11 及以上版本系统中,允许我们以嵌套滑动的形式拖动键盘(当我们滚动到容器的底部,再继续往下时,就会弹出键盘;而如果键盘处于展开状态,这时只要向下滚动容器,键盘就会收起。)
LazyColumn(
Modifier
.imePadding() // padding for the bottom for the IME
.imeNestedScroll() // 📌 scroll IME at the bottom
.fillMaxSize()
) {
item {
Spacer(modifier = Modifier.windowInsetsTopHeight(WindowInsets.systemBars))
}
items(colors) { color -> /* content */ }
item {
OutlinedTextField(value = "", onValueChange = {})
}
item {
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
}
}
Material 3 组件对 Insets 的支持
Material 3 库中的大多数组件已经默认适配了对边衬区的处理,比如 TopAppBar,在一个 Box 左上角放置 TopAppBar,即使不使用 windowInsetsPadding(),AppBar 的内容也不会嵌入到状态栏里面。但同样是在左上角放一个 Button,边衬区就得我们手动去处理了。
setContent {
Box(modifier = Modifier.fillMaxSize()) {
TopAppBar(
navigationIcon = {
IconButton(onClick = { }) {
Icon(imageVector = Icons.Default.ArrowBack, contentDescription = null)
}
},
title = { Text(text = "Hello, Compose!") },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Green)
)
Button(onClick = { }) {
Text(text = "I'm a Button")
}
}
}
如果你非要当搅屎棍,就是要让 TopAppBar 的内容嵌入到状态栏里面,那么可以给 TopAppBar 传递 windowInsets 参数:
TopAppBar(windowInsets = WindowInsets(0, 0, 0, 0))
总的来说,Material 3 库中自带对边衬区处理的组件分为两类:应用栏和容器
- 应用栏
TopAppBar/SmallTopAppBar/CenterAlignedTopAppBar/MediumTopAppBar/LargeTopAppBar:使用 systemBars 的 top、left、right 作为内边距,因为它在窗口顶部使用。BottomAppBar:使用 systemBars 的 bottom、left、right 作为内边距。
- 容器
ModalDrawerSheet/DismissibleDrawerSheet/PermanentDrawerSheet(模态抽屉式导航栏内的内容):对内容应用 top、bottom 和 left 边衬区。ModalBottomSheet:应用 bottom 边衬区。NavigationBar:应用 bottom、left、right 边衬区。NavigationRail:应用 top、bottom 和 left 边衬区。
在文章的最后,我们来看一下 Jetpack Compose 目前支持处理的边衬区类型吧,前面我们已经接触了 4 种,分别是 statusBars、navigationBars、systemBars 和 ime
| WindowInset | 描述 | |
|---|---|---|
WindowInsets.statusBars | 表示状态栏的插入区域。注意,当状态栏不可见时(例如进入沉浸式全屏模式),插入区域为空。 | |
WindowInsets.statusBarsIgnoringVisibility | 与 WindowInsets.statusBars 类似,但无论状态栏是否处于可见状态,边衬区都不会为空。 | |
WindowInsets.navigationBars | 表示导航栏的插入区域,可能会显示在设备左侧、右侧或底部。 | |
WindowInsets.navigationBarsIgnoringVisibility | 与 WindowInsets.navigationBarsIgnoringVisibility 类似,但无论导航栏是否处于可见状态,边衬区都不会为空。 | |
WindowInsets.captionBar | 表示标题栏的插入区域,一般在桌面环境像 ChromeBook 才能接触到,窗口的标题栏会显示程序名称、窗口最大化、最小化及关闭按钮。 | |
WindowInsets.captionBarIgnoringVisibility | 与 WindowInsets.captionBar 类似,但无论标题栏是否处于可见状态,边衬区都不会为空。 | |
WindowInsets.systemBars | 系统栏边衬区的集合,包括状态栏、导航栏和标题栏。 | |
WindowInsets.systemBarsIgnoringVisibility | 与 WindowInsets.systemBars 类似,但无论系统栏是否处于可见状态,边衬区都不会为空。 | |
WindowInsets.ime | 描述键盘所占底部空间的边衬区。 | |
WindowInsets.imeAnimationSource | 描述键盘动画之前所占空间的边衬区。 | 通常与 ime、imeAnimationTarget 配合,计算键盘展开/收起的进度。详见 IssueTrack。 |
WindowInsets.imeAnimationTarget | 描述键盘动画播放完后将要占据的空间量的边衬区。 | 通常与 ime、imeAnimationSource 配合,计算键盘展开/收起的进度。详见 IssueTrack。 |
WindowInsets.tappableElement | 用于描述导航栏中由系统处理的点击区域。例如传统的 3 大金刚键,导航栏的整个高度都是系统手势的点击区域;而如果是小横条,导航栏中系统手势点击区域实际为 0,我们可以在下方放置自己的可交互组件。 | |
WindowInsets.tappableElementIgnoringVisibility | 与 WindowInsets.tappableElement 类似,但无论系统栏是否处于可见状态,边衬区都不会为空。 | |
WindowInsets.systemGestures | 表示系统将在其中拦截导航手势的边衬区,例如 Android 10 侧滑返回的区域。 | |
WindowInsets.mandatorySystemGestures | 系统手势的子集,将始终由系统处理,且无法通过 Modifier.systemGestureExclusion 选择停用。 | 一般来说,侧滑返回,只要手指不摸在状态栏或导航栏上,中间的整块矩形都是支持侧拉返回的,但也有特殊情况,像 Material Design 里的抽屉,你会发现在屏幕上半部分侧拉会打开抽屉,下半部分则还是触发返回,这是因为组件通过 Modifier.systemGestureExclusion 占据了部分系统手势处理区域,由自己来处理。 |
WindowInsets.displayCutout | 描述避免与刘海屏(凹口或针孔)重叠所需的间距量。 | |
WindowInsets.waterfall | 表示瀑布显示屏曲线区域的边衬区,也就是曲面屏两侧的区域。 |
另外,有 3 种“安全”边衬区,它们的作用是根据不同系统版本中边衬区的不同,自动选择合适的插入区域:
WindowInsets.safeDrawing:确保不在任何系统界面区域绘制内容。WindowInsets.safeGestures:避免可交互内容与系统手势发生冲突。WindowInsets.safeContent:相当于safeDrawing与safeGestures的结合。
以上列出的各种类型的 WindowInsets,其中大部分都分别有对应的 Insets Padding Modifier:
| WindowInsets | 对应的 Insets Padding Modifier |
|---|---|
WindowInsets.statusBars | Modifier.statusBarsPadding() |
WindowInsets.navigationBars | Modifier.navigationBarsPadding() |
WindowInsets.captionBars | Modifier.captionBarsPadding() |
WindowInsets.systemBars | Modifier.systemBarsPadding() |
WindowInsets.ime | Modifier.imePadding() |
WindowInsets.systemGestures | Modifier.systemGesturesPadding() |
WindowInsets.mandatorySystemGestures | Modifier.mandatorySystemGesturesPadding() |
WindowInsets.displayCutout | Modifier.displayCutoutPadding() |
WindowInsets.waterfall | Modifier.waterfallPadding() |
WindowInsets.safeDrawing | Modifier.safeDrawingPadding() |
WindowInsets.safeGestures | Modifier.safeGesturesPadding() |
WindowInsets.safeContent | Modifier.safeContentPadding() |
也就是说如果我想将某种类型 WindowInsets 添加为边距,不需要写 Modifier.windowInsetsPadding(WindowInsets.xxx),直接使用 Modifier.xxxPadding() 就完事了。
总结
回顾一下今天学到的东西:
- 什么是 Window Insets?各种类型的 WindowInsets;
- 如何将内容绘制到窗口的边衬区;
- Insets Padding Modifier 和 Insets Size Modifiers,以及二者在消耗 WindowInsets 时的不同行为;
- 如何手动消费 WindowInsets;
如果你真的看到了这里,不妨点个赞?
参考: