在应用中全屏显示内容,并在 Compose 中处理窗口边衬区

Android 平台负责绘制系统界面,例如状态栏和导航栏。无论用户使用的是哪款应用,系统都会显示此界面。

WindowInsets 提供有关系统界面的信息,以确保您的应用在正确区域绘制,并且您的界面不会被系统界面遮挡。

实现无边框以在系统栏后面绘制
图 1. 从边到边绘制在系统栏后面。

在 Android 14(API 级别 34)及更低版本中,应用的界面默认不会在系统栏和显示屏缺口下方绘制。

在 Android 15(API 级别 35)及更高版本中,如果您的应用以 SDK 35 为目标平台,则会在系统栏下方绘制并显示屏幕缺口。这会带来更顺畅的用户体验,并让您的应用能够充分利用可用的窗口空间。

在系统界面后面显示内容称为“无边框”。在本页中,您将了解不同类型的边衬区、如何实现边到边设计,以及如何使用边衬区 API 为界面添加动画效果,并确保应用的内容不会被系统界面元素遮挡。

边衬区基础知识

当应用采用边到边模式时,您需要确保重要内容和互动不会被系统界面遮挡。例如,如果按钮位于导航栏后面,用户可能无法点击它。

系统界面的大小以及其放置位置的信息通过内边距指定。

系统界面的每个部分都有对应的内边距类型,用于描述其大小和放置位置。例如,状态栏内边距可提供状态栏的大小和位置,而导航栏内边距可提供导航栏的大小和位置。每种类型的内嵌边距都由四个像素尺寸组成:上、左、右和下。这些尺寸指定系统界面从应用窗口的相应侧延伸的距离。因此,为避免与此类系统界面重叠,应用界面必须内嵌该数量。

您可以通过 WindowInsets 使用以下内置 Android 边衬区类型:

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. 以 SDK 35 或更高版本为目标平台,以便在 Android 15 及更高版本上强制执行边到边。您的应用会显示在系统界面后面。您可以通过处理内边距来调整应用的界面。
  2. (可选)在 Activity.onCreate() 中调用 enableEdgeToEdge(),以允许您的应用在早期 Android 版本中采用无边框设计。
  3. 在 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 窗口内边距应用为应用的整个内容周围的内边距。虽然这可确保可交互元素不会与系统界面重叠,但也意味着任何应用都不会在系统界面后面绘制,以实现边到边效果。为了充分利用整个窗口,您需要逐个屏幕或逐个组件微调边衬区的应用位置。

所有这些内嵌类型都会自动呈现动画效果,并且 IME 动画会向 API 21 进行向后移植。因此,使用这些内边距的所有布局也会随着内边距值的变化而自动呈现动画效果。

您可以通过以下两种主要方式使用这些内边距类型来调整可组合项布局:内边距修饰符和内边距大小修饰符。

内边距修饰符

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 示例,LazyColumn 正在通过 imePadding 修饰符调整大小。在 LazyColumn 中,最后一个项的大小为系统栏底部的高度:

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

当 IME 处于关闭状态时,imePadding() 修饰符不会应用内边距,因为 IME 没有高度。由于 imePadding() 修饰符不会应用内边距,因此不会占用任何内边距,并且 Spacer 的高度将与系统栏底部的大小相同。

当 IME 打开时,IME 内嵌动画会调整为与 IME 的大小相匹配,并且 imePadding() 修饰符会在 IME 打开时开始应用底部内边距以调整 LazyColumn 的大小。当 imePadding() 修饰符开始应用底部内边距时,它也会开始使用相应数量的内边距。因此,由于 imePadding() 修饰符已应用系统栏的部分间距,因此 Spacer 的高度开始缩减。一旦 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 或固定高度的间隔符:

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")
        }
    }
}

展示界面元素上下滚动以便为键盘腾出空间的动画
图 3. 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

系统栏保护

当您的应用以 SDK 35 或更高版本为目标平台后,系统会强制采用无边框设计。系统状态栏和手势导航栏是透明的,但三按钮导航栏是半透明的。

如需移除默认的半透明三按钮导航栏背景保护,请将 Window.setNavigationBarContrastEnforced 设置为 false

资源