应对 Android 15 强制执行的无边框措施

1. 准备工作

SociaLite 演示了如何使用各种 Android 平台 API 实现社交网络应用中常见的功能;同时利用了各种 Jetpack API 来实现复杂功能,这些功能可在更多设备上可靠运行,而且需要更少的代码。

此 Codelab 会引导您完成以下过程:使 SociaLite 应用与 Android 15 强制执行的无边框措施兼容,并以向后兼容的方式使应用采用无边框设计。采用无边框设计后,SociaLite 将如下所示,具体情况会因设备和导航模式而异:

采用三按钮导航的 SociaLite 应用。

采用手势导航的 SociaLite 应用。

采用三按钮导航的 SociaLite

采用手势导航的 SociaLite

大屏设备上的 SociaLite 应用。

大屏设备上的 SociaLite

前提条件

  • 具备 Kotlin 基础知识。
  • 已完成设置 Android Studio Codelab,或者熟悉如何使用 Android Studio,以及如何在搭载 Android 15 的模拟器或实体设备中测试应用。

学习内容

  • 如何应对 Android 15 无边框变更。
  • 如何以向后兼容的方式使应用采用无边框设计。

所需条件

  • 最新版本的 Android Studio。
  • 搭载 Android 15 Beta 1 或更高版本的测试设备或模拟器。
  • Android 15 Beta 1 SDK 或更高版本。

2. 获取起始代码

  1. 从 GitHub 下载起始代码

或者,您也可以克隆代码库并查看 codelab_improve_android_experience_2024 分支。

$ git clone git@github.com:android/socialite.git
$ cd socialite
$ git checkout codelab_improve_android_experience_2024
  1. 在 Android Studio 中打开 SociaLite,然后在搭载 Android 15 的设备或模拟器上运行该应用。您将看到类似于以下内容之一的屏幕:

采用三按钮导航的 SociaLite。

采用手势导航的 SociaLite。

三按钮导航

手势导航

大屏设备上的 SociaLite。

大屏幕

  1. Chats 页面上,选择其中一个对话,例如谈论狗狗的对话。

谈论狗狗的聊天消息(三按钮导航)

谈论狗狗的聊天消息(手势导航)

谈论狗狗的聊天消息(三按钮导航)

谈论狗狗的聊天消息(手势导航)

3. 让应用采用 Android 15 中的无边框设计

什么是无边框?

应用可以在系统栏的后方进行绘制,从而提供优质的用户体验并充分利用显示空间。这就是所谓的“无边框”

GIF 图片:采用无边框设计的应用

如何应对 Android 15 无边框变更

在 Android 15 之前,应用的界面默认情况下只能避开系统栏区域(例如状态栏和导航栏)进行布局。应用可以选择采用无边框设计;但根据应用的不同,选择采用该设计的难易程度也各不相同。

从 Android 15 开始,应用将默认采用无边框设计。您将看到以下默认设置:

  • 三按钮导航栏是半透明的。
  • 手势导航栏是透明的。
  • 状态栏是透明的。
  • 除非内容应用了边衬区或内边距,否则内容将绘制在系统栏(例如导航栏、状态栏和标题栏)后面。

这样可确保无边框设计作为提高应用质量的一种手段不会被忽视,并减少对应用采用无边框设计所需的工作量。不过,这项变更可能会对应用产生负面影响。我们稍后会举例说明将目标 SDK 升级到 Android 15 后,对 SociaLite 造成的两个负面影响。

将目标 SDK 值更改为 Android 15

  1. 在 SociaLite 应用的 build.gradle 文件中,将目标 SDK 版本和编译 SDK 版本更改为 Android 15 或 VanillaIceCream

如果您是在 Android 15 稳定版发布之前学习此 Codelab,代码将如下所示:

android {
    namespace = "com.google.android.samples.socialite"
    compileSdkPreview = "VanillaIceCream"

    defaultConfig {
        applicationId = "com.google.android.samples.socialite"
        minSdk = 21
        targetSdkPreview = "VanillaIceCream"
        ...
    }
...
}

如果您是在 Android 15 稳定版发布之后学习此 Codelab,代码将如下所示:

android {
    namespace = "com.google.android.samples.socialite"
    compileSdk = 35

    defaultConfig {
        applicationId = "com.google.android.samples.socialite"
        minSdk = 21
        targetSdk = 35
        ...
    }
...
}
  1. 重新构建 SociaLite 并观察以下问题:
  • 三按钮导航背景保护与导航栏不匹配。对于手势导航,您无需执行任何操作,Chats 屏幕即会实现无边框设计。不过,仍然存在三按钮导航背景保护,应予以移除。

采用三按钮导航的“Chats”屏幕。

采用手势导航的“Chats”屏幕。

采用三按钮导航的 Chats 屏幕

采用手势导航的 Chats 屏幕

  • 界面被遮挡。对话的底部界面元素被导航栏遮挡。这种情况在三按钮导航中最为明显。

谈论狗狗的聊天消息(三按钮导航)。

谈论狗狗的聊天消息(手势导航)。

谈论狗狗的聊天消息(三按钮导航)

谈论狗狗的聊天消息(手势导航)

修正 SociaLite

如需移除默认的三按钮导航背景保护,请按以下步骤操作:

  1. MainActivity.kt 文件中,将 window.isNavigationBarContrastEnforced 属性设为 false,即可移除默认的背景保护。
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        installSplashScreen()
        super.onCreate(savedInstanceState)
        setContent {
            // Add this block:
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                window.isNavigationBarContrastEnforced = false
            }
        }
    }
    ...
}

window.isNavigationBarContrastEnforced 可确保在请求完全透明的背景时,导航栏具有足够的对比度。通过将此属性设置为 false,您可以有效地将三按钮导航背景设置为透明。window.isNavigationBarContrastEnforced 只会影响三按钮导航,不会影响手势导航。

  1. 重新运行该应用,并在 Android 15 设备上查看其中一个对话。TimelineChatsSettings 屏幕现在都会显示为无边框。应用的 NavigationBar(包含 TimelineChatsSettings 按钮)会在系统的透明三按钮导航栏后方绘制。

采用三按钮导航的“Chats”屏幕,条带已移除。

谈论狗狗的对话(手势导航)。

移除了条带的 Chats 屏幕

手势导航没有任何变化

但请注意,对话的 InputBar 仍被系统栏遮挡。您需要正确处理边衬区,才能解决此问题。

谈论狗狗的对话(三按钮导航)。

谈论狗狗的对话(手势导航)。

谈论狗狗的对话(三按钮导航)。底部的输入字段被系统的导航栏遮挡。

谈论狗狗的对话(手势导航)。底部的输入字段被系统的导航栏遮挡。

在 SociaLite 中,InputBar 会被遮挡。在实际操作中,当您旋转到横屏模式或使用大屏设备时,可能会发现顶部、底部、左侧和右侧的元素被遮挡。因此,请针对所有这些用例,考虑如何处理边衬区。对于 SociaLite,您可以应用内边距来抬高 InputBar 的可点按内容。

如需应用边衬区来解决界面被遮挡的问题,请按以下步骤操作:

  1. 打开 ui/chat/ChatScreen.kt 文件,在第 178 行附近找到 ChatContent 可组合函数,其中包含对话屏幕的界面。ChatContent 可利用 Scaffold 轻松构建界面。默认情况下,Scaffold 会以边衬区的形式提供关于系统界面的信息(例如系统栏的深度),您可以通过 Scaffold 的内边距值(innerPadding 参数)来使用这些信息。使用 ScaffoldinnerPaddingInputBar 添加内边距。
  2. ChatContent 中找到 InputBar(位于第 214 行附近)。这是一个自定义的可组合函数,用于创建供用户编写消息的界面。预览如下所示:

PreviewInputBar。

InputBar 获取了 contentPadding,并将其作为内边距应用到包含界面其余部分的 Row 可组合函数中。该内边距将应用于 Row 可组合函数的所有边。您可以在第 432 行附近看到这一点。以下是 InputBar 可组合函数,以供参考(请勿添加此代码):

// Don't add this code because it's only for reference.
@Composable
private fun InputBar(
    contentPadding: PaddingValues,
    ...,
) {
    Surface(...) {
        Row(
            modifier = Modifier
                .padding(contentPadding)
            ...
        ) {
            IconButton(...) { ... } // take picture
            IconButton(...) { ... } // attach picture
            TextField(...) // write message
            FilledIconButton(...){ ... } // send message
            }
        }
    }
}
  1. 返回到 ChatContent 中的 InputBar 并更改 contentPadding,以便使用系统栏边衬区。大约是在第 220 行附近。
InputBar(
    ...
    contentPadding = innerPadding, //Add this line.
    // contentPadding = PaddingValues(0.dp), // Remove this line.
    ...
 )
  1. 在 Android 15 设备上重新运行该应用。

谈论狗狗的对话(三按钮导航)。

谈论狗狗的对话(手势导航)。

谈论狗狗的对话(三按钮导航),其中错误地应用了边衬区。

谈论狗狗的对话(手势导航),其中错误地应用了边衬区。

应用了底部内边距,按钮不会再被系统栏遮挡;但同时也应用了顶部内边距。顶部内边距包含 TopAppBar 和系统栏的深度。Scaffold 会将内边距值传递给它的内容,这样就可以同时避开顶部应用栏和系统栏。

  1. 如需修正顶部内边距,可创建 innerPadding PaddingValues 的副本,将顶部内边距设置为 0.dp,然后将修改后的副本传入 contentPadding
InputBar(
    ...
    contentPadding = innerPadding.copy(layoutDirection, top = 0.dp), //Add this line.
    // contentPadding = innerPadding, // Remove this line.
    ...
 )
  1. 在 Android 15 设备上重新运行该应用。

谈论狗狗的对话(三按钮导航)。

谈论狗狗的对话(手势导航)。

谈论狗狗的对话(三按钮导航),已正确应用边衬区。

谈论狗狗的对话(手势导航),已正确应用边衬区。

恭喜!您已让 SociaLite 兼容了 Android 15 无边框平台变更。接下来,您将学习如何以向后兼容的方式使 SociaLite 采用无边框设计。

4. 以向后兼容的方式使 SociaLite 采用无边框设计

现在,SociaLite 已在 Android 15 上采用无边框设计,但在旧版 Android 设备上却并非如此。若要让 SociaLite 在旧版 Android 设备上采用无边框设计,请先调用 enableEdgeToEdge,然后再设置 MainActivity.kt 文件中的内容。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        installSplashScreen()
        enableEdgeToEdge() // Add this line.
        window.isNavigationBarContrastEnforced = false
        super.onCreate(savedInstanceState)
        setContent {... }
    }
}

enableEdgeToEdge 的导入内容为 import androidx.activity.enableEdgeToEdge。依赖项为 AndroidX Activity 1.8.0 或更高版本。

如需深入了解如何以向后兼容的方式使应用采用无边框设计以及如何处理边衬区,请参阅以下指南:

该开发者在线课程中关于无边框的部分到此就结束了。下一部分为可选内容,讨论了有关无边框的其他注意事项,可能也适用于您的应用。

5. 可选:有关无边框的其他注意事项

针对不同架构处理边衬区

组件

您可能已经注意到,在我们更改了目标 SDK 值后,SociaLite 中的许多组件并没有发生变化。这是因为 SociaLite 遵循了最佳实践进行架构设计,因此处理这种平台变更非常简单。最佳实践包括以下几点:

滚动内容

应用可能包含一些列表;受 Android 15 变更的影响,列表中的最后一项可能会被系统的导航栏遮挡。

最后一个列表项被三按钮导航栏遮挡的应用。

上图显示了列表中的最后一项被三按钮导航栏遮挡住。

使用 Compose 滚动内容

在 Compose 中,您可以使用 LazyColumncontentPadding 为最后一项内容增加空间,但使用 TextField 的情况除外:

Scaffold { innerPadding ->
    LazyColumn(
        contentPadding = innerPadding
    ) {
        // Content that does not contain TextField
    }
}

最后一个列表项未被三按钮导航栏遮挡的应用。

上图显示了列表中的最后一项未被三按钮导航栏遮挡住。

对于 TextField,请使用 SpacerLazyColumn 中绘制最后一个 TextField。如需了解详情,请参阅边衬区使用

LazyColumn(
    Modifier.imePadding()
) {
    // Content with TextField
    item {
        Spacer(
            Modifier.windowInsetsBottomHeight(
                WindowInsets.systemBars
            )
        )
    }
}

使用 View 滚动内容

对于 RecyclerViewNestedScrollView,请添加 android:clipToPadding="false"

<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/recycler"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false"
    app:layoutManager="LinearLayoutManager" />

您可以使用 setOnApplyWindowInsetsListener 从窗口边衬区提供左侧、右侧和底部内边距:

ViewCompat.setOnApplyWindowInsetsListener(binding.recycler) { v, insets ->
    val i = insets.getInsets(
        WindowInsetsCompat.Type.systemBars() + WindowInsetsCompat.Type.displayCutout()
    )
    v.updatePadding(
        left = i.left,
        right = i.right,
        bottom = i.bottom + bottomPadding,
    )
    WindowInsetsCompat.CONSUMED
}

使用 LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS

在指定 SDK 35 为目标平台之前,横屏模式下的 SocialLite 如下图所示:左侧边缘有一个为摄像头刘海屏预留的大白框。在三按钮导航中,按钮会位于右侧。

横屏模式下的 SociaLite 应用。

在指定 SDK 35 为目标平台之后,SociaLite 将如下图所示:左侧边缘不再有为摄像头刘海屏预留的大白框。为了实现这种效果,Android 会自动设置 LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS横屏模式下的 SociaLite 应用。

根据您的应用,您可能需要在此处处理边衬区。

如需在 SociaLite 中执行此操作,请按以下步骤操作:

  1. ui/ContactRow.kt 文件中,找到 Row 可组合函数。
  2. 修改内边距,以适应刘海屏。
@Composable
fun ChatRow(
   chat: ChatDetail,
   onClick: (() -> Unit)?,
   modifier: Modifier = Modifier,
) {
   // Add layoutDirection, displayCutout, startPadding, and endPadding.
   val layoutDirection = LocalLayoutDirection.current
   val displayCutout = WindowInsets.displayCutout.asPaddingValues()
   val startPadding = displayCutout.calculateStartPadding(layoutDirection)
   val endPadding = displayCutout.calculateEndPadding(layoutDirection)
   Row(
       modifier = modifier
           ...
           // .padding(16.dp) // Remove this line.
           // Add this block:
           .padding(
               PaddingValues(
                   top = 16.dp,
                   bottom = 16.dp,
                   // Ensure content is not occluded by display cutouts
                   // when rotating the device.
                   start = startPadding.coerceAtLeast(16.dp),
                   end = endPadding.coerceAtLeast(16.dp)
               )
           ),
       ...
   ) { ... }

处理刘海屏后,SociaLite 将如下所示:

横屏模式下的 SociaLite 应用。

您可以在开发者选项屏幕的刘海屏下测试各种刘海屏配置。

如果应用具有使用 LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULTLAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVERLAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES 的非浮动窗口(例如 Activity),则从 Android 15 Beta 2 开始,Android 会将这些刘海模式解读为 LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS。以前,在 Android 15 Beta 1 中,您的应用会崩溃。

标题栏也是系统栏

标题栏也是一种系统栏,用于描述自由窗口的系统界面窗口装饰,例如顶部标题栏。您可以在 Android Studio 的桌面模拟器中查看标题栏。在下面的屏幕截图中,标题栏位于应用顶部。

显示标题栏的模拟器。

在 Compose 中,如果您使用的是 ScaffoldPaddingValuessafeContentsafeDrawing 或内置的 WindowInsets.systemBars,您的应用将按预期显示。不过,如果您使用 statusBar 处理边衬区,那么应用内容可能无法按预期显示,因为状态栏不会考虑标题栏。

在 View 中,如果您使用 WindowInsetsCompat.systemBars 手动处理边衬区,您的应用将按预期显示。如果您使用 WindowInsetsCompat.statusBars 手动处理边衬区,您的应用可能无法按预期显示,因为状态栏并非标题栏。

处于沉浸模式的应用

处于沉浸模式的屏幕基本不受 Android 15 强制执行的无边框措施的影响,因为沉浸式应用已经采用无边框设计。

保护系统栏

您可能希望应用在手势导航时使用透明栏,而在三按钮导航时使用半透明或不透明栏。

在 Android 15 中,默认使用半透明的三按钮导航栏,因为该平台会将 window.isNavigationBarContrastEnforced 属性设置为 true。手势导航则保持透明。

采用三按钮导航的应用。

默认情况下,三按钮导航栏是半透明的。

一般来说,半透明的三按钮导航栏应该就足够了。不过,在某些情况下,应用可能需要不透明的三按钮导航栏。此时,请先将 window.isNavigationBarContrastEnforced 属性设置为 false。然后,使用 WindowInsetsCompat.tappableElement(针对 View)或 WindowInsets.tappableElement(针对 Compose)。如果这些值为 0,则表示用户正在使用手势导航。否则,用户使用的是三按钮导航。如果用户使用的是三按钮导航,请在导航栏后面绘制一个视图或框。Compose 示例可能如下所示:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            window.isNavigationBarContrastEnforced = false
            MyTheme {
                Surface(...) {
                    MyContent(...)
                    ProtectNavigationBar()
                }
            }
        }
    }
}

// Use only if required.
@Composable
fun ProtectNavigationBar(modifier: Modifier = Modifier) {
   val density = LocalDensity.current
   val tappableElement = WindowInsets.tappableElement
   val bottomPixels = tappableElement.getBottom(density)
   val usingTappableBars = remember(bottomPixels) {
       bottomPixels != 0
   }
   val barHeight = remember(bottomPixels) {
       tappableElement.asPaddingValues(density).calculateBottomPadding()
   }

   Column(
       modifier = modifier.fillMaxSize(),
       verticalArrangement = Arrangement.Bottom
   ) {
       if (usingTappableBars) {
           Box(
               modifier = Modifier
                   .background(MaterialTheme.colorScheme.background)
                   .fillMaxWidth()
                   .height(barHeight)
           )
       }
   }
}

采用三按钮导航的应用。

不透明的三按钮导航栏

6. 查看解决方案代码

MainActivity.kt 文件的 onCreate 方法应如下所示:

class MainActivity : ComponentActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       installSplashScreen()
       enableEdgeToEdge()
       window.isNavigationBarContrastEnforced = false
       super.onCreate(savedInstanceState)
       setContent {
           Main(
               shortcutParams = extractShortcutParams(intent),
           )
       }
   }
}

ChatScreen.kt 文件中的 ChatContent 可组合函数应处理边衬区:

private fun ChatContent(...) {
   ...
   Scaffold(...) { innerPadding ->
       Column {
           ...
           InputBar(
               input = input,
               onInputChanged = onInputChanged,
               onSendClick = onSendClick,
               onCameraClick = onCameraClick,
               onPhotoPickerClick = onPhotoPickerClick,
               contentPadding = innerPadding.copy(
                    layoutDirection, top = 0.dp
                ),
               sendEnabled = sendEnabled,
               modifier = Modifier
                   .fillMaxWidth()
                   .windowInsetsPadding(
                       WindowInsets.ime.exclude(WindowInsets.navigationBars)
                    ),
            )
       }
   }
}

可在 main 分支中获取解决方案代码。如果您已下载 SociaLite,请执行以下命令:

git checkout main

如果还未下载,您可以再次下载该代码,以直接或通过 git 查看 main 分支:

git clone git@github.com:android/socialite.git