问题描述

在 Android 设备上运行 Capacitor 打包的 Vue 3 应用时,遇到虚拟导航栏(底部返回键、主页键等)和状态栏遮挡应用内容的问题。

问题表现

  • 底部 Tab 导航栏被虚拟导航栏遮挡一部分
  • 顶部内容被状态栏遮挡
  • 页面底部内容贴近虚拟导航栏,没有安全间距

问题根源分析

初始状态

应用使用了沉浸式布局,在 MainActivity.java 中设置了:

WindowCompat.setDecorFitsSystemWindows(getWindow(), false);
getWindow().setStatusBarColor(Color.TRANSPARENT);
getWindow().setNavigationBarColor(Color.TRANSPARENT);

这使得 WebView 内容延伸到状态栏和导航栏后面,实现了全屏显示。

错误的假设

最初尝试使用 CSS 的环境变量来解决:

padding-top: env(safe-area-inset-top, 0);
padding-bottom: env(safe-area-inset-bottom, 0);

问题:Android WebView 不支持 CSS 的 env(safe-area-inset-*) 环境变量

实验验证

通过添加调试日志,在控制台输出:

// 检查 CSS 环境变量
const rootStyles = getComputedStyle(document.documentElement)
console.log('CSS 环境变量原始值:', {
  'safe-area-inset-top': rootStyles.getPropertyValue('safe-area-inset-top'),
  'safe-area-inset-bottom': rootStyles.getPropertyValue('safe-area-inset-bottom'),
})

// 检查实际效果
console.log('body paddingTop:', getComputedStyle(document.body).paddingTop)
console.log('body paddingBottom:', getComputedStyle(document.body).paddingBottom)

实验结果

🔍 [CSS 环境变量] 原始值:[object Object]
  - safe-area-inset-top: "" (空字符串)
  - safe-area-inset-bottom: "" (空字符串)
🔍 [实验结论]: ❌ CSS 环境变量未生效 - 硬性编码起作用

结论

  • CSS env() 环境变量在 Android WebView 中返回空值
  • 之前看到的 padding 效果是 CSS 中硬性编码的数值在起作用
  • 底部的 env(safe-area-inset-bottom, 0) fallback 到 0,导致没有底部间距

最终解决方案

方案核心:JavaScript 动态估算 + CSS 变量

由于 CSS 环境变量不生效,改用 JavaScript 在运行时动态计算安全区域高度,并通过 CSS 变量传递给样式层。

实现步骤

1. JavaScript 动态计算(src/main.ts)
// Capacitor 安全区域适配
const setupSafeArea = async () => {
  try {
    const { Capacitor } = await import('@capacitor/core')
    
    // 只在原生平台上执行
    if (!Capacitor.isNativePlatform()) {
      console.log('ℹ️ 非原生平台,跳过设置')
      return
    }

    console.log('🔍 [安全区域] 检测到原生平台,开始设置...')

    // 获取屏幕尺寸
    const screenWidth = window.screen.width
    const screenHeight = window.screen.height
    
    // 通过屏幕比例估算状态栏和导航栏高度
    // 状态栏通常是屏幕高度的 3-5% 或固定值(约 24-50dp)
    // 导航栏通常是 48-56dp
    const estimatedStatusBarHeight = Math.min(
      Math.round(screenHeight * 0.04), // 4% 屏幕高度
      50 // 最大 50px
    )
    
    const estimatedNavBarHeight = Math.min(
      Math.round(screenHeight * 0.05), // 5% 屏幕高度
      56 // 最大 56px
    )
    
    console.log('🔍 [安全区域] 屏幕尺寸:', screenWidth, 'x', screenHeight)
    console.log('🔍 [安全区域] 估算值 - 状态栏:', estimatedStatusBarHeight, '导航栏:', estimatedNavBarHeight)
    
    // 设置 CSS 变量
    const root = document.documentElement
    root.style.setProperty('--sat', `${estimatedStatusBarHeight}px`)
    root.style.setProperty('--sab', `${estimatedNavBarHeight}px`)
    
    // 更新 body 的 padding
    const body = document.body
    body.style.paddingTop = `${estimatedStatusBarHeight}px`
    body.style.paddingBottom = `${estimatedNavBarHeight}px`
    
    console.log('✅ [安全区域] 已设置 - 顶部:', estimatedStatusBarHeight, '底部:', estimatedNavBarHeight)
  } catch (e: any) {
    console.log('⚠️ [安全区域] 设置失败:', e.message)
  }
}

// 应用挂载时执行
app.mount('#app').$nextTick(async () => {
  await setupSafeArea()
  // ... 其他初始化逻辑
})
2. CSS 变量应用

将所有使用 env(safe-area-inset-*) 的地方替换为 var(--sat, 0)var(--sab, 0)

src/style.css

body {
  /* 顶部和底部安全区域通过 JS 动态设置 CSS 变量 */
  padding-top: var(--sat, 0);
  padding-bottom: var(--sab, 0);
  transition: padding-top 0.3s ease, padding-bottom 0.3s ease;
}

/* 页面容器 */
.page-container {
  padding: 16px;
  padding-top: calc(16px + var(--sat, 0));
  padding-bottom: calc(80px + var(--sab, 0));
  min-height: 100vh;
  min-height: 100dvh;
}

src/App.vue

.app-container {
  width: 100%;
  height: 100%;
  min-height: 100vh;
  min-height: 100dvh;
  position: relative;
  padding-top: var(--sat, 0); /* 顶部安全区域 */
  padding-bottom: calc(80px + var(--sab, 0)); /* 底部安全区域 + 导航栏高度 */
  box-sizing: border-box;
}

.tab-bar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  height: auto;
  min-height: calc(56px + var(--sab, 0));
  display: flex;
  justify-content: space-around;
  align-items: flex-start;
  background: #fff;
  border-top: 1px solid #f0f0f0;
  z-index: 1000;
  padding-top: 10px;
  padding-bottom: calc(10px + var(--sab, 0));
  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.05);
}

src/views/Login.vue

.login-container {
  padding-top: calc(16px + var(--sat, 0));
  padding-bottom: calc(32px + var(--sab, 0));
}

.login-form {
  padding: 36px 32px;
  padding-top: calc(36px + var(--sat, 0));
  padding-bottom: calc(32px + var(--sab, 0));
}

src/views/Classmates.vue 和 src/views/Tasks.vue

.page-container {
  padding: 16px;
  padding-top: calc(16px + var(--sat, 0));
  padding-bottom: calc(120px + var(--sab, 0));
}

src/views/Tasks.vue (浮动按钮):

.fab-button {
  position: fixed;
  bottom: calc(90px + var(--sab, 0));
  right: 16px;
}
3. Android 原生层配置(MainActivity.java)

保持沉浸式布局,但不隐藏系统栏:

@Override
public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);

    // 启用边缘到边缘显示 (沉浸式)
    WindowCompat.setDecorFitsSystemWindows(getWindow(), false);

    // 设置透明状态栏和导航栏
    setupTransparentSystemBars();
}

private void setupTransparentSystemBars() {
    // 设置状态栏和导航栏透明
    getWindow().setStatusBarColor(android.graphics.Color.TRANSPARENT);
    getWindow().setNavigationBarColor(android.graphics.Color.TRANSPARENT);

    // 设置窗口标志以实现沉浸式
    int flags = WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS;
    getWindow().setFlags(flags, flags);

    // 设置为边缘到边缘布局,但不隐藏系统栏
    // 让 CSS 的 safe-area-inset 来处理间距
    View decorView = getWindow().getDecorView();
    int uiOptions = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
            | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
            | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION;
    decorView.setSystemUiVisibility(uiOptions);
}

@Override
public void onWindowFocusChanged(boolean hasFocus) {
    super.onWindowFocusChanged(hasFocus);
    if (hasFocus) {
        // 窗口获得焦点时,重新应用边缘到边缘布局
        View decorView = getWindow().getDecorView();
        int uiOptions = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
                | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION;
        decorView.setSystemUiVisibility(uiOptions);
    }
}

关键点

  • 移除了 SYSTEM_UI_FLAG_FULLSCREENSYSTEM_UI_FLAG_HIDE_NAVIGATION
  • 移除了 SYSTEM_UI_FLAG_IMMERSIVE_STICKY
  • 只保留 LAYOUT_FULLSCREENLAYOUT_HIDE_NAVIGATION 实现边缘到边缘布局
  • 不隐藏系统栏,让内容延伸到系统栏后面,通过 padding 留出安全区域
4. HTML viewport 配置(index.html)
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />

添加 viewport-fit=cover 启用全面屏适配。

1. 为什么 CSS env() 不生效?

  • CSS 的 env(safe-area-inset-*) 是 iOS Safari 引入的特性
  • Android WebView 和 Chrome 对此支持不完善
  • 在 Android 上需要手动实现安全区域适配

2. 为什么使用估算而非精确值?

  • Capacitor 的 StatusBar 和 NavigationBar 插件需要额外安装
  • 为了减少依赖,使用屏幕比例估算
  • 经验值:状态栏 ≈ 4% 屏幕高度,导航栏 ≈ 5% 屏幕高度
  • 设置上限避免极端情况(状态栏最大 50px,导航栏最大 56px)

3. CSS 变量的优势

  • 一次设置,全局使用
  • 支持动态更新
  • 保持样式层简洁
  • fallback 机制:var(--sat, 0) 确保非原生平台正常显示

4. 沉浸式布局的关键

  • WindowCompat.setDecorFitsSystemWindows(getWindow(), false) 启用边缘到边缘
  • 透明状态栏和导航栏
  • 使用 LAYOUT_FULLSCREENLAYOUT_HIDE_NAVIGATION 让内容延伸到系统栏后面
  • 不隐藏系统栏,而是通过 padding 留出安全区域

参考资料

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐