作者 / Ash Nohe
查阅更多关于 "Spotlight Week: Android 15" 的概述 https://android-developers.googleblog.com/2024/09/android-15-spotlight-week.html
△ 图 3. 以 SDK 35 为目标的应用在 Android 15 设备上运行。应用实现了无边框显示,但没有处理边衬区。状态栏、导航栏和刘海屏会遮挡界面。
将应用绘制为无边框 https://developer.android.google.cn/about/versions/15/behavior-changes-15#edge-to-edge
本文将介绍以下适用于 Compose 和 View 的技巧:
我们还将介绍以下 Compose 专门技巧:
最后,我们将介绍以下针对 View 的专门技巧:
1. 使用 Material 组件简化处理边衬区
自动处理边衬区的 Material 3 组件 (androidx.compose.material3) 包括:
BottomAppBar CenterAlignedTopAppBar DismissibleDrawerSheet LargeTopAppBar MediumTopAppBar ModalBottomSheet ModalDrawerSheet NavigationBar NavigationRail NavigationSuiteScaffold PermanentDrawerSheet TopAppBar SmallTopAppBar
Material 3 组件 https://m3.material.io/blog/migrating-material-3 BottomAppBar https://link.gevents.cn/MPUUrH TopAppBar https://link.gevents.cn/vnxAz5 BottomNavigation https://link.gevents.cn/e6tCpe NavigationRail https://link.gevents.cn/Y3mCpa Scaffold https://link.gevents.cn/xfTJvw
自动处理边衬区的 Material View 组件 (com.google.android.material) 包括:
BottomAppBar BottomNavigationView NavigationRailView NavigationView
2. 绘制无边框背景,并内嵌关键界面元素
△ 图 5. 以 SDK 34 为目标的应用在 Android 15 设备上运行。应用设置了状态栏和导航栏颜色,这种做法在以 SDK 35 为目标进行开发后将不再受支持。状态栏和导航栏的颜色特意设置为略有不同的色调,以便区分系统栏和应用栏,但通常应用会为系统栏和应用栏使用相同的颜色,以实现无边框的视觉效果。
3. 处理刘海屏和标题栏的边衬区
刘海屏
在 Compose 中处理刘海屏
// Shows using WindowInsets.displayCutout for fine-grained control.
// But, it's often sufficient and easier to instead use
// WindowInsets.safeContent, WindowInsets.safeDrawing, or WindowInsets.safeGestures
fun ChatRow(...) {
val layoutDirection = LocalLayoutDirection.current
val displayCutout = WindowInsets.displayCutout.asPaddingValues()
val startPadding = displayCutout.calculateStartPadding(layoutDirection)
val endPadding = displayCutout.calculateEndPadding(layoutDirection)
Row(
modifier = modifier.padding(
PaddingValues(
top = 16.dp,
bottom = 16.dp,
// Ensure content is not hidden by display cutouts
// when rotating the device.
start = startPadding.coerceAtLeast(16.dp),
end = endPadding.coerceAtLeast(16.dp)
)
),
...
) { ... }
△ 图 9. 以 SDK 35 为目标的应用在 Android 15 设备上运行。刘海屏边衬区已处理。注意: 理想情况下,"Chats" 也应当向右添加内边距。
开发者选项 https://developer.android.google.cn/studio/debug/dev-options
应对 Android 15 默认采用无边框设计 https://developer.android.google.cn/codelabs/edge-to-edge#4
在 View 中处理刘海屏
文档 https://developer.android.google.cn/develop/ui/views/layout/edge-to-edge#cutout-insets
LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT https://developer.android.google.cn/reference/android/view/WindowManager.LayoutParams#LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER https://developer.android.google.cn/reference/android/view/WindowManager.LayoutParams#LAYOUT_IN_DISPLAY_CUTOUT_MODE_NEVER LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES https://developer.android.google.cn/reference/android/view/WindowManager.LayoutParams#LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS https://developer.android.google.cn/reference/android/view/WindowManager.LayoutParams#LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
标题栏
△ 图 10. 桌面模拟器中的标题栏。
4. 不要遗漏最后一个列表项
在 Compose 中处理最后一个列表项
Scaffold { innerPadding ->
LazyColumn(
contentPadding = innerPadding
{
Content that does not contain TextField
}
}
contentPadding https://developer.android.google.cn/develop/ui/compose/lists#content-padding
LazyColumn(
Modifier.imePadding()
) {
// Content with TextField
item {
Spacer(
Modifier.windowInsetsBottomHeight(
WindowInsets.systemBars
)
)
}
}
边衬区消耗 https://developer.android.google.cn/develop/ui/compose/layouts/insets#inset-consumption
在 View 中处理最后一个列表项
△ 图 11. 使用 RecyclerView 的应用。左图: 以 SDK 34 为目标。最后一个列表项显示在 "三按钮" 导航栏之上。右图: 以 SDK 35 为目标。最后一个列表项绘制在 "三按钮" 导航栏之下。
// Figure 12
ViewCompat.setOnApplyWindowInsetsListener(
findViewById(R.id.recycler_view)
) { v, insets ->
val innerPadding = insets.getInsets(
// Notice we're using systemBars, not statusBar
WindowInsetsCompat.Type.systemBars()
// Notice we're also accounting for the display cutouts
or WindowInsetsCompat.Type.displayCutout()
// If using EditText, also add
// "or WindowInsetsCompat.Type.ime()"
// to maintain focus when opening the IME
)
v.setPadding(
innerPadding.left,
innerPadding.top,
innerPadding.right,
innerPadding.bottom)
insets
}
<!-- Figure 13 -->
<RecyclerView
...
android:clipToPadding="false" />
5. 不要忽略 IME
android:windowSoftInputMode="adjustResize" https://developer.android.google.cn/guide/topics/manifest/activity-element#wsoft
在 Compose 中处理 IME 边衬区
Modifier.imePadding() https://developer.android.google.cn/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).imePadding() 边衬区消耗 https://developer.android.google.cn/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).imePadding()
在 View 中处理 IME 边衬区
WindowInsetsCompat.Type.ime() https://developer.android.google.cn/reference/androidx/core/view/WindowInsetsCompat.Type#ime()
6. 为了向后兼容,请使用
enableEdgeToEdge 而不是
setDecorFitsSystemWindows
enableEdgeToEdge https://developer.android.google.cn/develop/ui/views/layout/edge-to-edge#enable-edge-to-edge-display setDecorFitsSystemWindows https://developer.android.google.cn/develop/ui/views/layout/edge-to-edge-manually 100 行代码 https://medium.com/androiddevelopers/is-your-app-providing-a-backward-compatible-edge-to-edge-experience-2479267073a0
7. 仅在必要时使用后台保护系统栏
已弃用 https://developer.android.google.cn/about/versions/15/behavior-changes-15#deprecated-apis
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
window.isNavigationBarContrastEnforced = false
MyTheme {
Surface(...) {
MyContent(...)
ProtectNavigationBar()
}
}
}
}
}
// Use only if required.
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)
)
}
}
}
WindowInsets.tappableElement https://developer.android.google.cn/reference/kotlin/androidx/compose/foundation/layout/WindowInsets.Companion#(androidx.compose.foundation.layout.WindowInsets.Companion).tappableElement()
Jetpack Compose https://developer.android.google.cn/jetpack/compose 无边框和边衬区 | Compose 技巧 https://www.youtube.com/watch?v=QRzepC9gHj4
8. 使用 Scaffold 的 PaddingValues
Scaffold https://developer.android.google.cn/develop/ui/compose/layouts/insets#scaffold
△ 图 14. 左图: 当以 SDK 35 为目标时,底部的输入字段被系统的导航栏遮挡。中间: 使用了 Scaffold 的 PaddingValues 来处理输入字段。系统根据状态栏和顶部应用栏的大小来计算顶部内边距值,这导致输入字段上方产生了多余的内边距。右图: 使用了 Scaffold 的 PaddingValues,但手动去除了顶部的内边距。
// Causes excess padding, seen in the middle image of Figure 14.
Scaffold { innerPadding -> // innerPadding is Scaffold's PaddingValues
InputBar(
...
contentPadding = innerPadding
) {...}
}
// Function to make a copy of PaddingValues, using existing defaults unless an
// alternative value is specified
private fun PaddingValues.copy(
layoutDirection: LayoutDirection,
start: Dp? = null,
top: Dp? = null,
end: Dp? = null,
bottom: Dp? = null,
) = PaddingValues(
start = start ?: calculateStartPadding(layoutDirection),
top = top ?: calculateTopPadding(),
end = end ?: calculateEndPadding(layoutDirection),
bottom = bottom ?: calculateBottomPadding(),
)
// Produces correct padding, seen in the right-side image of Figure 14.
Scaffold { innerPadding -> // innerPadding is Scaffold's PaddingValues
val layoutDirection = LocalLayoutDirection.current
InputBar(
...
contentPadding = innerPadding.copy(layoutDirection, top = 0.dp)
) {...}
}
9. 使用高阶 WindowInsets API
类似于 Scaffold 的 PaddingValues,您也可以使用高阶 WindowInset API 来轻松且安全地绘制关键界面元素。这些 API 包括:
WindowInsets.safeDrawing https://developer.android.google.cn/reference/kotlin/androidx/compose/foundation/layout/package-summary#(androidx.compose.foundation.layout.WindowInsets.Companion).safeDrawing() WindowInsets.safeGestures https://developer.android.google.cn/reference/kotlin/androidx/compose/foundation/layout/package-summary#(androidx.compose.foundation.layout.WindowInsets.Companion).safeGestures() WindowInsets.safeContent https://developer.android.google.cn/reference/kotlin/androidx/compose/foundation/layout/package-summary#(androidx.compose.foundation.layout.WindowInsets.Companion).safeContent()
边衬区基础知识 https://developer.android.google.cn/develop/ui/compose/layouts/insets
10. 优先使用 ViewCompat.setOn
ApplyWindowInsetsListener
而不是 fitsSystemWindows=true
您可以使用 fitsSystemWindows=true 来内嵌应用的内容。这是一个简单的单行代码更改。但是,请勿在包含整个布局 (包括背景) 的 View 上使用 fitsSystemWindows。这会使应用看起来不是无边框的,因为 fitsSystemWindows 会在所有边缘处理边衬区。
△ 图 16. 默认采用无边框设计,fitsSystemWindows=true。左侧边缘的间隙是由于刘海屏造成的,这里没有显示出来。这不是我们期望的结果,因为应用看起来不是无边框的。
<!-- Figure 17 -->
<CoordinatorLayout
android:fitsSystemWindows="true"
...>
<AppBarLayout
android:fitsSystemWindows="true"
...>
<TextView
android:text="App Bar Layout"
.../>
</AppBarLayout>
</CoordinatorLayout>
覆盖 fitsSystemWindows 时,CoordinatorLayout 和 AppBarLayout 对象具有以下行为:
CoordinatorLayout: 如果子视图也设置了 fitsSystemWindows=true,则这些视图的背景会绘制在系统栏下方。系统会自动为这些 View 的内容 (如文本、图标、图片) 应用内边距,以适应系统栏和刘海屏。 AppBarLayout: 如果设置了 fitsSystemWindows=true,则系统会将内容绘制在系统栏下方,并自动为内容应用顶部内边距。
11. 在布局阶段根据
应用栏的高度应用边衬区
val myScrollView = findViewById<NestedScrollView>(R.id.my_scroll_view)
val myAppBar = findViewById<AppBarLayout>(R.id.my_app_bar_layout)
{ scrollView, windowInsets ->
val insets = windowInsets.getInsets(
or WindowInsetsCompat.Type.displayCutout()
)
{ appBar ->
scrollView.updatePadding(
left = insets.left,
right = insets.right,
top = appBar.height,
bottom = insets.bottom
)
}
WindowInsetsCompat.CONSUMED
}
<!-- In values-v35.xml -->
<resources>
<!-- TODO: Remove once activities handle insets. -->
<style name="OptOutEdgeToEdgeEnforcement">
<item name="android:windowOptOutEdgeToEdgeEnforcement">true</item>
</style>
</resources>
<!-- In values.xml -->
<resources>
<!-- TODO: Remove once activities handle insets. -->
<style name="OptOutEdgeToEdgeEnforcement">
<!-- android:windowOptOutEdgeToEdgeEnforcement
isn't supported before SDK 35. This empty
style enables programmatically opting-out. -->
</style>
</resources>
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
// Call before the DecorView is accessed in setContentView
theme.applyStyle(R.style.OptOutEdgeToEdgeEnforcement, /* force */ false)
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
...
}
}
Android 15 的无边框设计要求 https://developer.android.google.cn/about/versions/15/behavior-changes-15#edge-to-edge
以 Android 15 为目标平台的应用的行为变更: 默认采用无边框设计 https://developer.android.google.cn/about/versions/15/behavior-changes-15#edge-to-edge Compose 中的窗口边衬区 https://developer.android.google.cn/develop/ui/compose/layouts/insets Compose 中的刘海屏 https://developer.android.google.cn/develop/ui/compose/system/cutouts 在应用中显示无边框内容 (View) https://developer.android.google.cn/develop/ui/views/layout/edge-to-edge 支持刘海屏 (View) https://developer.android.google.cn/develop/ui/views/layout/display-cutout
无边框和边衬区 (Compose) https://www.youtube.com/watch?v=QRzepC9gHj4 边衬区: Compose 版本 https://www.youtube.com/watch?v=mlL6H-s0nF0 演示视频: 提升 Android 应用用户体验的 3 件事: 应对 Android 15 中的无边框设计要求 (Compose) https://m.youtube.com/watch?v=RimGfoOU67s
应对 Android 15 的无边框设计要求 (Compose) https://developer.android.google.cn/codelabs/edge-to-edge#0