什么是 Insets?
Inset是指屏幕上由于系统 UI 元素(如状态栏, 导航栏, 显示屏切口(通常称为凹槽或针孔)和 IME 键盘)而无法完全用于应用程序 UI 的区域.
默认情况下, 我们应用程序的用户界面仅限于在系统用户界面内布局, 如状态栏和导航栏. 这样可以确保系统用户界面元素不会遮挡应用程序的内容.
那我们为什么还要担心 Inset 呢?
建议应用程序选择在这些同时显示系统用户界面的区域进行显示(从边缘到边缘), 这将带来更完美的用户体验, 并让我们的应用程序充分利用可用的窗口空间.
随着现代智能手机采用边缘到边缘屏幕和不同的宽高比, Inset管理的重要性也随之提升.
“能力越大, 责任越大”
从本质上讲, 我们正在从系统控制Inset模式转变为开发人员主动启用边缘到边缘显示或在系统 UI 元素后面绘图, 从而控制Inset管理的模式!
如何开始?
初始设置
我们必须让应用程序完全控制绘制内容的区域. 如果没有这样的设置, 我们的应用程序可能会在系统用户界面后面绘制黑色或纯色, 或者无法与软件键盘同步动画.
- 在 Activity
onCreate
中调用 enableEdgeToEdge 函数.
此调用请求我们的应用显示在系统 UI 后面. 应用将控制如何使用这些嵌入来调整 UI.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
// App content here
}
}
- 在我们的Activity
AndroidManifest.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)
)
}
在只启用边缘到边缘功能后
我们可以看到, 彩色的 Box
已经填满了整个屏幕, 甚至可以在系统栏(顶部状态栏和底部导航栏)后面绘制. 这意味着, 现在我们的代码可以在系统UI后面绘制, 而且我们可以自己控制这些区域!
控制 Inset
一旦我们的 Activity 在系统用户界面后面显示, 并控制了所有 Inset 的手动处理, 我们就可以使用 Compose API 来确保应用的可交互内容不会与系统用户界面重叠.
这些应用接口还能使应用的布局与 Inset 变化同步.
使用嵌入类型调整Composable布局有两种主要方法: Padding Modifier 和 Inset 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
. 这将使我们的应用看起来像这样:
为系统栏(系统栏 + 导航栏)插入padding
我们还可以使用 windowInsetsPadding(WindowInsets.statusBars)
或 windowInsetsPadding(WindowInsets.navigationBars)
分别控制这些 Inset.
对于最常见的 Inset 类型, 还有许多内置方法. 例如:
-
safeDrawingPadding()
, 相当于windowInsetsPadding(WindowInsets.safeDrawing)
-
safeContentPadding()
, 相当于windowInsetsPadding(WindowInsets.safeContent)
-
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)
)
}
}
在这里, 我们没有像上次那样为DarkGrey
Box
添加 Inset padding, 而是添加了LightGray
Spacer
, 它与状态栏和导航栏的大小完全一致.
使用键盘 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
将产生这样的体验:
滚动时的键盘 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之间的通信实现的.
这就是上述代码的实际效果:
打开键盘时的Inset消耗
让我们来看另一个示例:
在本例中, 我们将探讨Modifier: Modifier.considedWindowInsets(insets:WindowInsets)
.
该Modifier
与 Modifier.windowInsetsPadding
一样用于消耗Inset, 但它不会将消耗的Inset用作padding.
另一个Modifier
是 Modifier.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)
)
}
}
}
}
这段代码看起来像这样:
使用Scaffold时的重复padding
我们可以看到, 这个结果存在一些问题. 虽然来自 Scaffold
lambda 的 innerPadding
被应用到了外部 Box
的 padding
中, 但内部 Box
的 windowInsetsPadding(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
}
}
}
}
将会产生效果如下:
通过消耗Inset删除重复padding
因此, 一旦 innerPadding
被外部 Box
占用, 内部 Box
的 windowInsetsPadding(WindowInsets.safeDrawing)
就不会应用任何重复的padding.