在您的应用中边缘到边缘显示内容,并在 Compose 中处理窗口内边距

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

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

Going edge-to-edge to draw behind the system bars
图 1. 边缘到边缘绘制以覆盖系统栏。

在 Android 14(API 级别 34)及更低版本中,您的应用 UI 默认情况下不会在系统栏和显示挖孔下方绘制。

在 Android 15(API 级别 35)及更高版本中,一旦您的应用以 SDK 35 为目标,您的应用就会在系统栏和显示挖孔下方绘制。这带来了更无缝的用户体验,并允许您的应用充分利用可用的窗口空间。

在系统 UI 后面显示内容称为边缘到边缘。在本页中,您将了解不同类型的内边距、如何实现边缘到边缘以及如何使用内边距 API 来为您的 UI 设置动画并确保您的应用内容不会被系统 UI 元素遮挡。

内边距基础

当应用实现边缘到边缘时,您需要确保重要的内容和交互不会被系统 UI 遮挡。例如,如果按钮放置在导航栏后面,用户可能无法点击它。

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

系统 UI 的每个部分都有一个相应的内边距类型来描述其大小和放置位置。例如,状态栏内边距提供状态栏的大小和位置,而导航栏内边距提供导航栏的大小和位置。每种类型的内边距都包含四个像素尺寸:顶部、左侧、右侧和底部。这些尺寸指定系统 UI 从应用窗口相应侧边延伸的距离。因此,为了避免与该类型的系统 UI 重叠,应用 UI 必须缩进该距离。

这些内置的 Android 内边距类型可通过 WindowInsets 获取。

WindowInsets.statusBars

描述状态栏的内边距。这些是顶部系统 UI 栏,包含通知图标和其他指示器。

WindowInsets.statusBarsIgnoringVisibility

状态栏在可见时的内边距。如果状态栏当前隐藏(由于进入沉浸式全屏模式),则主要状态栏内边距将为空,但这些内边距将不为空。

WindowInsets.navigationBars

描述导航栏的内边距。这些是设备左侧、右侧或底部的系统 UI 栏,描述任务栏或导航图标。这些内容会根据用户首选的导航方法和与任务栏的交互在运行时发生变化。

WindowInsets.navigationBarsIgnoringVisibility

导航栏在可见时的内边距。如果导航栏当前隐藏(由于进入沉浸式全屏模式),则主要导航栏内边距将为空,但这些内边距将不为空。

WindowInsets.captionBar

描述系统 UI 窗口装饰(如果在自由窗格窗口中,例如顶部标题栏)的内边距。

WindowInsets.captionBarIgnoringVisibility

标题栏在可见时的内边距。如果标题栏当前隐藏,则主要标题栏内边距将为空,但这些内边距将不为空。

WindowInsets.systemBars

系统栏内边距的并集,包括状态栏、导航栏和标题栏。

WindowInsets.systemBarsIgnoringVisibility

系统栏在可见时的内边距。如果系统栏当前隐藏(由于进入沉浸式全屏模式),则主要系统栏内边距将为空,但这些内边距将不为空。

WindowInsets.ime

描述软件键盘占据底部空间量的内边距。

WindowInsets.imeAnimationSource

描述当前键盘动画之前软件键盘占据的空间量的内边距。

WindowInsets.imeAnimationTarget

描述当前键盘动画之后软件键盘将占据的空间量的内边距。

WindowInsets.tappableElement

一种内边距类型,描述有关导航 UI 的更详细信息,提供系统将处理“点击”的空间量,而不是应用。对于具有手势导航的透明导航栏,某些应用元素可以通过系统导航 UI 点击。

WindowInsets.tappableElementIgnoringVisibility

可点击元素在可见时的内边距。如果可点击元素当前隐藏(由于进入沉浸式全屏模式),则主要可点击元素内边距将为空,但这些内边距将不为空。

WindowInsets.systemGestures

表示系统将拦截手势以进行导航的内边距量。应用可以通过 Modifier.systemGestureExclusion 手动指定处理这些手势中的有限数量。

WindowInsets.mandatorySystemGestures

系统手势的一个子集,这些手势将始终由系统处理,并且无法通过 Modifier.systemGestureExclusion 选择退出。

WindowInsets.displayCutout

表示为避免与显示挖孔(缺口或针孔)重叠所需的空间量的内边距。

WindowInsets.waterfall

表示瀑布显示器弯曲区域的内边距。瀑布显示器在屏幕边缘具有弯曲区域,屏幕在这些区域开始沿着设备侧面包裹。

这些类型由三种“安全”内边距类型概括,以确保内容不会被遮挡

这些“安全”内边距类型以不同的方式保护内容,具体取决于底层平台内边距

内边距设置

要使您的应用完全控制其绘制内容的位置,请按照以下设置步骤操作。如果没有这些步骤,您的应用可能会在系统 UI 后面绘制黑色或纯色,或者不会与软件键盘同步动画。

  1. 以 SDK 35 或更高版本为目标,以便在 Android 15 及更高版本中强制执行边缘到边缘。您的应用会显示在系统 UI 后面。您可以通过处理内边距来调整应用的 UI。
  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 来确保内容不会被遮挡,并且可交互元素不会与系统 UI 重叠。这些 API 还会将您的应用布局与内嵌区域更改同步。

例如,这是将内嵌区域应用于整个应用内容的最基本方法。

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

    enableEdgeToEdge()

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

此代码片段将safeDrawing窗口内嵌区域作为填充应用于整个应用的内容周围。虽然这可以确保可交互元素不会与系统 UI 重叠,但也意味着应用的任何部分都不会绘制在系统 UI 后面以实现边缘到边缘的效果。要充分利用整个窗口,您需要根据屏幕或组件逐个调整内嵌区域的应用位置。

所有这些内嵌区域类型都随 IME 动画自动进行动画处理,并向后移植到 API 21。因此,使用这些内嵌区域的所有布局也会随着内嵌区域值的变化而自动进行动画处理。

主要有两种方法可以使用这些内嵌区域类型来调整您的 Composable 布局:填充修饰符和内嵌区域大小修饰符。

填充修饰符

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()修饰符开始应用底部填充以调整LazyColumn的大小,因为 IME 正在打开。随着imePadding()修饰符开始应用底部填充,它也开始消耗该数量的内嵌区域。因此,Spacer的高度开始减小,因为系统栏的一部分间距已由imePadding()修饰符应用。一旦imePadding()修饰符应用的底部填充量大于系统栏,Spacer的高度将为零。

当 IME 关闭时,更改会反向发生:一旦imePadding()应用的填充量小于系统栏底部,Spacer将从高度为零开始扩展,直到最终 IME 完全动画结束时Spacer与系统栏底部的的高度匹配。

图 2. 带有TextField的边缘到边缘的 LazyColumn。

此行为是通过所有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 使用管理内嵌区域的底层平台 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")
        }
    }
}

Animation showing a UI element scrolling up and down to make way for a keyboard
图 3. IME 动画。

对 Material 3 组件的内嵌区域支持

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

处理内嵌区域的可组合项

以下是自动处理内嵌区域的Material 组件列表。

应用栏

内容容器

脚手架

默认情况下,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

资源