返回

安卓Edge-to-Edge:消除手势导航栏下方多余空白

Android

安卓应用手势导航栏下的空间,如何榨干最后一像素?

搞安卓开发的,估计不少人都折腾过怎么让 App 界面延伸到屏幕边缘,也就是搞个“边到边”(Edge-to-Edge)的效果。尤其是底部导航栏(Bottom Bar)这种常驻嘉宾,总想让它尽可能地贴近屏幕最底部,显得更“沉浸”。

你可能也遇到了类似的问题:明明用了 WindowInsetsCompat 去获取系统手势区域的高度,给底部导航栏加上了 padding,代码瞅着也没啥毛病:

ViewCompat.setOnApplyWindowInsetsListener(
    findViewById<View>(R.id.bottombar)
) { v: View, insets: WindowInsetsCompat ->
    // 获取系统手势区域的 Insets
    val bars = insets.getInsets(WindowInsetsCompat.Type.systemGestures())
    // 给 Bottom Bar 设置底部 padding
    v.setPadding(bars.left, 0, bars.right, bars.bottom)
    // 消费掉 Insets,防止传递给子 View
    insets // 这里简化了,实际可能需要消费 specific types
}

结果跑起来一看,底下还是留了一块“固定高度”的区域给手势导航条(那个细长条或者三个按钮)。更气人的是,瞅瞅隔壁亚马逊的 App,人家的底部导航栏好像就贴得更低一些,手势条占的空间显得更小。

就像下面这样:

你的 App 效果 (示意):

你的 App 示意图

亚马逊 App 效果 (示意):

亚马逊 App 示意图

这是咋回事?难道手势导航栏的高度还能让 App 自己控制不成?怎样才能让我们的 App 也尽可能利用好手势导航栏下面的空间呢?

为什么看起来不一样?剖析手Set类型

先说个结论:那个细细的手势指示条(Gesture Pill)本身的高度,App 是控制不了的。 这是系统 UI 的一部分。不同 App 看起来手势条区域高度不同,猫腻在于它们处理 WindowInsets 的方式不一样,特别是用哪个 Type 来获取边距信息。

我们来捋一捋 WindowInsetsCompat.Type 里几个容易混淆的概念:

  1. systemBars() : 这是个组合类型,包含了状态栏 (statusBars())、导航栏 (navigationBars()) 和标题栏 (captionBar())。通常用它来获取所有系统栏占据的空间。
  2. navigationBars() : 这个专门指屏幕底部(或侧边,根据设备设置)的导航栏区域,无论是手势导航还是传统三按钮导航,都算它。这通常是我们实现底部 Edge-to-Edge 时,最关心的类型。 它告诉你导航栏实际占了多少地方。
  3. systemGestures() : 这个定义的是系统手势 可以 被触发的区域。比如从屏幕底部边缘向上滑,或者从侧边向内滑触发返回。这个区域 可能比 navigationBars() 更大 。为啥?因为系统需要留出足够的热区来识别手势,尤其是在屏幕边缘。
  4. mandatorySystemGestures() : 这是系统 强制 保留用于手势的区域,比 systemGestures() 更小。意思是在这块区域里,系统手势的优先级最高,App 最好别放什么需要优先响应的交互控件,否则可能冲突。你可以把内容绘制到这个区域之下 (视觉上),但交互元素要避开。

现在回头看开头的代码:

val bars = insets.getInsets(WindowInsetsCompat.Type.systemGestures())
v.setPadding(bars.left, 0, bars.right, bars.bottom)

问题就在这儿!你用了 systemGestures() 来给 bottombar 加底部 padding。如前所述,systemGestures().bottom 的值 可能大于 实际导航栏 navigationBars().bottom 的值,因为它包含了手势识别的热区。这就导致你的 bottombar 被顶上去了,留出的空白比实际需要的(仅仅是容纳手势条本身)要多。

亚马逊 App 看起来贴得更低,很可能是因为它更精确地使用了 navigationBars() 或者甚至结合了 mandatorySystemGestures() 来处理边距。

精准控制:用对 Insets 类型

知道了原因,解决起来就有方向了。关键就是别再用“范围可能过大”的 systemGestures() 来计算底部内边距了。

方案一:标准做法 - 使用 navigationBars()

这是最推荐也是最常用的方法,适用于大多数 Edge-to-Edge 场景下处理底部导航栏。

原理与作用:

WindowInsetsCompat.Type.navigationBars() 直接告诉你导航栏(不管是手势条还是按钮)精确占用的空间大小。用这个值来设置 paddingBottom,你的 Bottom Bar 就会刚刚好出现在导航栏的上方,不多不少。

操作步骤与代码示例:

  1. 确保开启 Edge-to-Edge: 在你的 ActivityFragmentonCreateonViewCreated 中,确保调用了:

    WindowCompat.setDecorFitsSystemWindows(window, false)
    

    这一步是告诉系统,你的 App 内容要绘制到系统栏(状态栏、导航栏)后面。

  2. 修改 Insets 监听器: 将获取 Insets 的类型从 systemGestures() 改为 navigationBars()

    ViewCompat.setOnApplyWindowInsetsListener(
        findViewById<View>(R.id.bottombar) // 或者你的 Bottom Bar 实例
    ) { view, windowInsets ->
        val insets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars())
        // 设置底部 padding,左右 padding 也考虑进去,虽然通常底部导航栏是横跨屏幕的
        view.setPadding(insets.left, view.paddingTop, insets.right, insets.bottom)
    
        // 重要:返回原始的 windowInsets,或者根据需要消费掉 navigationBars 类型的 insets
        // 如果不消费,这个 insets 会继续传递给子 View,可能导致重复 padding
        // 如果你的 bottombar 内部还有需要处理 insets 的子 View,这里要小心处理
        // 最简单的处理方式是返回原始 insets,让 bottombar 自己处理 padding
        windowInsets // 或者 WindowInsetsCompat.CONSUMED (已废弃,不推荐)
                     // 或者构建一个新的 Insets 对象,消费掉 navigationBars
                     // 例如: WindowInsetsCompat.Builder(windowInsets)
                     //         .setInsets(WindowInsetsCompat.Type.navigationBars(), Insets.NONE)
                     //         .build()
                     // 但通常让 View 自己处理 padding 更简单
    }
    

    注意:

    • 代码中的 findViewById<View>(R.id.bottombar) 只是示例,换成你实际的 Bottom Bar 视图引用。
    • setPadding 时,我们保留了原有的 paddingTop,只修改了 bottomleftrightleftrightnavigationBars Insets 通常在平板或折叠屏设备的侧边导航栏模式下才会有值,但也加上比较稳妥。
    • 关于返回 windowInsets 还是消费掉 Insets:如果你的 Bottom Bar 内部没有其他需要响应窗口边距的复杂子视图,直接设置 padding 并返回原始 windowInsets 是最常见的做法。视图自身的 padding 已经“消耗”了这部分空间。如果 Bottom Bar 内部还有复杂的布局需要进一步处理 Insets,那你就需要更精细地控制 Insets 的消费和传递了。

效果:

这样修改后,你的 Bottom Bar 应该会更贴近屏幕底部,只留出系统实际为导航栏保留的空间。

安全建议:

  • 确保你的 Bottom Bar 里的可点击元素(如图标、文字)在设置了 paddingBottom 之后,仍然有足够的热区,方便用户点击。特别是对于比较矮的 Bottom Bar,底部增加的 padding 会压缩内容区域。

方案二:极限压榨 - 结合 mandatorySystemGestures()

如果你还想追求“极致”,让 Bottom Bar 的背景“视觉上”更贴近底部,甚至让背景“垫在”手势条下面一点点,可以尝试结合 mandatorySystemGestures()。但这招更复杂,也需要更仔细地测试。

原理与作用:

这个思路是:Bottom Bar背景 可以延伸得更靠下,利用 navigationBars()padding 保证它不和导航栏重叠。但是 Bottom Bar 内部的 可交互内容 (比如图标按钮)则使用 mandatorySystemGestures()padding。因为 mandatorySystemGestures() 的底部边距通常比 navigationBars() 的要小(只包含必须避让的核心手势区),这样你的按钮就能放得更低一些,给人的感觉就是整个 Bottom Bar 更“沉底”了。

操作步骤与代码示例:

这种方法通常需要你对 Bottom Bar 的内部布局有更多控制。假设你的 Bottom Bar (XML 里 idbottombar_root) 内部有一个 LinearLayout ( idbottombar_content) 来放图标和文字:

<!-- bottombar_root 可能是一个 FrameLayout 或 CoordinatorLayout -->
<FrameLayout
    android:id="@+id/bottombar_root"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom"
    android:background="?attr/colorPrimary"> <!-- 背景色可以绘制到底 -->

    <!-- 这个 LinearLayout 是真正放内容的地方 -->
    <LinearLayout
        android:id="@+id/bottombar_content"
        android:layout_width="match_parent"
        android:layout_height="@dimen/bottom_bar_height" <!-- 假设内容区域有固定高度 -->
        android:orientation="horizontal"
        android:gravity="center_vertical"
        android:paddingHorizontal="@dimen/bottom_bar_horizontal_padding">
        <!-- 这里放你的图标按钮等 -->
    </LinearLayout>

</FrameLayout>

然后,你需要分别给根布局 bottombar_root 和内容布局 bottombar_content 应用不同的 Insets 逻辑:

val bottomBarRoot = findViewById<View>(R.id.bottombar_root)
val bottomBarContent = findViewById<View>(R.id.bottombar_content)

ViewCompat.setOnApplyWindowInsetsListener(bottomBarRoot) { view, windowInsets ->
    // 1. 给根布局设置 navigationBars 的 padding,让背景绘制区域正确
    val navBarInsets = windowInsets.getInsets(WindowInsetsCompat.Type.navigationBars())
    // 注意:这里可能需要调整 padding 或 margin,取决于你的布局方式
    // 如果 root 本身就想充满 navigationBar 以下的区域,可能需要调整 margin
    // 这里我们先用 padding 演示,假设 root 背景延伸到底部
    view.setPadding(navBarInsets.left, view.paddingTop, navBarInsets.right, navBarInsets.bottom)

    // 2. 给内容布局设置 mandatorySystemGestures 的 padding (或者 margin)
    val mandatoryInsets = windowInsets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures())
    // 将 mandatoryInsets 的 bottom 值应用到 content 的 padding 或 margin
    // 如果 content 已经有 padding,可能需要叠加
    // 或者更简单的做法是调整 content 的 marginBottom
    val currentContentLp = bottomBarContent.layoutParams as FrameLayout.LayoutParams // 或者其他 LayoutParams 类型
    // 确保内容区域底部至少有 mandatoryInsets.bottom 的边距
    // 原始 margin + mandatoryInsets.bottom ? 
    // 或者直接设置,覆盖原有 margin ? -> 取决于具体布局需求
    // **一个简单的策略可能是让 content 容器的 bottom padding 使用 mandatoryInsets** 
    // (假设 LinearLayout 内部没有复杂边距处理)
    bottomBarContent.setPadding(
        bottomBarContent.paddingLeft,
        bottomBarContent.paddingTop,
        bottomBarContent.paddingRight,
        mandatoryInsets.bottom // 使用 mandatory 区域作为底部内边距
    )

    // 3. 消费掉 insets 或仅消费部分类型,防止重复处理
    // 在这个场景下,由于我们手动应用了两种类型的 insets 到不同 View,
    // 最好返回原始 windowInsets,让子 View 不再接收到这些类型的 Insets (如果子 View 也监听的话)
    // 或者精确消费掉 navigationBars 和 mandatorySystemGestures
    // val consumedInsets = WindowInsetsCompat.Builder(windowInsets)
    //    .setInsets(WindowInsetsCompat.Type.navigationBars(), Insets.NONE)
    //    .setInsets(WindowInsetsCompat.Type.mandatorySystemGestures(), Insets.NONE)
    //    .build()
    // return consumedInsets
    
    // 简化处理:还是返回原始的,由各 View 自己决定如何使用
    windowInsets
}

// (可选) 清除内容视图监听器,如果它不需要独立处理 Insets
// ViewCompat.setOnApplyWindowInsetsListener(bottomBarContent, null) 

进阶使用技巧:

  • 动态调整: 有些设备或系统版本 mandatorySystemGesturesnavigationBarsbottom 值可能相等。你的代码需要能优雅地处理这种情况。
  • 视觉调试: 打开开发者选项里的“显示布局边界”和“显示点按操作反馈”,仔细观察手势条区域、你的 Bottom Bar 背景、内容区域的实际边界和触摸反馈,确保没有重叠或交互问题。
  • 冲突处理: 如果 Bottom Bar 内的某个按钮正好落在了 mandatorySystemGestures 区域内(即使设置了 padding,也可能因为控件本身大小和位置导致),用户向上滑动手势条时可能会意外触发按钮,反之亦然。测试!测试!测试!

安全与交互建议:

  • 绝对不要 把重要的、频繁点击的按钮完全放在 mandatorySystemGestures 所定义的底部边距内。这个区域是系统“高优先级”区域。即使你的 padding 设置对了,过于贴近也可能导致误操作。可以在 mandatorySystemGestures 提供的边距之上 再加一点点视觉 paddingmargin,确保安全距离。
  • 在多种设备(不同屏幕尺寸、分辨率)和 Android 版本上测试,因为 Insets 的值可能存在差异。

别忘了主题和样式

有时候,问题也可能出在 App 的主题(Theme)设置上。

方案三:检查主题设置

原理与作用:

为了让内容能够绘制到导航栏后面,导航栏需要是透明或半透明的。这通常在 App 的主题中设置。如果导航栏背景不透明,即使你处理了 Insets,视觉上内容还是会被挡住。

操作步骤与代码示例:

检查你的 App 主题文件(通常在 res/values/themes.xmlres/values-v27/themes.xml 等):

<resources>
    <style name="Theme.MyApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
        <!-- ... 其他属性 ... -->

        <!-- 关键:确保导航栏透明 -->
        <item name="android:navigationBarColor">@android:color/transparent</item>

        <!-- 可选:在 API 29+ 控制导航栏内容的对比度,防止看不清 -->
        <!-- <item name="android:enforceNavigationBarContrast" tools:targetApi="q">true</item> -->
        
        <!-- 可选: 明确启用 edge-to-edge (主要影响浅色/深色导航栏图标颜色) -->
        <!-- <item name="android:windowLightNavigationBar" tools:targetApi="o_mr1">true</item> -->
        <!-- 根据你的背景色调调整,亮色背景用 true,暗色背景用 false -->

    </style>
</resources>
  • android:navigationBarColor 设置为 @android:color/transparent 是必须的。
  • android:enforceNavigationBarContrast (API 29+) 可以让系统在导航栏后面自动加一个半透明的遮罩,确保手势条在某些背景下可见,但这会占用一点点空间,可能会影响你“极限压榨”的效果,酌情使用。
  • android:windowLightNavigationBar (API 27+) 控制手势条本身的颜色(浅色或深色),需要根据你的 Bottom Bar 背景色来适配,确保手势条清晰可见。

效果:

正确的主题设置是实现 Edge-to-Edge 视觉效果的基础。

亚马逊怎么做的?一些猜测

回过头看亚马逊的例子,他们怎么做到看起来那么“低”?

  1. 标准方案用得好: 最大可能性是他们精确使用了 navigationBars() Insets,并且 Bottom Bar 本身设计得比较紧凑。
  2. 可能用了极限方案: 他们或许使用了类似方案二的技巧,结合 mandatorySystemGestures() 来放置内部元素,使得视觉上背景延伸得更低。
  3. 视觉技巧: 可能通过背景色、分割线、阴影等视觉设计元素,让人产生 Bottom Bar 更低的错觉。比如 Bottom Bar 背景和页面主体内容融为一体,只在顶部有一条细线分割。
  4. 原生控件的差异: 不同设备、不同 Android 版本提供的 navigationBarsmandatorySystemGestures 的具体像素值可能略有不同。

重点是理解 WindowInsets 的工作原理和不同 Type 的含义。掌握了 navigationBars()mandatorySystemGestures() 的用法,你就能根据自己 App 的设计需求,选择最合适的方案,让你的 Bottom Bar 在手势导航下优雅地“沉底”。别太纠结于像素级的完全一致,优先保证功能的稳定和用户交互的顺畅。