Insets in Jetpack Compose 🖼️

2,218 阅读13分钟

Insets in Jetpack Compose 🖼️

什么是 Insets?

窗口

Insets 这个单词,直译过来是“插入物”、“插图”、“插页”的意思。在 Android 开发里面,Insets 通常指的是视图(View)的内边距或插入区域,亦可称作边衬区(几个中文叫法,没一个是让人好理解的 😅...),你可以简单地将 Insets 理解为视图的“内边距”或“边缘留白”。它是为了描述在视图的边界内插入的空白区域,确保内容与边界之间有一定的距离,从而提高用户界面的美观性和可用性。

在本篇文章,我们主要关注 Window Insets,这些是与窗口相关的内边距,例如:系统状态栏、导航栏和键盘等区域。

edge to edge

边衬区设置

默认情况下,Activity 的内容不会被绘制到状态栏或导航栏区域,因为系统会将 content 做一定的偏移调整。

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())
   }
}
WindowCompat.setDecorFitsSystemWindows(window, false)

📢 好消息是,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>

关联阅读:Android软键盘 windowSoftInputMode 的使用与原理(使用篇)

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
Modifier.windowInsetsPadding()

关于 WindowInsets,其实前面已经提过了,它就是窗口的内边距(边缘留白),包含了上下左右 4 块矩形区域:

What is WindowInsets?

查看 WindowInsets 的源码,发现它有 4 个方法分别获取 leftrighttopbottom,返回的其实就是左右留白区域的宽,和上下留白区域的高:

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.statusBarsWindowInsets.navigationBars

WindowInsets.statusBars 仅包含上贴边的状态栏,那么 top 就是状态栏高度,而左右以及底部的 3 块区域都为空,所以 leftrightbottom 都为 0。当我们将 WindowInsets.statusBars 传递给 windowInsetsPadding() 时,自然就会为组件添加一定的上边距。WindowInsets.navigationBars 同理,不再赘述。

WindowInsets.statusBars 和 WindowInsets.navigationBars.webp

一个 WindowInsets 可以由多个 WindowInsets 相加得到。有一种类型 WindowInsets 叫 WindowInsets.systemBars,它包含两块留白区域:状态栏和导航栏。

准确来说,system bars 包含 status bars、navigation bars 和 caption bar 三部分,但是 caption bar 在一般的 Android 开发中用不到,它是指电脑窗口标题栏部分,通常包含应用程序的标题和窗口控制按钮(如最小化、最大化、关闭按钮),在 ChromeBook 设备上才会接触到,这里我们暂时忽略它。

WindowInsets.systembars

那么对于前面的问题,我们可以换一种思路,不给按钮添加外边距,而是给 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 上。也就是说键盘展开时,列表会添加上相应的底部外边距,自然就被顶起来了。

Inset consumption

在输入法收起时,拉到列表最后,发现最后一个输入框确实没有被导航栏遮挡,证明尾部的 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 大小。

Inset consumption issue cause by Inset Size modifiers

运行,展开输入法,往下滑动到最后,问题来了......

外层的 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 的上下两块区域。

Modifier.consumeWindowInsets()

顺便提一嘴,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))
  }
}
Modifier.imeNestedScroll()

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")
        }
    }
}
Material 3 组件对 Insets 的支持

如果你非要当搅屎棍,就是要让 TopAppBar 的内容嵌入到状态栏里面,那么可以给 TopAppBar 传递 windowInsets 参数:

TopAppBar(windowInsets = WindowInsets(0, 0, 0, 0))

总的来说,Material 3 库中自带对边衬区处理的组件分为两类:应用栏和容器

  • 应用栏
    • TopAppBar/SmallTopAppBar/CenterAlignedTopAppBar/MediumTopAppBar / LargeTopAppBar:使用 systemBars 的 topleftright 作为内边距,因为它在窗口顶部使用。
    • BottomAppBar:使用 systemBars 的 bottomleftright 作为内边距。
  • 容器
    • ModalDrawerSheet/DismissibleDrawerSheet/PermanentDrawerSheet(模态抽屉式导航栏内的内容):对内容应用 top、bottom 和 left 边衬区。
    • ModalBottomSheet:应用 bottom 边衬区。
    • NavigationBar:应用 bottomleftright 边衬区。
    • NavigationRail:应用 topbottomleft 边衬区。

在文章的最后,我们来看一下 Jetpack Compose 目前支持处理的边衬区类型吧,前面我们已经接触了 4 种,分别是 statusBarsnavigationBarssystemBarsime

WindowInset描述
WindowInsets.statusBars表示状态栏的插入区域。注意,当状态栏不可见时(例如进入沉浸式全屏模式),插入区域为空。statusBars inset
WindowInsets.statusBarsIgnoringVisibilityWindowInsets.statusBars 类似,但无论状态栏是否处于可见状态,边衬区都不会为空。
WindowInsets.navigationBars表示导航栏的插入区域,可能会显示在设备左侧、右侧或底部。navigationBars inset
WindowInsets.navigationBarsIgnoringVisibilityWindowInsets.navigationBarsIgnoringVisibility 类似,但无论导航栏是否处于可见状态,边衬区都不会为空。
WindowInsets.captionBar表示标题栏的插入区域,一般在桌面环境像 ChromeBook 才能接触到,窗口的标题栏会显示程序名称、窗口最大化、最小化及关闭按钮。captionBar inset.png
WindowInsets.captionBarIgnoringVisibilityWindowInsets.captionBar 类似,但无论标题栏是否处于可见状态,边衬区都不会为空。
WindowInsets.systemBars系统栏边衬区的集合,包括状态栏、导航栏和标题栏。
WindowInsets.systemBarsIgnoringVisibilityWindowInsets.systemBars 类似,但无论系统栏是否处于可见状态,边衬区都不会为空。
WindowInsets.ime描述键盘所占底部空间的边衬区。ime inset
WindowInsets.imeAnimationSource描述键盘动画之前所占空间的边衬区。通常与 imeimeAnimationTarget 配合,计算键盘展开/收起的进度。详见 IssueTrack
WindowInsets.imeAnimationTarget描述键盘动画播放完后将要占据的空间量的边衬区。通常与 imeimeAnimationSource 配合,计算键盘展开/收起的进度。详见 IssueTrack
WindowInsets.tappableElement用于描述导航栏中由系统处理的点击区域。例如传统的 3 大金刚键,导航栏的整个高度都是系统手势的点击区域;而如果是小横条,导航栏中系统手势点击区域实际为 0,我们可以在下方放置自己的可交互组件。
WindowInsets.tappableElementIgnoringVisibilityWindowInsets.tappableElement 类似,但无论系统栏是否处于可见状态,边衬区都不会为空。
WindowInsets.systemGestures表示系统将在其中拦截导航手势的边衬区,例如 Android 10 侧滑返回的区域。
WindowInsets.mandatorySystemGestures系统手势的子集,将始终由系统处理,且无法通过 Modifier.systemGestureExclusion 选择停用。一般来说,侧滑返回,只要手指不摸在状态栏或导航栏上,中间的整块矩形都是支持侧拉返回的,但也有特殊情况,像 Material Design 里的抽屉,你会发现在屏幕上半部分侧拉会打开抽屉,下半部分则还是触发返回,这是因为组件通过 Modifier.systemGestureExclusion 占据了部分系统手势处理区域,由自己来处理。
WindowInsets.displayCutout描述避免与刘海屏(凹口或针孔)重叠所需的间距量。displayCutout inset
WindowInsets.waterfall表示瀑布显示屏曲线区域的边衬区,也就是曲面屏两侧的区域。waterfall inset

另外,有 3 种“安全”边衬区,它们的作用是根据不同系统版本中边衬区的不同,自动选择合适的插入区域:

以上列出的各种类型的 WindowInsets,其中大部分都分别有对应的 Insets Padding Modifier:

WindowInsets对应的 Insets Padding Modifier
WindowInsets.statusBarsModifier.statusBarsPadding()
WindowInsets.navigationBarsModifier.navigationBarsPadding()
WindowInsets.captionBarsModifier.captionBarsPadding()
WindowInsets.systemBarsModifier.systemBarsPadding()
WindowInsets.imeModifier.imePadding()
WindowInsets.systemGesturesModifier.systemGesturesPadding()
WindowInsets.mandatorySystemGesturesModifier.mandatorySystemGesturesPadding()
WindowInsets.displayCutoutModifier.displayCutoutPadding()
WindowInsets.waterfallModifier.waterfallPadding()
WindowInsets.safeDrawingModifier.safeDrawingPadding()
WindowInsets.safeGesturesModifier.safeGesturesPadding()
WindowInsets.safeContentModifier.safeContentPadding()

也就是说如果我想将某种类型 WindowInsets 添加为边距,不需要写 Modifier.windowInsetsPadding(WindowInsets.xxx),直接使用 Modifier.xxxPadding() 就完事了。

总结

回顾一下今天学到的东西:

  • 什么是 Window Insets?各种类型的 WindowInsets;
  • 如何将内容绘制到窗口的边衬区;
  • Insets Padding Modifier 和 Insets Size Modifiers,以及二者在消耗 WindowInsets 时的不同行为;
  • 如何手动消费 WindowInsets;

如果你真的看到了这里,不妨点个赞?


参考: