Android “edge to edge”特性(一)官方“沉浸式”方案实探

5,660 阅读7分钟

00 前言

在 Android 15 设备上,如果 App 的 targetSdk 是 35+,会默认启用“edge to edge”的特性,这是适配 Android 15 必须要了解的知识点。所以,趁这段时间有空,我就了解了一下这个特性,也算为以后做下知识储备吧。

01 什么是沉浸式

在 Android 开发中,真正的“沉浸式”是指通过将内容延伸到状态栏和导航栏的方式,给用户一种完全沉浸在内容中的感觉。“沉浸式”既不显示状态栏,也不显示导航栏。例如,在书籍阅读、看视频、玩游戏等场景下,我们应该尽量避免打扰用户,给用户提供真正的“沉浸式”体验。真正的“沉浸式”如下图所示。

像我们平时说的比较多的“沉浸式状态栏”这种说法,大多指的是将状态栏设置为透明,然后将内容延伸到状态栏下方,如下图所示。这种形式一般不会隐藏状态栏和导航栏,所以并不属于真正的“沉浸式”,只是我们习惯称呼它为“沉浸式状态栏”。不过,是不是真正的“沉浸式”并不重要,只要用户体验好就行了,叫什么名字不重要。

02 实现沉浸式

接下来,我们回归正题,看看 Google 的“edge to edge”特性是如何实现沉浸式的。

新建项目

点击 File -> New -> New Project -> Empty Activity -> 选择 Minium SDK 为 API 21 ,新建项目,新项目的 targetSdk 是 34。新建完成后生成的 MainActivity 默认使用了 Compose,并开启了 enableEdgeToEdge()。因为我想尝试的是“edge to edge”在 View 体系下的表现,所以我将自动生成的 Compose 代码删除了,通过 New-> Activity -> Empty Views Activity 创建了新的类 WelcomeActivity,并将 WelcomeActivity 设置成了启动页。因为 WelcomeActivity 继承自 AppCompatActivity,我又将主题改为了 “Theme.AppCompat.Light.NoActionBar”。

下面这是 Android Studio 自动生成的 WelcomeActivity 代码,enableEdgeToEdge 代表启用“边到边”特性。

class WelcomeActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_welcome)
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }
}
}

这是自动生成的 res/activity_welcome.xml。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".WelcomeActivity">

</androidx.constraintlayout.widget.ConstraintLayout>

为了更直观地展示效果,我给 activity_welcome.xml 的 ConstraintLayout 布局添加了一个背景。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:background="#FFBB86FC"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".WelcomeActivity">

</androidx.constraintlayout.widget.ConstraintLayout>

运行代码后,我们可以看到,如下图所示,仅运行默认生成的代码,状态栏就已经是透明的了,背景色也延伸到了状态栏区域。

去掉半透明遮罩

接下来要说一下导航栏。你可能已经注意到了,虽然内容区已经延伸到导航栏底下了,但是导航栏不是全透明的,在它上面是有个半透明遮罩的。这个遮罩是系统加的,从 Android 10 开始都有这个遮罩。如果想去掉这个遮罩,只需要在 WelcomeActivity 中的 onCreate 方法里添加 isNavigationBarContrastEnforced 属性并设置为 false 即可。

class WelcomeActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_welcome)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            window.isNavigationBarContrastEnforced = false
        }
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars =
                insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }
}
}

修改后的效果如下图所示,底部导航栏的遮罩确实已被移除了。

安全边衬区

将内容延伸到状态栏和导航栏之后,需要控制一下内容的安全边界,避免可点击的内容区延伸到状态栏和导航栏区域,导致点击不灵敏。此外,可点击内容区也不应该被刘海屏遮挡,遮挡了的话就不能点了。实现以上这些功能需要用到的知识点是 WindowInsets,翻译过来就是“边衬区”的意思。

以下代码设置了 setOnApplyWindowInsetsListener 监听器,监听回调中通过WindowInsetsCompat.Type.systemBars() 获取了状态栏和导航栏的边衬,通过 WindowInsetsCompat.Type.displayCutout() 获取了刘海屏的边衬,然后将这些边衬间距设置为了根布局的 padding,加了padding 以后,内容就在安全区域内了。

ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars =
        insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
    v.updatePadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
    insets
} 

为了方便展示,我在页面顶部和底部各加了一个 TextView 并修改了 res/activity_welcome.xml。

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#FFBB86FC"
    tools:context=".WelcomeActivity">

    <TextView
        android:id="@+id/buttonTop"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@color/white"
        android:padding="10dp"
        android:text="顶部按钮"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        tools:ignore="HardcodedText" />

    <TextView
        android:id="@+id/buttonBottom"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@color/white"
        android:padding="10dp"
        android:text="底部按钮"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        tools:ignore="HardcodedText" />
</androidx.constraintlayout.widget.ConstraintLayout>

接着修改了 WelcomeActivity,为根布局添加了边衬区。

class WelcomeActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_welcome)
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main))  { v, insets -> 
 val systemBars =
                insets.getInsets(WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
         } 
}
}

运行后,我们看到顶部和底部按钮都设置了安全距离。

有人可能会想,刘海屏都在状态栏里,如果只设置 systemBars 的边衬,不设置 displayCutout 的边衬,可行吗?答案是不行的。我们还要考虑到横屏的情况,像下图这样的,就是横屏的时候没有设置 displayCutout 边衬,导致内容区被刘海挡住了,所以 systemBars 边衬和 displayCutout 边衬都需要设置。

隐藏系统栏

状态栏和导航栏统称为系统栏,真正的“沉浸式”UI应该隐藏系统栏。可以通过调用 windowInsetsController.hide 隐藏系统栏。

private fun hideSystemBars() {
    val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
    windowInsetsController.systemBarsBehavior =
        WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
}

现在,我们来实现真正的“沉浸式”体验,继续修改 WelcomeActivity,添加隐藏系统栏的方法。

class WelcomeActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_welcome)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            window.isNavigationBarContrastEnforced = false
        }
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }
hideSystemBars()
    }

    private fun hideSystemBars() {
        val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
        windowInsetsController.systemBarsBehavior =
            WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
        windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
    }
}

至此,真正的“沉浸式”UI 的实现方式我们就完成了。写到这发现,代码写起来好像确实简单多了。

03 系统栏功能

在这部分,我们要介绍几个关于系统栏的 Api。

显示、隐藏状态栏

通过以下代码,可以隐藏和显示系统栏,具体隐藏和显示的元素取决于传递的类型参数:

val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
windowInsetsController.systemBarsBehavior =
    WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars())
windowInsetsController.show(WindowInsetsCompat.Type.systemBars())

使用 WindowInsetsCompat.Type.statusBars() 仅隐藏状态栏。

使用 WindowInsetsCompat.Type.navigationBars() 仅隐藏导航栏。

使用 WindowInsetsCompat.Type.systemBars() 可隐藏这两个系统栏。

系统栏背景色

修改状态栏的背景色:

 window.statusBarColor = ContextCompat.getColor(this, R.color.purple_200)

修改导航栏的背景色:

window.navigationBarColor = ContextCompat.getColor(this, R.color.purple_200)

注意:如果 tagetSdk 是 35+ 且设备系统是 Android 15 的话,这两个属性已经废弃了。

我测试的结果是:如果 tagetSdk 是 35+ 但设备系统小于 Android 15 或者 tagetSdk 是 34 但设备系统是 Android 15 的话,这两个属性都是能起作用的;如果 tagetSdk 是 35+ 且设备系统是 Android 15 的话,两个属性都不起作用。详细描述看官网文档:developer.android.com/about/versi…

系统栏前景色

控制状态栏内容(例如时间、电池图标、通知图标)的外观:

val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
windowInsetsController.isAppearanceLightStatusBars = true
//windowInsetsController.isAppearanceLightStatusBars = false

控制导航栏内容(例如返回、主页、最近应用按钮)的外观:

val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
windowInsetsController.isAppearanceLightNavigationBars = true
//windowInsetsController.isAppearanceLightNavigationBars = false

以下图示解释了参数的具体意义:

04 总结

本文主要探讨了 Android 的“edge to edge”特性,学习了如何通过边衬区来适配安全距离,以及如何实现沉浸式 UI。同时,还介绍了状态栏和导航栏的一些常见操作。不得不说,“edge to edge”特性确实简化了我们对状态栏和导航栏的适配工作,就是不知道线上用起来会不会有兼容性问题。“纸上得来终觉浅,绝知此事要躬行。”

希望本文对你有所帮助。如果你有任何问题或建议,欢迎随时提出!

参考资料

Google. 手动设置无边框显示. developer.android.google.cn/develop/ui/…

Google. 在窗口边衬区内布置应用. developer.android.google.cn/develop/ui/…

Google. (2024, April 9). 应对 Android 15 强制执行的无边框措施. developer.android.com/codelabs/ed…

其它

关于边衬区还有 2 个知识点,一个是用于防止圆角设备将底部图标裁剪的( developer.android.com/develop/ui/… ),一个是系统手势边衬区( developer.android.com/develop/ui/… )。不过跟本文关系不大,就不说了,请直接看官网。

隐藏系统栏时应该同时设置 systemBarsBehavior,用于指定要隐藏的系统栏的行为,这个知识点很有用的。developer.android.com/develop/ui/…

我使用的 Android Studio 版本是 Android Studio Koala | 2024.1.1 Patch 1(Build #Al-241.18034.62.2411.12071903, built on July 11, 2024),本文的代码都是在这个版本下开发的。