mcpField
E
edge-to-edge

edge-to-edge

github.com/android设计与 UI10.0k3.3k 次安装2 天前更新
代码仓库

关于 edge-to-edge

面向 Android Jetpack Compose 项目的技能,指导将 Activity、列表和输入界面改造为 edge-to-edge 布局,并正确处理系统栏与 IME 内边距。要求 target SDK 35 或更高。

主要功能

  1. 01检查 Activity、列表和 FAB 的 edge-to-edge 支持
  2. 02配置 enableEdgeToEdge 与 adjustResize
  3. 03使用 Scaffold 和系统 Insets 管理布局
  4. 04处理 IME 遮挡与重复内边距
  5. 05适配对话框及系统栏图标对比度

适用场景

  1. 01为 Compose 应用补充 edge-to-edge 支持
  2. 02修复软键盘遮挡输入框的问题
  3. 03确保列表和 FAB 避开系统栏

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/android/skills --skill edge-to-edge

安装后在 Claude Code 中输入/edge-to-edge确认已加载。

详细介绍

前提条件

  • 项目 必须 使用 Android Jetpack Compose。
  • 项目 必须 以 SDK 35 或更高版本为目标。如果 SDK 低于 35,请将 SDK 提升至 35。

步骤 1:规划

  1. 定位并分析所有 Activity 类,检查哪些类已支持 edge-to-edge。对于尚未支持 edge-to-edge 的 Activity,规划为每个 Activity 添加该支持。
  2. 在每个 Activity 中,定位并分析所有列表和 FAB 组件,检查哪些组件已支持 edge-to-edge。对于尚未支持 edge-to-edge 的组件,规划为每个组件添加该支持。
  3. 在每个 Activity 中,扫描 TextField、OutlinedTextField 或 BasicTextField。如果找到,则 必须 按照此技能中的 IME 部分进行验证,确保 IME 不会遮挡输入框。

步骤 2:添加 edge-to-edge 支持

  1. 对于尚未调用 enableEdgeToEdge 的每个 Activity,在其 onCreate 中的 setContent 之前添加 enableEdgeToEdge。
  2. 在所有使用软键盘的 Activity 的 AndroidManifest.xml 中添加 android:windowSoftInputMode="adjustResize"。

步骤 3:应用 insets

  • 应用 必须 应用系统 insets,或使内容与标尺对齐,以确保关键 UI 仍可点击。只选择一种方法,避免重复添加内边距:

    1. 首选: 在可用时,使用 Scaffold 并将 PaddingValues 传递给内容 lambda。
    Scaffold { innerPadding ->
        // innerPadding accounts for system bars and any Scaffold components
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .consumeWindowInsets(innerPadding),
            contentPadding = innerPadding
        ) { /* Content */ }
    }
    

    1. 首选: 在可用时,使用 Material 组件中的自动 inset 处理或内边距修饰符。

      • Material 3 组件会为自身组件管理安全区域,包括:
        • TopAppBar
        • SmallTopAppBar
        • CenterAlignedTopAppBar
        • MediumTopAppBar
        • LargeTopAppBar
        • BottomAppBar
        • ModalDrawerSheet
        • DismissibleDrawerSheet
        • PermanentDrawerSheet
        • ModalBottomSheet
        • NavigationBar
        • NavigationRail
      • 对于 Material 2 组件,使用 windowInsetsparameter 为 BottomAppBar、TopAppBar 和 BottomNavigation 手动应用 insets。不要 为父容器添加内边距,而应直接将 insets 传递给 App Bar 组件。为父容器添加内边距会阻止 App Bar 背景绘制到系统栏区域。例如,对于 TopAppBar,只选择以下一种选项:
        1. 首选: TopAppBar(windowInsets = AppBarDefaults.topAppBarWindowInsets)
        2. TopAppBar(windowInsets = WindowInsets.systemBars.exclude(WindowInsets.navigationBars))
        3. TopAppBar(windowInsets = WindowInsets.systemBars.add(WindowInsets.captionBar))
    2. 对于 Scaffold 外部的组件,请使用内边距修饰符,例如 Modifier.safeDrawingPadding() 或 Modifier.windowInsetsPadding(WindowInsets.safeDrawing)。

      Box(
          modifier = Modifier
              .fillMaxSize()
              .safeDrawingPadding()
      ) {
          Button(
              onClick = {},
              modifier = Modifier.align(Alignment.BottomCenter)
          ) {
              Text("Login")
          }
      }
      

    3. 对于内边距过多的深层嵌套组件,请使用 WindowInsetsRulers(例如 Modifier.fitInside(WindowInsetsRulers.SafeDrawing.current))。代码示例请参阅 IME 部分。

    4. 如果需要让某个元素(例如自定义标题栏或装饰性遮罩层)的尺寸 与系统栏相同,请使用 inset 尺寸修饰符(例如 Modifier.windowInsetsTopHeight(WindowInsets.systemBars))。 代码示例请参阅 列表 部分。

自适应脚手架

  • NavigationSuiteScaffold 会为其自身组件(如 NavigationRail 或 NavigationBar)处理安全区域。不过,自适应脚手架(例如 NavigationSuiteScaffold、ListDetailPaneScaffold)不会将 PaddingValues 传递给其内部内容。你必须按照步骤 3中的说明,为各个屏幕或组件应用内边距(例如列表的 contentPadding 或 FAB 内边距)。不要对 NavigationSuiteScaffold 父级应用 safeDrawingPadding 或类似修饰符。这会裁剪内容,并阻止屏幕实现 edge-to-edge。

IME

  • 对于每个使用软键盘的 Activity,请检查 AndroidManifest.xml 中是否设置了 android:windowSoftInputMode="adjustResize"。不要使用已弃用的 SOFT_INPUT_ADJUST_RESIZE。然后,保持输入字段处于焦点状态。选择以下一种方式:
      1. **首选:**在内容容器中添加 Modifier.fitInside(WindowInsetsRulers.Ime.current)。相比 imePadding(),这是首选方案,因为它可以减少卡顿,以及因忘记在层级上游消耗内边距而导致的额外内边距。
      1. 在内容容器中添加 imePadding。此内边距修饰符必须放在 Modifier.verticalScroll() 之前。如果父级已通过 contentWindowInsets(例如 contentWindowInsets = WindowInsets.safeDrawing)处理 IME,请勿使用 Modifier.imePadding()。否则会产生双重内边距。
使用 Scaffold 的 IME 代码模式
正确

正确,因为 contentWindowInsets 包含 IME 内边距,并将其作为 innerPadding 传递给内容 lambda。

// RIGHT
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding ->
    Column(
        modifier = Modifier
            .padding(innerPadding)
            .consumeWindowInsets(innerPadding)
            .verticalScroll(rememberScrollState())
    ) { /* Content */ }
}


正确,因为无论 contentWindowInsets 如何设置,fitInside 都会使内容适配 IME 内边距。

// RIGHT
Scaffold() { innerPadding ->
    Column(
        modifier = Modifier
            .padding(innerPadding)
            .consumeWindowInsets(innerPadding)
            .fitInside(WindowInsetsRulers.Ime.current)
            .verticalScroll(rememberScrollState())
    ) { /* Content */ }
}


正确,因为默认的 contentWindowInsets 不包含 IME 内边距,而 imePadding() 会应用 IME 内边距:

// RIGHT
Scaffold() { innerPadding ->
    Column(
        modifier = Modifier
            .padding(innerPadding)
            .consumeWindowInsets(innerPadding)
            .imePadding()
            .verticalScroll(rememberScrollState())
    ) { /* Content */ }
}

错误

错误,因为 IME 打开时会产生多余的内边距。IME 内边距被应用了两次:一次通过 innerPadding,其中包含由传入的 contentWindowInsets 值提供的 IME 内边距;另一次通过 imePadding:

// WRONG
Scaffold(contentWindowInsets = WindowInsets.safeDrawing) { innerPadding ->
    Column(
        modifier = Modifier
            .padding(innerPadding)
            .imePadding()
            .verticalScroll(rememberScrollState())
    ) { /* Content */ }
}


错误,因为 IME 会遮挡内容。Scaffold 的默认 contentWindowInsets 不包含 IME 内边距。

// WRONG
Scaffold() { innerPadding ->
    Column(
        modifier = Modifier
            .padding(innerPadding)
            .verticalScroll(rememberScrollState())
    ) { /* Content */ }
}

不使用 Scaffold 的 IME 代码模式
正确

以下代码示例不会导致内边距过大。

// RIGHT
Box(
    // Insets consumed
    modifier = Modifier.safeDrawingPadding() // or imePadding(), safeContentPadding(), safeGesturesPadding()
) {
    Column(
        modifier = Modifier.imePadding()
    ) { /* Content */ }
}


// RIGHT
Box(
    // Insets consumed
    modifier = Modifier.windowInsetsPadding(WindowInsets.safeDrawing) // or WindowInsets.ime, WindowInsets.safeContent, WindowInsets.safeGestures
) {
    Column(
        modifier = Modifier.imePadding()
    ) { /* Content */ }
}


// RIGHT
Box(
    // Insets not consumed, but irrelevant due to fitInside
    modifier = Modifier.padding(WindowInsets.safeDrawing.asPaddingValues()) // or WindowInsets.ime.asPaddingValues(), WindowInsets.safeContent.asPaddingValues(), WindowInsets.safeGestures.asPaddingValues()
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .fitInside(WindowInsetsRulers.Ime.current)
    ) { /* Content */ }
}

错误

以下代码示例会导致内边距过大,因为 IME 内边距被应用了两次:

// WRONG
Box(
    // Insets not consumed
    modifier = Modifier.padding(WindowInsets.safeDrawing.asPaddingValues()) // or WindowInsets.ime.asPaddingValues(), WindowInsets.safeContent.asPaddingValues(), WindowInsets.safeGestures.asPaddingValues()
) {
    Column(
        modifier = Modifier.imePadding()
    ) { /* Content */ }
}

导航栏对比度 & 系统栏图标

  • 如果 Activity 使用 WindowCompat 中的 enableEdgeToEdge,对于支持浅色和深色主题的应用,你必须将 isAppearanceLightNavigationBars 和 isAppearanceLightStatusBars 设置为与设备主题相反的值,以确保系统栏图标清晰可见。建议在主题文件中进行此设置。如果 Activity 使用 ComponentActivity 中的 enableEdgeToEdge,不要进行此设置,因为它会自动处理图标颜色。

    // Only use if calling `enableEdgeToEdge` from `WindowCompat`.
    // Apply to your theme file.
    @Composable
    fun MyTheme(
        darkTheme: Boolean = isSystemInDarkTheme(),
        content: @Composable () -> Unit
    ) {
        val view = LocalView.current
        if (!view.isInEditMode) {
            SideEffect {
                val window = (view.context as? Activity)?.window ?: return@SideEffect
                val controller = WindowCompat.getInsetsController(window, view)
    
                // Dark icons for Light Mode (!darkTheme), Light icons for Dark Mode
                controller.isAppearanceLightStatusBars = !darkTheme
                controller.isAppearanceLightNavigationBars = !darkTheme
            }
        }
    
        MaterialTheme(content = content)
    }
    

  • 如果任何屏幕使用带底部栏(例如 BottomAppBar、NavigationBar)的 Scaffold 或 NavigationSuiteScaffold,请在对应的 Activity 中为 SDK 29 及更高版本设置 window.isNavigationBarContrastEnforced = false。这样可以防止系统为导航栏添加半透明背景,并确保底部栏的颜色延伸至屏幕底部。

列表

  • 将内边距(例如 Scaffold 的 innerPadding)应用于可滚动组件(例如 LazyColumn、LazyRow)的 contentPadding 参数。不要将其作为 Modifier.padding() 应用于列表的父容器,因为这会裁剪内容,并阻止内容在系统栏后方滚动。
  • 创建一个覆盖系统栏的半透明可组合项,以确保图标仍清晰可见。
class SystemBarProtectionSnippets : ComponentActivity() {

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

        // enableEdgeToEdge sets window.isNavigationBarContrastEnforced = true
        // which is used to add a translucent scrim to three-button navigation
        enableEdgeToEdge()

        setContent {
            MyTheme {
                // Main content
                MyContent()

                // After drawing main content, draw status bar protection
                StatusBarProtection()
            }
        }
    }
}

@Composable
private fun StatusBarProtection(
    color: Color = MaterialTheme.colorScheme.surfaceContainer,
) {
    Spacer(
        modifier = Modifier
            .fillMaxWidth()
            .height(
                with(LocalDensity.current) {
                    (WindowInsets.statusBars.getTop(this) * 1.2f).toDp()
                }
            )
            .background(
                brush = Brush.verticalGradient(
                    colors = listOf(
                        color.copy(alpha = 1f),
                        color.copy(alpha = 0.8f),
                        Color.Transparent
                    )
                )
            )
    )
}

## 对话框

如果以下两个条件都满足,则对话框为全屏对话框,必须延伸至屏幕边缘:

  1. DialogProperties 包含 usePlatformDefaultWidth = false。
  2. 对话框调用 Modifier.fillMaxSize()。

若要让全屏对话框延伸至屏幕边缘,请在 DialogProperties 中设置 decorFitsSystemWindows = false。

Dialog(
    onDismissRequest = { /* Handle dismiss */ },
    properties = DialogProperties(
        // 1. Allows the dialog to span the full width of the screen
        usePlatformDefaultWidth = false,
        // 2. Allows the dialog to draw behind status and navigation bars
        decorFitsSystemWindows = false
    )
) { /* Content */ }

检查清单

  • [ ] 每个 Activity 都调用了 enableEdgeToEdge() 吗?
  • [ ] AndroidManifest.xml 中设置了 adjustResize 吗?
  • [ ] 每个 TextField、OutlinedTextField 或 BasicTextField 的父级都设置了 imePadding()、fitInside、Modifier.safeDrawingPadding()、Modifier.safeContentPadding()、Modifier.safeGesturesPadding(),或者将 contentWindowInsets 设置为 WindowInsets.safeDrawing 或 WindowInsets.ime 吗?
  • [] 首个和末尾列表项是否通过将内边距传递给 contentPadding,避开了系统栏?
  • [] FAB 是否通过放置在 Scaffold 内,或应用 Modifier.safeDrawingPadding(),显示在导航栏上方?
  • [] 项目能否构建?运行 ./gradlew build 进行确认。

评论

支持 Markdown