Compose 页面沉浸式体验适配

1,717 阅读7分钟

沉浸式

所谓沉浸式就是适配状态栏和导航栏,使其和应用内容融为一体,有两种方式:

  • 全屏展示应用,隐藏掉状态栏和导航栏,达到沉浸式效果;
  • 将状态栏和导航栏设置为透明,应用页面内容的颜色延伸到屏幕边缘,注意这里是内容颜色不是内容本身。

实现方案

创建一个 Android Compose 项目,会默认生成 MainActivity 的代码:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ImmersiveDemoTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Greeting(
                        name = "Android",
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }
    }
}

enableEdgeToEdge

在 onCreate 中会默认调用 enableEdgeToEdge(),这个方法是 ComponentActivity 的拓展方法,用来将 Activity 的内容延展到边缘,将状态栏设置为透明,导航栏根据导航模式呈现不同的效果,为这个 Activity 添加一个灰色背景,效果如下:

Screenshot_1729824579.png

Screenshot_1729822985.png

Screenshot_1729824516.png

这是三种导航模式的显示效果,导航模式可以在设置中更改:

Screenshot_1729822926.png

可以看出三种导航模式显示效果略有不同,双按钮导航和三按钮导航模式下,导航栏会有系统配置的蒙层。 而手势导航模式下,Activity 内容的背景是延伸到状态栏和导航栏的。

enableEdgeToEdge() 是 ComponentActivity 的拓展方法:

/**
 * 对这个 ComponentActivity 开启边到边的显示
 *
 * 要使用默认样式进行设置,在你的 Activity's onCreate 方法中调用这个方法:
 * ```
 *     override fun onCreate(savedInstanceState: Bundle?) {
 *         enableEdgeToEdge()
 *         super.onCreate(savedInstanceState)
 *         ...
 *     }
 * ```
 * 
 * 默认样式会在系统能够强制实施对比度的时候(在 API 29 及以上版本),把系统栏设置为透明背景。
 * 在旧的平台上(只有 三按钮导航、双按钮导航模式),会应用一个类似的遮光层以确保与系统栏有对比度。
 * See [SystemBarStyle] for more customization options.
 *
 * @param statusBarStyle The [SystemBarStyle] for the status bar.
 * @param navigationBarStyle The [SystemBarStyle] for the navigation bar.
 */
@JvmName("enable")
@JvmOverloads
fun ComponentActivity.enableEdgeToEdge(
    statusBarStyle: SystemBarStyle = SystemBarStyle.auto(Color.TRANSPARENT, Color.TRANSPARENT),
    navigationBarStyle: SystemBarStyle = SystemBarStyle.auto(DefaultLightScrim, DefaultDarkScrim)
) {
    val view = window.decorView
    val statusBarIsDark = statusBarStyle.detectDarkMode(view.resources)
    val navigationBarIsDark = navigationBarStyle.detectDarkMode(view.resources)
    val impl = Impl ?: if (Build.VERSION.SDK_INT >= 29) {
        EdgeToEdgeApi29()
    } else if (Build.VERSION.SDK_INT >= 26) {
        EdgeToEdgeApi26()
    } else if (Build.VERSION.SDK_INT >= 23) {
        EdgeToEdgeApi23()
    } else if (Build.VERSION.SDK_INT >= 21) {
        EdgeToEdgeApi21()
    } else {
        EdgeToEdgeBase()
    }.also { Impl = it }
    impl.setUp(
        statusBarStyle, navigationBarStyle, window, view, statusBarIsDark, navigationBarIsDark
    )
}

这个方法的注释中也描述三按钮和双按钮导航模式会有遮光层。

SystemBarStyle

enableEdgeToEdge() 方法中无论是导航栏还是状态栏的 Style 都是 SystemBarStyle 类型,SystemBarStyle 提供默认的系统风格,并且具有自动监测 dark 模式的能力。

SystemBarStyle 源码大致如下:

/**
 * [enableEdgeToEdge] 中使用的状态栏或导航栏的样式。
 */
class SystemBarStyle private constructor(
    private val lightScrim: Int,
    internal val darkScrim: Int,
    internal val nightMode: Int,
    internal val detectDarkMode: (Resources) -> Boolean
) {

    companion object {
        @JvmStatic
        @JvmOverloads
        fun auto(
            @ColorInt lightScrim: Int,
            @ColorInt darkScrim: Int,
            detectDarkMode: (Resources) -> Boolean = { resources ->
                (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) ==
                    Configuration.UI_MODE_NIGHT_YES
            }
        ): SystemBarStyle 

        @JvmStatic
        fun dark(@ColorInt scrim: Int): SystemBarStyle 
      
        @JvmStatic
        fun light(@ColorInt scrim: Int, @ColorInt darkScrim: Int): SystemBarStyle
    }

    internal fun getScrim(isDark: Boolean) = if (isDark) darkScrim else lightScrim

    internal fun getScrimWithEnforcedContrast(isDark: Boolean): Int {
        return when {
            nightMode == UiModeManager.MODE_NIGHT_AUTO -> Color.TRANSPARENT
            isDark -> darkScrim
            else -> lightScrim
        }
    }
}

SystemBarStyle 提供了三个初始化方法,auto、dark、light,auto,三个方法的行为各不相同。

SystemBarStyle.auto

写个例子:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge(
            navigationBarStyle = SystemBarStyle.dark(Color.Red.toArgb()) // set color for navigationBar
        )
        setContent {
            ImmersiveDemoTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Spacer(modifier = Modifier.fillMaxSize().background(Color.Cyan))
                    Greeting(
                        name = "Android",å
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }
    }
}

效果如下:

Screenshot_1729824579.png

Screenshot_1729824483.png

Screenshot_1729822956.png

在 API 级别 29 及以上,auto 方法在手势导航的情况下是透明的,设置的颜色不会生效。

在三按钮和双按钮导航模式下,系统将自动应用默认的遮光层。请注意,指定的颜色都不会被使用。在 API 级别 28 及以下,导航栏将根据暗黑模式是否开启来展示指定的颜色。

  • lightScrim 当应用处于浅色模式时用于背景的遮光层颜色。

  • darkScrim 当应用处于深色模式时用于背景的遮光层颜色。这也用于系统图标颜色始终为浅色的设备。

SystemBarStyle.dark

创建一个新的 [SystemBarStyle] 实例。这种样式始终如一地应用指定的遮光层颜色,而不考虑系统导航模式。参数 scrim 用于背景的遮光层颜色。为了与浅色系统图标形成对比,它应该是深色的。

dark 模式很简单,无论什么导航模式、主题模式,他都显示设置的颜色。

Screenshot_1729828540.png

SystemBarStyle.light

创建一个新的 [SystemBarStyle] 实例。这种样式始终如一地应用指定的遮光层颜色,而不考虑系统导航模式。

  • 参数 scrim 用于背景的遮光层颜色。为了与深色系统图标形成对比,它应该是浅色的。
  • 参数 darkScrim 在系统图标颜色始终为浅色的设备上用于背景的遮光层颜色。它应该是深色的。

与 dark 不同,应用可以强制设置为 light 模式,而不用随系统的主题模式变化而变化,此时 darkScrim 生效。其他情况下使用 scrim。

系统栏背景遮光层

在上面的内容中,我们知道系统会给导航栏和状态栏设置一个遮光层,导航栏和状态栏会随着系统的导航模式和主题模式而变化。

但实际上应用希望呈现沉浸式的效果,就需要无论在上面导航模式、主题模式下都呈现与内容相同的颜色效果,所以需要去掉导航栏和状态栏的遮罩。

当我们什么也不设置,只调用 enableEdgeToEdge() 时,是这样的:

Screenshot_1729835423.png

调用去掉导航栏遮罩效果:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
		// 去掉导航栏遮罩
		window.isNavigationBarContrastEnforced = false
}

isNavigationBarContrastEnforced 属性可以关闭强制使用导航栏遮罩,源码如下:

    /**
     * 当请求完全透明的背景时,设置系统是否应该确保导航栏有足够的对比度
     * 
     * 如果设置为这个值,系统将确定是否需要一个遮光层来确保导航栏与这个应用的内容有足够的对比度,
     * 并相应地设置一个适当的有效的导航栏背景颜色。
		 * 
     * 当导航栏颜色具有非零的透明度值时,这个属性的值没有效果。
     *
     * @see android.R.attr#enforceNavigationBarContrast
     * @see #isNavigationBarContrastEnforced
     * @see #setNavigationBarColor
     */
    public void setNavigationBarContrastEnforced(boolean enforceContrast) {
    }

同样地,对于状态栏也有相同的属性:

    /**
     * 当请求完全透明的背景时,设置系统是否应该确保栏有足够的对比度
     *
     * 如果设置为这个值,系统将确定是否需要一个遮光层来确保状态栏与这个应用的内容有足够的对比度,
     * 并相应地设置一个适当的有效的导航栏背景颜色。
     *
     * 当导航栏颜色具有非零的透明度值时,这个属性的值没有效果。
     *
     * @see android.R.attr#enforceStatusBarContrast
     * @see #isStatusBarContrastEnforced
     * @see #setStatusBarColor
     */
    public void setStatusBarContrastEnforced(boolean ensureContrast) {
    }

所以去掉遮光层效果如下:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
		// 去掉导航栏遮罩
		window.isNavigationBarContrastEnforced = false
  	// 去掉状态栏遮罩
		window.isStatusBarContrastEnforced = false
}

系统栏前景色

在状态栏和导航栏中有一些图标,比如状态栏中的电量图标、手势导航模式下的导航条图标,这些图标会随着系统主题(dark or light)变化为深色 icon 或是浅色 icon,

  • 当系统为 dark 主题模式下,icon 是浅色的,以和背景达成一种对比效果;
  • 当系统为 light 主题模式下,icon 是深色的。
		/**
     * 如果为 true,则将状态栏的前景色更改为浅色,以便可以清晰地读取栏上的项目。
     * 如果为 false,则恢复为默认外观。
     * 
     * 此方法在 API 级别小于 23 时没有效果。
     * 
     * 一旦调用此方法,直接修改 “systemUiVisibility” 以更改外观是未定义的行为。
     *
     * @see #isAppearanceLightStatusBars()
     */
    public void setAppearanceLightStatusBars(boolean isLight) {
        mImpl.setAppearanceLightStatusBars(isLight);
    }

同样地,有对导航栏设置的 API:

    /**
     * 如果为 true,则将导航栏的前景色更改为浅色,以便可以清晰地读取栏上的项目。
     * 如果为 false,则恢复为默认外观。
     *
     * 此方法在 API 级别小于 26 时没有效果。
     * 
     * 一旦调用此方法,直接修改 “systemUiVisibility” 以更改外观是未定义的行为。
     *
     * @see #isAppearanceLightNavigationBars()
     */
    public void setAppearanceLightNavigationBars(boolean isLight) {
        mImpl.setAppearanceLightNavigationBars(isLight);
    }

完整的设置方法:

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