Compose 中的窗口边衬区

Android 平台负责绘制系统界面,例如状态栏和导航栏。无论用户使用哪个应用,系统都会显示此系统界面。WindowInsets 提供有关系统界面的信息,以确保您的应用在正确的区域绘制,并且您的界面不会被系统界面遮挡。

在系统栏后面全屏绘制
图 1. 在系统栏后面全屏绘制

默认情况下,应用界面仅限在系统界面中布局,例如状态栏和导航栏。这样可确保应用的内容不会被系统界面元素遮挡。

不过,我们建议应用选择在这些区域显示系统界面,这样可以提供更顺畅的用户体验,并让应用能够充分利用可用的窗口空间。这样一来,应用还可以与系统界面一起添加动画效果,尤其是在显示和隐藏软件键盘时。

选择在这些区域显示内容并在系统界面后面显示内容称为全屏显示。在本页中,您将了解不同类型的边衬区、如何选择采用全屏,以及如何使用边衬区 API 为界面添加动画效果并避免遮盖应用的某些部分。

边衬区基础

当应用全屏显示时,您需要确保重要内容和互动不会被系统界面遮挡。例如,如果某个按钮放在导航栏后面,用户可能无法点击该按钮。

系统界面的大小以及有关其放置位置的信息通过边衬区指定。

系统界面的每个部分都有相应的边衬区类型,该边衬区会描述其尺寸及其放置位置。例如,状态栏边衬区提供状态栏的尺寸和位置,而导航栏边衬区则提供导航栏的尺寸和位置。每种类型的边衬区都包含四个像素尺寸:顶部、左侧、右侧和底部。这些尺寸指定系统界面从应用窗口的相应侧边延伸多远。因此,为了避免与此类系统界面重叠,应用界面必须按此幅度进行嵌入。

以下内置 Android 边衬区类型可通过 WindowInsets 提供:

WindowInsets.statusBars

描述状态栏的边衬区。这些是包含通知图标和其他指示器的顶部系统界面栏。

WindowInsets.statusBarsIgnoringVisibility

状态栏用于设置何时可见。如果状态栏当前处于隐藏状态(由于进入沉浸式全屏模式),则主状态栏边衬区将为空,但这些边衬区将是非空。

WindowInsets.navigationBars

描述导航栏的边衬区。这些是设备左侧、右侧或底部的系统界面栏,用于描述任务栏或导航图标。它们可能会在运行时发生变化,具体取决于用户的首选导航方法以及与任务栏的交互情况。

WindowInsets.navigationBarsIgnoringVisibility

导航栏会带有边衬区(当它们处于可见状态时)。如果导航栏当前处于隐藏状态(由于进入沉浸式全屏模式),则主导航栏边衬区将为空,但这些边衬区将是非空。

WindowInsets.captionBar

描述系统界面窗口装饰(如果是在自由窗口)中的边衬区,例如顶部标题栏。

WindowInsets.captionBarIgnoringVisibility

字幕栏会在其可见的情况下插入。如果字幕栏当前处于隐藏状态,则主字幕栏边衬区将为空,但这些边衬区将是非空的。

WindowInsets.systemBars

系统栏边衬区的集合,包括状态栏、导航栏和标题栏。

WindowInsets.systemBarsIgnoringVisibility

系统栏插入,用于指明何时可见。如果系统栏当前处于隐藏状态(由于进入沉浸式全屏模式),则主系统栏边衬区将为空,但这些边衬区将是非空。

WindowInsets.ime

描述软件键盘底部所占空间的边衬区。

WindowInsets.imeAnimationSource

描述当前键盘动画之前软件键盘所占空间的边衬区。

WindowInsets.imeAnimationTarget

描述当前键盘动画播放完后软件键盘将占据的空间量的边衬区。

WindowInsets.tappableElement

一种边衬区,用于描述有关导航界面的更多详细信息,从而留出了“点按”空间将由系统(而非应用)处理。对于带有手势导航的透明导航栏,某些应用元素可通过系统导航界面进行点按。

WindowInsets.tappableElementIgnoringVisibility

可点按的元素在它们可见时插入。如果可点按元素当前处于隐藏状态(由于进入沉浸式全屏模式),则可点按元素的主要边衬区将为空,但这些边衬区将是非空的。

WindowInsets.systemGestures

表示系统将在其中拦截导航手势的边衬区的边衬区。应用可以通过 Modifier.systemGestureExclusion 手动指定处理有限数量的手势。

WindowInsets.mandatorySystemGestures

系统手势的子集,将始终由系统处理,且无法通过 Modifier.systemGestureExclusion 选择停用。

WindowInsets.displayCutout

边衬区表示需要避免与刘海屏(凹口或针孔)重叠所需的间距量。

WindowInsets.waterfall

表示瀑布显示屏曲线区域的边衬区。瀑布式显示屏具有沿屏幕边缘的曲线区域,该区域开始沿着设备侧边的屏幕包围。

这些类型总结为可确保内容不会被遮盖的三种“安全”边衬区类型:

这些“安全”边衬区类型会根据底层平台边衬区以不同的方式保护内容:

边衬区设置

如需允许应用完全控制其绘制内容的位置,请按照以下设置步骤操作。如果不执行这些步骤,您的应用可能会在系统界面后面绘制黑色或纯色,或者无法与软件键盘同步添加动画效果。

  1. Activity.onCreate 中调用 enableEdgeToEdge()。此调用会请求您的应用显示在系统界面后面。然后,您的应用将控制如何使用这些边衬区来调整界面。
  2. 在您的 Activity 的 AndroidManifest.xml 条目中设置 android:windowSoftInputMode="adjustResize"。此设置允许您的应用以边衬区的形式接收软件 IME 的大小,当 IME 在应用中出现和消失时,您可以使用边衬区来填充和合理布局内容。

    <!-- in your AndroidManifest.xml file: -->
    <activity
      android:name=".ui.MainActivity"
      android:label="@string/app_name"
      android:windowSoftInputMode="adjustResize"
      android:theme="@style/Theme.MyApplication"
      android:exported="true">
    

Compose API

一旦您的 activity 控制了所有边衬区的处理,您就可以使用 Compose API 来确保内容不会被遮挡,并且可互动的元素不会与系统界面重叠。这些 API 还会将应用的布局与边衬区更改同步。

例如,下面是将边衬区应用于整个应用内容的最基本方法:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    enableEdgeToEdge()

    setContent {
        Box(Modifier.safeDrawingPadding()) {
            // the rest of the app
        }
    }
}

此代码段将应用 safeDrawing 窗口边衬区作为应用整个内容的内边距。这可确保可互动的元素不会与系统界面重叠,但这也意味着任何应用都不会在系统界面后面绘制,以达到全屏效果。为了充分利用整个窗口,您需要逐个屏幕或逐个组件地微调边衬区的应用位置。

所有这些边衬区类型都会自动向后移植到 API 21 的 IME 动画添加动画效果。推而广之,当边衬区值发生变化时,使用这些边衬区的所有布局也会自动添加动画效果。

使用这些边衬区类型调整可组合项布局的主要方式有两种:内边距修饰符和边衬区大小修饰符。

内边距修饰符

Modifier.windowInsetsPadding(windowInsets: WindowInsets) 会将指定的窗口边衬区作为内边距应用,运作方式与 Modifier.padding 一样。例如,Modifier.windowInsetsPadding(WindowInsets.safeDrawing) 会将安全绘图边衬区作为内边距应用于所有 4 条边。

还有几种内置实用程序方法适用于最常见的边衬区类型。Modifier.safeDrawingPadding() 就是这样一种方法,相当于 Modifier.windowInsetsPadding(WindowInsets.safeDrawing)。其他边衬区类型也有类似的修饰符。

边衬区大小修饰符

以下修饰符将组件的大小设置为边衬区的大小,从而应用一定数量的窗口边衬区:

Modifier.windowInsetsStartWidth(windowInsets: WindowInsets)

将 windowInsets 的起始侧作为宽度应用(如 Modifier.width

Modifier.windowInsetsEndWidth(windowInsets: WindowInsets)

将 windowInsets 的末端作为宽度应用(例如 Modifier.width

Modifier.windowInsetsTopHeight(windowInsets: WindowInsets)

将 windowInsets 的顶部作为高度应用(如 Modifier.height

Modifier.windowInsetsBottomHeight(windowInsets: WindowInsets)

将 windowInsets 的底部作为高度应用(如 Modifier.height

这些修饰符对于调整占用边衬区空间的 Spacer 的大小特别有用:

LazyColumn(
    Modifier.imePadding()
) {
    // Other content
    item {
        Spacer(
            Modifier.windowInsetsBottomHeight(
                WindowInsets.systemBars
            )
        )
    }
}

边衬区消耗

边衬区内边距修饰符(windowInsetsPaddingsafeDrawingPadding 等辅助程序)会自动使用作为内边距应用的边衬区部分。深入了解组合树时,嵌套的边衬区内边距修饰符和边衬区尺寸修饰符知道部分边衬区已经被外部边衬区内边距修饰符占用,应避免多次使用边衬区的同一部分,以免产生过多的额外空间。

如果已经使用了边衬区,则边衬区大小修饰符还可以避免多次使用同一边衬区部分。不过,由于它们会直接改变大小,因此不会消耗边衬区本身。

因此,嵌套内边距修饰符会自动更改应用于每个可组合项的内边距量。

再看一下前面的 LazyColumn 示例,imePadding 修饰符正在调整 LazyColumn 的大小。在 LazyColumn 内,最后一项的大小调整为系统栏底部的高度:

LazyColumn(
    Modifier.imePadding()
) {
    // Other content
    item {
        Spacer(
            Modifier.windowInsetsBottomHeight(
                WindowInsets.systemBars
            )
        )
    }
}

当 IME 关闭时,imePadding() 修饰符不会应用任何内边距,因为 IME 没有高度。由于 imePadding() 修饰符未应用任何内边距,因此不会使用任何边衬区,并且 Spacer 的高度将是系统栏底部的尺寸。

当 IME 打开时,IME 边衬区会进行动画处理以匹配 IME 的大小,并且 imePadding() 修饰符开始应用底部内边距,以在 IME 打开时调整 LazyColumn 的大小。当 imePadding() 修饰符开始应用底部内边距时,也会开始使用相应数量的边衬区。因此,Spacer 的高度开始降低,因为 imePadding() 修饰符已应用了系统栏间距的一部分。当 imePadding() 修饰符应用的底部内边距大于系统栏时,Spacer 的高度将为零。

当 IME 关闭时,变化会反过来:当 imePadding() 的应用高度小于系统栏的底部时,Spacer 从零开始展开,直到 IME 完全动画化后,Spacer 与系统栏底部的高度一致。

图 2.使用 TextField 的边缘延迟列

此行为是通过所有 windowInsetsPadding 修饰符之间的通信完成的,并且会受到其他一些方式的影响。

Modifier.consumeWindowInsets(insets: WindowInsets) 也使用与 Modifier.windowInsetsPadding 相同的方式使用边衬区,但它不会将使用的边衬区用作内边距。这与边衬区尺寸修饰符结合使用会非常有用,可向同级元素表明已被使用一定数量的边衬区:

Column(Modifier.verticalScroll(rememberScrollState())) {
    Spacer(Modifier.windowInsetsTopHeight(WindowInsets.systemBars))

    Column(
        Modifier.consumeWindowInsets(
            WindowInsets.systemBars.only(WindowInsetsSides.Vertical)
        )
    ) {
        // content
        Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
    }

    Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.systemBars))
}

Modifier.consumeWindowInsets(paddingValues: PaddingValues) 的行为与使用 WindowInsets 参数的版本非常相似,但需要使用任意 PaddingValues。当内边距或间距由边衬区内边距修饰符之外的某种其他机制(例如普通的 Modifier.padding 或固定高度的分隔符)提供时,这对于通知子级非常有用:

@OptIn(ExperimentalLayoutApi::class)
Column(Modifier.padding(16.dp).consumeWindowInsets(PaddingValues(16.dp))) {
    // content
    Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.ime))
}

如果无需消耗原始窗口边衬区,请直接使用 WindowInsets 值,或使用 WindowInsets.asPaddingValues() 返回不受消耗情况影响的边衬区的 PaddingValues。不过,由于以下注意事项,请尽可能使用窗口边衬区内边距修饰符和窗口边衬区大小修饰符。

边衬区和 Jetpack Compose 阶段

Compose 使用底层 AndroidX 核心 API 来更新边衬区并为其添加动画效果,而边衬区使用底层平台 API 管理边衬区。由于这种平台行为,边衬区与 Jetpack Compose 的阶段之间存在特殊关系。

边衬区的值在组合阶段之后但在布局阶段之前更新。这意味着,读取组合中边衬区的值通常使用延迟一帧的边衬区值。本页介绍的内置修饰符可将边衬区值延迟到布局阶段使用,从而确保在更新边衬区值时将其用于同一帧。

使用 WindowInsets 的键盘 IME 动画

您可以将 Modifier.imeNestedScroll() 应用于滚动容器,以便在滚动到容器底部时自动打开和关闭 IME。

class WindowInsetsExampleActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        WindowCompat.setDecorFitsSystemWindows(window, false)

        setContent {
            MaterialTheme {
                MyScreen()
            }
        }
    }
}

@OptIn(ExperimentalLayoutApi::class)
@Composable
fun MyScreen() {
    Box {
        LazyColumn(
            modifier = Modifier
                .fillMaxSize() // fill the entire window
                .imePadding() // padding for the bottom for the IME
                .imeNestedScroll(), // scroll IME at the bottom
            content = { }
        )
        FloatingActionButton(
            modifier = Modifier
                .align(Alignment.BottomEnd)
                .padding(16.dp) // normal 16dp of padding for FABs
                .navigationBarsPadding() // padding for navigation bar
                .imePadding(), // padding for when IME appears
            onClick = { }
        ) {
            Icon(imageVector = Icons.Filled.Add, contentDescription = "Add")
        }
    }
}

展示界面元素上下滚动以便为键盘腾出空间的动画

图 1. IME 动画

对 Material 3 组件的边衬区支持

为便于使用,许多内置的 Material 3 可组合项 (androidx.compose.material3) 会根据 Material 规范在应用中放置可组合项的方式,自行处理边衬区。

边衬区处理可组合项

下面列出了可自动处理边衬区的 Material 组件

应用栏

内容容器

Scaffold

默认情况下,Scaffold 会提供边衬区作为参数 paddingValues,供您使用和使用。Scaffold 不会将边衬区应用于内容;您应自行负责。例如,如需在 Scaffold 中使用 LazyColumn 使用这些边衬区,请使用以下代码:

Scaffold { innerPadding ->
    // innerPadding contains inset information for you to use and apply
    LazyColumn(
        // consume insets as scaffold doesn't do it by default
        modifier = Modifier.consumeWindowInsets(innerPadding),
        contentPadding = innerPadding
    ) {
        items(count = 100) {
            Box(
                Modifier
                    .fillMaxWidth()
                    .height(50.dp)
                    .background(colors[it % colors.size])
            )
        }
    }
}

替换默认边衬区

您可以更改传递给可组合项的 windowInsets 参数,以配置可组合项的行为。此参数可以是要改为应用的其他类型的窗口边衬区,也可以通过传递空实例 WindowInsets(0, 0, 0, 0) 来停用。

例如,如需对 LargeTopAppBar 停用边衬区处理,请将 windowInsets 参数设置为空实例:

LargeTopAppBar(
    windowInsets = WindowInsets(0, 0, 0, 0),
    title = {
        Text("Hi")
    }
)

与 View 系统边衬区互操作

如果屏幕的 View 和 Compose 代码位于同一层次结构中,您可能需要替换默认边衬区。在这种情况下,您需要明确哪个位置应该使用边衬区,哪个应该忽略边衬区。

例如,如果最外层的布局是 Android View 布局,您应该使用 View 系统中的边衬区,并在 Compose 中忽略这些边衬区。或者,如果您最外层的布局是可组合项,则应在 Compose 中使用边衬区,并相应地填充 AndroidView 可组合项。

默认情况下,每个 ComposeView 都会使用 WindowInsetsCompat 级别使用的所有边衬区。如需更改此默认行为,请将 ComposeView.consumeWindowInsets 设置为 false

资源

  • Now in Android - 一款完全使用 Kotlin 和 Jetpack Compose 构建的功能齐全的 Android 应用。