乐趣区

关于android:Android-系统-Bar-沉浸式完美兼容方案

引言

自 Android 5.0 版本,Android 带来了沉迷式零碎 bar(状态栏和导航栏),Android 的视觉效果进一步提高,各大 app 厂商也在大多数场景上应用沉迷式成果。但因为 Android 碎片化比较严重,每个版本的零碎 bar 成果可能会有所差别,导致开发者往往须要进行兼容适配。为了简化零碎 bar 沉迷式的应用,以及对立机型、版本差别所造成的成果差别,本文将介绍零碎 bar 的组成以及沉迷式适配计划。

背景

问题一:沉迷式下无奈设置背景色

对于大于等于 Android 5.0 版本的零碎,在 Activity 的 onCreate 时,通过给 window 设置属性:

window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)

即可开启沉迷式零碎 bar,成果如下:

Android 5.0 沉迷式状态栏

Android 5.0 沉迷式导航栏

然而设置沉迷式之后,原来通过 window.statusBarColorwindow.statusBarColor 设置的色彩也不可用,也就是说不反对自定义半透明零碎 bar 的色彩。

问题二:无奈全透明导航栏

零碎默认的状态栏和导航栏都有一个半透明的蒙层,尽管不反对设置色彩,但通过设置以下代码,可让状态栏变为全透明:

window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
        or View.SYSTEM_UI_FLAG_LAYOUT_STABLE)
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
window.statusBarColor = Color.TRANSPARENT

成果如下:

Android 10.0 沉迷式全透明状态栏

通过相似的形式尝试将导航栏设置为全透明:

window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
window.decorView.systemUiVisibility = (View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
        or View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION)
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
window.navigationBarColor = Color.TRANSPARENT

但发现导航栏半透明背景仍然无奈去掉:

问题三:亮色零碎 bar 版本差别

对于大于等于 Android 6.0 版本的零碎,如果背景是浅色的,可通过设置状态栏和导航栏文字色彩为深色,也就是导航栏和状态栏为浅色(只有 Android 8.0 及以上才反对导航栏文字色彩批改):

window.decorView.systemUiVisibility =
    View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR

window.decorView.systemUiVisibility =
    window.decorView.systemUiVisibility or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR else 0

成果如下:

Android 8.0 亮色状态栏

Android 8.0 亮色导航栏

然而在亮色零碎 bar 根底上开启沉迷式后,在 8.0 至 9.0 零碎中,导航栏深色导航 icon 不失效,而 10.0 以上版本能显示深色导航 icon:

Android 8.0 亮色沉迷式亮色导航栏

Android 10.0 亮色沉迷式亮色导航栏

问题剖析

问题一:沉迷式下无奈设置背景色

查看源码发现设置状态栏和导航栏背景色彩时,是不能为沉迷式的:

问题二:无奈全透明导航栏

当设置导航栏为通明色(Color.TRANSPARENT)时,导航栏会变成半透明,当设置其余色彩,则是失常的,例如设置色彩为 0x700F7FFF,显示成果如下:

Android 10.0 沉迷式导航栏

为什么会呈现这个状况呢,通过调试进入源码,发现 activity 的 onApplyThemeResource 办法中有一个逻辑:

// Get the primary color and update the TaskDescription for this activity
TypedArray a = theme.obtainStyledAttributes(com.android.internal.R.styleable.ActivityTaskDescription);
if (mTaskDescription.getPrimaryColor() == 0) {
    int colorPrimary = a.getColor(com.android.internal.R.styleable.ActivityTaskDescription_colorPrimary, 0);
    if (colorPrimary != 0 && Color.alpha(colorPrimary) == 0xFF) {mTaskDescription.setPrimaryColor(colorPrimary);
    }
}

也就是说如果设置的导航栏色彩为 0(纯通明)时,将会为其批改为内置的色彩:ActivityTaskDescription_colorPrimary,因而就会呈现灰色蒙层成果。

问题三:亮色零碎 bar 版本差别

通过查看源码发现,与设置状态栏和导航栏背景色彩相似,设置导航栏 icon 色彩也是不能为沉迷式:

解决沉迷式兼容性问题

对于问题二无奈全透明导航栏,由上述问题剖析中的代码能够看出,当且仅当设置的导航栏色彩为纯通明时(0),才会置换为半透明的蒙层。那么,咱们能够将纯通明这种状况批改色彩为 0x01000000,这样也能达到靠近纯通明的成果:

对于问题一,难以通过惯例形式进行沉迷式下的零碎 bar 背景色彩设置。而对于问题三,通过惯例形式须要别离对各个版本进行适配,对于国内手机来说,适配难度更大。

为了解决兼容性问题,以及更好的治理状态栏和导航栏,咱们是否能本人实现状态栏和导航栏的背景 View 呢?

通过 Layout Inspector 能够看出,导航栏和状态栏实质上也是一个 view:

在 activity 创立的时候,会创立两个 view(navigationBarBackground 和 statusBarBackground),将其加到 decorView 中,从而能够管制状态栏的色彩。那么,是否能把零碎的这两个 view 暗藏起来,替换成自定义的 view 呢?

因而,为了进步兼容性,以及更好的治理状态栏和导航栏,咱们能够将零碎的 navigationBarBackground 和 statusBarBackground 暗藏起来,替换成自定义的 view,而不再通过 FLAG_TRANSLUCENT_STATUSFLAG_TRANSLUCENT_NAVIGATION 来设置。

实现沉迷式状态栏

  1. 增加自定义的状态栏。通过创立一个 view,让其高度等于状态栏的高度,并将其增加到 decorView 中:
View(window.context).apply {
id = R.id.status_bar_view
    val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, statusHeight)
    params.gravity = Gravity.TOP
    layoutParams = params
    (window.decorView as ViewGroup).addView(this)
}
  1. 暗藏零碎的状态栏。因为 activity 在 onCreate 时,并没有创立状态栏的 view(statusBarBackground),因而无奈间接将其暗藏。这里能够通过对 decorView 增加 OnHierarchyChangeListener 监听来捕捉到 statusBarBackground:
(window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {override fun onChildViewAdded(parent: View?, child: View?) {if (child?.id == android.R.id.statusBarBackground) {child.scaleX = 0f}
    }

    override fun onChildViewRemoved(parent: View?, child: View?) {}})

留神:这里将 child 的 scaleX 设为 0 即可将其暗藏起来,那么为什么不能设置 visibilityGONE 呢?这是因为后续在利用主题时(onApplyThemeResource),零碎会将 visibility 又从新设置为 VISIBLE

暗藏之后,半透明的状态栏不显示,然而顶部会呈现空白:

通过 Layout Inspector 发现,decorView 的第一个元素(内容 view)会存在一个 padding:

因而,能够通过设置 paddingTop 为 0 将其去除:

val view = (window.decorView as ViewGroup).getChildAt(0)
view.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
    if (view.paddingTop > 0) {view.setPadding(0, 0, 0, view.paddingBottom)
        val content = findViewById<View>(android.R.id.content)
        content.requestLayout()}
}

留神:这里须要监听 view 的 layout 变动,否则只有一开始设置则前面又被批改了。

实现沉迷式导航栏

导航栏的自定义与状态栏相似,不过会存在一些差别。先创立一个自定义 view 将其增加到 decorView 中,而后把原来零碎的 navigationBarBackground 暗藏:

window.decorView.findViewById(R.id.navigation_bar_view) ?: View(window.context).apply {
id = R.id.navigation_bar_view
    val resourceId = resources.getIdentifier(navigation_bar_height ,  dimen ,  android)
    val navigationBarHeight = if (resourceId > 0) resources.getDimensionPixelSize(resourceId) else 0
    val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, navigationBarHeight)
    params.gravity = Gravity.BOTTOM
    layoutParams = params
    (window.decorView as ViewGroup).addView(this)

    (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {override fun onChildViewAdded(parent: View?, child: View?) {if (child?.id == android.R.id.navigationBarBackground) {child.scaleX = 0f} else if (child?.id == android.R.id.statusBarBackground) {child.scaleX = 0f}
        }

        override fun onChildViewRemoved(parent: View?, child: View?) {}})
}

留神:这里 onChildViewAdded 办法中,因为只能设置一次 OnHierarchyChangeListener,须要同时思考状态栏和导航栏。

通过这个形式,能将导航栏替换为自定义的 view,然而存在一个问题,因为 navigationBarHeight 是固定的,如果用户切换了导航栏的款式,再回到 app 时,导航栏的高度不会从新调整。为了让导航栏看的分明,设置其色彩为 0x7F00FF7F:

从图中能够看出,导航栏切换之后高度没有发生变化。为了解决这个问题,须要通过对 navigationBarBackground 设置 OnLayoutChangeListener 来监听导航栏高度的变动,并通过 liveData 关联到 view 中,代码实现如下:

val heightLiveData = MutableLiveData<Int>()
heightLiveData.value = 0
window.decorView.setTag(R.id.navigation_height_live_data, heightLiveData)

val navigationBarView = window.decorView.findViewById(R.id.navigation_bar_view) ?: View(window.context).apply {
    id = R.id.navigation_bar_view
    val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, heightLiveData.value ?: 0)
    params.gravity = Gravity.BOTTOM
    layoutParams = params
    (window.decorView as ViewGroup).addView(this)

    if (this@immersiveNavigationBar is FragmentActivity) {heightLiveData.observe(this@immersiveNavigationBar) {
            val lp = layoutParams
            lp.height = heightLiveData.value ?: 0
            layoutParams = lp
        }
    }

    (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {override fun onChildViewAdded(parent: View?, child: View?) {if (child?.id == android.R.id.navigationBarBackground) {
                child.scaleX = 0f

                child.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
                    heightLiveData.value = bottom - top
                }
            } else if (child?.id == android.R.id.statusBarBackground) {child.scaleX = 0f}
        }

        override fun onChildViewRemoved(parent: View?, child: View?) {}})
}

通过下面形式,能够解决切换导航栏款式后自定义的导航栏高度问题:

残缺代码

@file:Suppress("DEPRECATION")

package com.bytedance.heycan.systembar.activity

import android.app.Activity
import android.graphics.Color
import android.os.Build
import android.util.Size
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.widget.FrameLayout
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import com.bytedance.heycan.systembar.R

/**
 * Created by dengchunguo on 2021/4/25
 */
fun Activity.setLightStatusBar(isLightingColor: Boolean) {
    val window = this.window
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {if (isLightingColor) {
            window.decorView.systemUiVisibility =
                View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
        } else {window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE}
    }
}

fun Activity.setLightNavigationBar(isLightingColor: Boolean) {
    val window = this.window
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && isLightingColor) {
        window.decorView.systemUiVisibility =
            window.decorView.systemUiVisibility or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR else 0
    }
}

/**
 * 必须在 Activity 的 onCreate 时调用
 */
fun Activity.immersiveStatusBar() {val view = (window.decorView as ViewGroup).getChildAt(0)
    view.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
        val lp = view.layoutParams as FrameLayout.LayoutParams
        if (lp.topMargin > 0) {
            lp.topMargin = 0
            v.layoutParams = lp
        }
        if (view.paddingTop > 0) {view.setPadding(0, 0, 0, view.paddingBottom)
            val content = findViewById<View>(android.R.id.content)
            content.requestLayout()}
    }

    val content = findViewById<View>(android.R.id.content)
    content.setPadding(0, 0, 0, content.paddingBottom)

    window.decorView.findViewById(R.id.status_bar_view) ?: View(window.context).apply {
        id = R.id.status_bar_view
        val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, statusHeight)
        params.gravity = Gravity.TOP
        layoutParams = params
        (window.decorView as ViewGroup).addView(this)

        (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {override fun onChildViewAdded(parent: View?, child: View?) {if (child?.id == android.R.id.statusBarBackground) {child.scaleX = 0f}
            }

            override fun onChildViewRemoved(parent: View?, child: View?) {}})
    }
    setStatusBarColor(Color.TRANSPARENT)
}

/**
 * 必须在 Activity 的 onCreate 时调用
 */
fun Activity.immersiveNavigationBar(callback: (() -> Unit)? = null) {val view = (window.decorView as ViewGroup).getChildAt(0)
    view.addOnLayoutChangeListener { v, _, _, _, _, _, _, _, _ ->
        val lp = view.layoutParams as FrameLayout.LayoutParams
        if (lp.bottomMargin > 0) {
            lp.bottomMargin = 0
            v.layoutParams = lp
        }
        if (view.paddingBottom > 0) {view.setPadding(0, view.paddingTop, 0, 0)
            val content = findViewById<View>(android.R.id.content)
            content.requestLayout()}
    }

    val content = findViewById<View>(android.R.id.content)
    content.setPadding(0, content.paddingTop, 0, -1)

    val heightLiveData = MutableLiveData<Int>()
    heightLiveData.value = 0
    window.decorView.setTag(R.id.navigation_height_live_data, heightLiveData)
    callback?.invoke()

    window.decorView.findViewById(R.id.navigation_bar_view) ?: View(window.context).apply {
        id = R.id.navigation_bar_view
        val params = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, heightLiveData.value ?: 0)
        params.gravity = Gravity.BOTTOM
        layoutParams = params
        (window.decorView as ViewGroup).addView(this)

        if (this@immersiveNavigationBar is FragmentActivity) {heightLiveData.observe(this@immersiveNavigationBar) {
                val lp = layoutParams
                lp.height = heightLiveData.value ?: 0
                layoutParams = lp
            }
        }

        (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {override fun onChildViewAdded(parent: View?, child: View?) {if (child?.id == android.R.id.navigationBarBackground) {
                    child.scaleX = 0f
                    bringToFront()

                    child.addOnLayoutChangeListener { _, _, top, _, bottom, _, _, _, _ ->
                        heightLiveData.value = bottom - top
                    }
                } else if (child?.id == android.R.id.statusBarBackground) {child.scaleX = 0f}
            }

            override fun onChildViewRemoved(parent: View?, child: View?) {}})
    }
    setNavigationBarColor(Color.TRANSPARENT)
}

/**
 * 当设置了 immersiveStatusBar 时,如需应用状态栏,可调佣该函数
 */
fun Activity.fitStatusBar(fit: Boolean) {val content = findViewById<View>(android.R.id.content)
    if (fit) {content.setPadding(0, statusHeight, 0, content.paddingBottom)
    } else {content.setPadding(0, 0, 0, content.paddingBottom)
    }
}

fun Activity.fitNavigationBar(fit: Boolean) {val content = findViewById<View>(android.R.id.content)
    if (fit) {content.setPadding(0, content.paddingTop, 0, navigationBarHeightLiveData.value ?: 0)
    } else {content.setPadding(0, content.paddingTop, 0, -1)
    }
    if (this is FragmentActivity) {navigationBarHeightLiveData.observe(this) {if (content.paddingBottom != -1) {content.setPadding(0, content.paddingTop, 0, it)
            }
        }
    }
}

val Activity.isImmersiveNavigationBar: Boolean
    get() = window.attributes.flags and WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION != 0

val Activity.statusHeight: Int
    get() {
        val resourceId =
            resources.getIdentifier("status_bar_height", "dimen", "android")
        if (resourceId > 0) {return resources.getDimensionPixelSize(resourceId)
        }
        return 0
    }

val Activity.navigationHeight: Int
    get() {return navigationBarHeightLiveData.value ?: 0}

val Activity.screenSize: Size
    get() {return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {Size(windowManager.currentWindowMetrics.bounds.width(), windowManager.currentWindowMetrics.bounds.height())
        } else {Size(windowManager.defaultDisplay.width, windowManager.defaultDisplay.height)
        }
    }

fun Activity.setStatusBarColor(color: Int) {val statusBarView = window.decorView.findViewById<View?>(R.id.status_bar_view)
    if (color == 0 && Build.VERSION.SDK_INT < Build.VERSION_CODES.M) {statusBarView?.setBackgroundColor(STATUS_BAR_MASK_COLOR)
    } else {statusBarView?.setBackgroundColor(color)
    }
}

fun Activity.setNavigationBarColor(color: Int) {val navigationBarView = window.decorView.findViewById<View?>(R.id.navigation_bar_view)
    if (color == 0 && Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {navigationBarView?.setBackgroundColor(STATUS_BAR_MASK_COLOR)
    } else {navigationBarView?.setBackgroundColor(color)
    }
}

@Suppress("UNCHECKED_CAST")
val Activity.navigationBarHeightLiveData: LiveData<Int>
    get() {var liveData = window.decorView.getTag(R.id.navigation_height_live_data) as? LiveData<Int>
        if (liveData == null) {liveData = MutableLiveData()
            window.decorView.setTag(R.id.navigation_height_live_data, liveData)
        }
        return liveData
    }

val Activity.screenWidth: Int get() = screenSize.width

val Activity.screenHeight: Int get() = screenSize.height

private const val STATUS_BAR_MASK_COLOR = 0x7F000000

扩大

对话框适配

有时候须要通过 Dialog 来显示一个提醒对话框、loading 对话框等,当显示一个对话框时,即便设置了 activity 为深色状态栏和导航栏文字色彩,这时候状态栏和导航栏的文字色彩又变成红色,如下所示:

这是因为对 activity 设置的状态栏和导航栏色彩是作用 于 activity 的 window,而 dialog 和 activity 不是同一个 window,因而 dialog 也须要独自设置。

残缺代码

@file:Suppress(DEPRECATION)

package com.bytedance.heycan.systembar.dialog

import android.app.Dialog
import android.os.Build
import android.view.View
import android.view.ViewGroup

/**
 * Created by dengchunguo on 2021/4/25
 */
fun Dialog.setLightStatusBar(isLightingColor: Boolean) {
    val window = this.window ?: return
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {if (isLightingColor) {
            window.decorView.systemUiVisibility =
                View.SYSTEM_UI_FLAG_LAYOUT_STABLE or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
        } else {window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE}
    }
}

fun Dialog.setLightNavigationBar(isLightingColor: Boolean) {
    val window = this.window ?: return
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && isLightingColor) {
        window.decorView.systemUiVisibility =
            window.decorView.systemUiVisibility or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR else 0
    }
}

fun Dialog.immersiveStatusBar() {
    val window = this.window ?: return
    (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {override fun onChildViewAdded(parent: View?, child: View?) {if (child?.id == android.R.id.statusBarBackground) {child.scaleX = 0f}
        }

        override fun onChildViewRemoved(parent: View?, child: View?) {}})
}

fun Dialog.immersiveNavigationBar() {
    val window = this.window ?: return
    (window.decorView as ViewGroup).setOnHierarchyChangeListener(object : ViewGroup.OnHierarchyChangeListener {override fun onChildViewAdded(parent: View?, child: View?) {if (child?.id == android.R.id.navigationBarBackground) {child.scaleX = 0f} else if (child?.id == android.R.id.statusBarBackground) {child.scaleX = 0f}
        }

        override fun onChildViewRemoved(parent: View?, child: View?) {}})
}

成果如下:

疾速应用

Activity 沉迷式

immersiveStatusBar() // 沉迷式状态栏
immersiveNavigationBar() // 沉迷式导航栏

setLightStatusBar(true) // 设置浅色状态栏背景(文字为深色)setLightNavigationBar(true) // 设置浅色导航栏背景(文字为深色)setStatusBarColor(color) // 设置状态栏背景色
setNavigationBarColor(color) // 设置导航栏背景色

navigationBarHeightLiveData.observe(this) {// 监听导航栏高度变动}

Dialog 沉迷式

val dialog = Dialog(this, R.style.Heycan_SampleDialog)
dialog.setContentView(R.layout.dialog_loading)
dialog.immersiveStatusBar()
dialog.immersiveNavigationBar()
dialog.setLightStatusBar(true)
dialog.setLightNavigationBar(true)
dialog.show()

Demo 成果

可实现与 iOS 相似的页面沉迷式导航条成果:

退出移动版