深入理解Jetpack Compose中的Window Inset

什么是 Insets?

Inset是指屏幕上由于系统 UI 元素(如状态栏, 导航栏, 显示屏切口(通常称为凹槽或针孔)和 IME 键盘)而无法完全用于应用程序 UI 的区域.

默认情况下, 我们应用程序的用户界面仅限于在系统用户界面内布局, 如状态栏和导航栏. 这样可以确保系统用户界面元素不会遮挡应用程序的内容.

那我们为什么还要担心 Inset 呢?

建议应用程序选择在这些同时显示系统用户界面的区域进行显示(从边缘到边缘), 这将带来更完美的用户体验, 并让我们的应用程序充分利用可用的窗口空间.

随着现代智能手机采用边缘到边缘屏幕和不同的宽高比, Inset管理的重要性也随之提升.

“能力越大, 责任越大”

从本质上讲, 我们正在从系统控制Inset模式转变为开发人员主动启用边缘到边缘显示或在系统 UI 元素后面绘图, 从而控制Inset管理的模式!

如何开始?

初始设置

我们必须让应用程序完全控制绘制内容的区域. 如果没有这样的设置, 我们的应用程序可能会在系统用户界面后面绘制黑色或纯色, 或者无法与软件键盘同步动画.

此调用请求我们的应用显示在系统 UI 后面. 应用将控制如何使用这些嵌入来调整 UI.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    enableEdgeToEdge()
    setContent {
        // App content here
    }
}
  • 在我们的ActivityAndroidManifest.xml里设置android:windowSoftInputMode="adjustResize".

此设置允许我们的应用接收软件 IME 的Inset尺寸, 当 IME 在应用中出现和消失时, 我们可以使用这些Inset尺寸来适当地填充和布局内容.

<activity
  android:name=".ui.MainActivity"
  android:label="@string/app_name"
  android:windowSoftInputMode="adjustResize"
  android:theme="@style/Theme.MyApplication"
  android:exported="true">

让我们用下面的代码来看看我们的应用现在的样子:

setContent {
    Box(
        modifier = Modifier
             .fillMaxSize()
             .background(color = Color.DarkGray)
       )
}

深入理解Jetpack Compose中的Window Inset

在只启用边缘到边缘功能后

我们可以看到, 彩色的 Box 已经填满了整个屏幕, 甚至可以在系统栏(顶部状态栏和底部导航栏)后面绘制. 这意味着, 现在我们的代码可以在系统UI后面绘制, 而且我们可以自己控制这些区域!

控制 Inset

一旦我们的 Activity 在系统用户界面后面显示, 并控制了所有 Inset 的手动处理, 我们就可以使用 Compose API 来确保应用的可交互内容不会与系统用户界面重叠.

这些应用接口还能使应用的布局与 Inset 变化同步.

使用嵌入类型调整Composable布局有两种主要方法: Padding ModifierInset Size Modifier.

Padding Modifier

我们可以使用 Modifier.windowInsetsPadding(windowInsets: WindowInsets) 来应用窗口Inset作为填充. 它的工作原理与 Modifier.padding非常相似.

有一些内置的窗口Inset, 如 WindowInsets.systemBars, WindowInsets.statusBars, WindowInsets.navigationBars 等, 我们可以使用它们来提供所需的填充.

例如, 在前面的代码中, 如果我们想省略状态栏和导航栏, 然后绘制 Grey 框, 我们可以这样做:

setContent {
    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(color = Color.LightGray)
            .windowInsetsPadding(WindowInsets.systemBars)
    ) {
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(color = Color.DarkGray)
        )
    }
}

Modifier windowInsetsPadding(WindowInsets.systemBars)为顶部状态栏和底部导航栏添加宽padding, 为便于理解, 它们的颜色为LightGray. 这将使我们的应用看起来像这样:

深入理解Jetpack Compose中的Window Inset

为系统栏(系统栏 + 导航栏)插入padding

我们还可以使用 windowInsetsPadding(WindowInsets.statusBars)windowInsetsPadding(WindowInsets.navigationBars) 分别控制这些 Inset.

对于最常见的 Inset 类型, 还有许多内置方法. 例如:

  1. safeDrawingPadding(), 相当于windowInsetsPadding(WindowInsets.safeDrawing)
  2. safeContentPadding(), 相当于windowInsetsPadding(WindowInsets.safeContent)
  3. safeGesturesPadding(), 相当于windowInsetsPadding(WindowInsets.safeGestures)

Inset size modifier

这些Modifier有助于将组件的大小设置为Inset的精确大小. 在创建屏幕时, 这些Modifier对于调整占用Inset尺寸的Spacer的大小非常有用.

例如, 我们可以这样编写最后一段代码, 使用Inset Size Modifier为状态栏和导航栏提供padding. 这将产生相同的结果:

setContent {
    Column {
        Spacer(
            modifier = Modifier
                .fillMaxWidth()
                .background(color = Color.LightGray)
                .windowInsetsTopHeight(WindowInsets.statusBars)
        )
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(color = Color.DarkGray)
                .weight(1f)
        )
        Spacer(
            modifier = Modifier
                .fillMaxWidth()
                .background(color = Color.LightGray)
                .windowInsetsBottomHeight(WindowInsets.navigationBars)
        )
    }
}

在这里, 我们没有像上次那样为DarkGreyBox添加 Inset padding, 而是添加了LightGraySpacer, 它与状态栏和导航栏的大小完全一致.

使用键盘 IME 调整组件padding大小

有时, 我们希望根据键盘 IME 是打开还是关闭的状态为 UI 组件应用动态padding. 在列表底部添加输入框就是一个很好的用例.

请看这段代码:

setContent {
    Column(
        modifier = Modifier.fillMaxSize().systemBarsPadding()
    ) {
        LazyColumn(
            modifier = Modifier.weight(1f),
            reverseLayout = true
        ) {
            items(100) { index ->
                Text(text = "Item $index", modifier = Modifier.padding(16.dp).fillMaxWidth())
            }
        }
        var textFieldValue by remember { mutableStateOf(TextFieldValue()) }
        TextField(
            modifier = Modifier.fillMaxWidth(),
            value = textFieldValue,
            onValueChange = { textFieldValue = it },
            placeholder = {
                Text(text = "Type something here")
            }
        )
    }
}

如果我们不处理TextField的padding:

不处理文本字段的padding

在这里, 我们可以看到当键盘打开时, TextField会停留在屏幕底部, 这不会提供很好的用户体验.

处理 TextField 的padding之后:

我们只需在上面的 TextField 中添加 imePadding() Modifier.

现在的代码如下:

// Same code as above
TextField(
    modifier = Modifier.fillMaxWidth().imePadding(), // IME padding added
    value = textFieldValue,
    onValueChange = { textFieldValue = it },
    placeholder = {
        Text(text = "Type something here")
    }
)

现在, TextField 的 padding 将随 IME 状态的变化而变化, 并产生动画效果, 从而产生输入框随键盘移动的效果

处理完文本字段的padding后

滚动时的键盘 IME 动画:

还有一个实验性的 API Modifier imeNestedScroll() , 添加到滚动容器后, 当滚动到容器底部时, 键盘会以动画形式打开.

如果我们修改上述代码, 将此Modifier添加到 LazyColumn 中:

// Same code as above
LazyColumn(
    modifier = Modifier.weight(1f).imeNestedScroll(), // Modifier added
    reverseLayout = true
) {
// Same code as previous

将产生这样的体验:

深入理解Jetpack Compose中的Window Inset

滚动时的键盘 IME 动画

Inset消耗

现在, 我们可能会突然想到几个问题.

考虑到任何Inset padding Modifier(例如 safeDrawingPadding()), 我们是否只需要在Composable层次结构中应用一次? 如果应用多次会发生什么情况? 如果我们对父代应用了一次, 然后又对下面的子代应用了一次, 那么padding是否会添加两次?

好吧, 这就是Inset消耗的概念.

内置的Inset padding Modifier会自动消耗作为padding应用的Inset部分. 当深入到合成树时, 应用于子合成的Inset padding Modifier和Inset Size Modifier会知道, 外层Modifier已经消耗(或应用或考虑)了部分Inset, 因此会跳过再次应用这些Inset, 从而避免空间的重复.

**让我们看一个例子来理解这一点: **

setContent {
    var textFieldValue by remember { mutableStateOf(TextFieldValue()) }
    LazyColumn(
        Modifier.windowInsetsPadding(WindowInsets.statusBars).imePadding()
    ) {
        items(count = 30) {
            Text(
                modifier = Modifier.fillMaxWidth().padding(16.dp),
                text = "Item $it"
            )
        }
        item {
            TextField(
                modifier = Modifier.fillMaxWidth().height(56.dp),
                value = textFieldValue,
                onValueChange = { textFieldValue = it },
                placeholder = { Text(text = "Type something here") }
            )
        }
        item {
            Spacer(
                Modifier.windowInsetsBottomHeight(
                    WindowInsets.systemBars
                )
            )
        }
    }
}

这段代码在LazyColumn中显示一长串条目. 在列表的底部, 有一个供用户输入的 TextField 字段, 在末尾有一个 Spacer 空间, 该空间使用窗口Inset Size Modifier为底部系统导航栏提供空间. 此外,LazyColumn还应用了imePadding.

在这里, 当键盘关闭时, IME 没有高度, 因此 imePadding() Modifier不会应用任何padding. 因此不会消耗任何Inset, 此时Spacer的高度就是系统栏底部的大小. 当键盘打开时, IME 嵌套的动画将与 IME 的大小相匹配, imePadding() Modifier开始为LazyColumn应用底部padding. 因此, 它也开始消耗该数量的嵌入.

此时, imePadding() Modifier已经应用了一定量的系统栏底部间距, 因此Spacer的高度开始下降. 在某个时间点, 当 IME padding大小超过底部系统栏的大小时, Spacer 的高度将变为零. 当键盘关闭时, 同样的机制会反向发生.

这种行为是通过所有 windowInsetsPadding Modifier之间的通信实现的.

这就是上述代码的实际效果:

深入理解Jetpack Compose中的Window Inset

打开键盘时的Inset消耗

让我们来看另一个示例:

在本例中, 我们将探讨Modifier: Modifier.considedWindowInsets(insets:WindowInsets).

ModifierModifier.windowInsetsPadding一样用于消耗Inset, 但它不会将消耗的Inset用作padding.

另一个ModifierModifier.considedWindowInsets(paddingValues:PaddingValues), 它可以使用任意的 PaddingValues.

当padding或间距由Inset padding Modifier以外的其他机制(如普通的 Modifier.padding或固定高度的Spacer)提供时, 这对于通知子代非常有用.

请看这段代码:

setContent {
    Scaffold { innerPadding ->
        // innerPadding contains inset information to use and apply
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(color = Color.LightGray)
                .padding(innerPadding)
        ) {
            Box(
                modifier = Modifier
                    .fillMaxSize()
                    .background(color = Color.Red)
                    .windowInsetsPadding(WindowInsets.safeDrawing)
            ) {
                Box(
                    modifier = Modifier
                        .fillMaxSize()
                        .background(color = Color.DarkGray)
                )
            }
        }
    }
}

这段代码看起来像这样:

深入理解Jetpack Compose中的Window Inset

使用Scaffold时的重复padding

我们可以看到, 这个结果存在一些问题. 虽然来自 Scaffold lambda 的 innerPadding 被应用到了外部 Boxpadding 中, 但内部 BoxwindowInsetsPadding(WindowInsets.safeDrawing) 却产生了重复的 padding(红色可见). 这意味着, 由于某种原因, 这里没有发生Inset消耗.

这是因为, 默认情况下, Scaffold会将嵌套作为参数paddingValues提供给我们消耗和使用. Scaffold不会将Inset应用于内容; 这是我们的责任.

因此, 如果我们想避免这种重复的padding, 就需要使用 consumeWindowInsets(innerPadding) Modifier自行消耗padding.

将此视为更新后的代码:

setContent {
    Scaffold { innerPadding ->
        // innerPadding contains inset information to use and apply
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(color = Color.LightGray)
                .padding(innerPadding)
                // Consume this insets so that it's not applied again when using safeDrawing in the hierarchy below
                .consumeWindowInsets(innerPadding)
        ) {
              // Remaining code
          }
        }
    }
}

将会产生效果如下:

深入理解Jetpack Compose中的Window Inset

通过消耗Inset删除重复padding

因此, 一旦 innerPadding 被外部 Box 占用, 内部 BoxwindowInsetsPadding(WindowInsets.safeDrawing) 就不会应用任何重复的padding.