前言

最近我在小破站开发一款新App,叫高能链。我是一个完美主义者,所以不论对架构仍是UI,我都是比较抠细节的,在状况栏和导航栏沉溺式这一块,我仍是踩了挺多坑,费了挺多精力的。这次我将我踩坑,适配各机型总结出来的史上最完美的Android沉溺式状况导航栏攻略分享给咱们,咱们也能够去 高能链官网 下载体验一下咱们的App,实际感受一下沉溺式状况导航栏的作用(登录,实名等账号相关页面由于不是我开发的,就没有适配沉溺式导航栏啦,嘻嘻)

注:此攻略只针对 Android 5.0 及以上机型,即 minSdkVersion >= 21

实际作用

在开端攻略之前,咱们先看看完美的沉溺式状况导航栏作用

传统三键式导航栏

史上最完美的Android沉浸式状态导航栏攻略

史上最完美的Android沉浸式状态导航栏攻略

全面屏导航条

史上最完美的Android沉浸式状态导航栏攻略

史上最完美的Android沉浸式状态导航栏攻略

理论剖析

在上详细实现代码之前,咱们先剖析一下,实现沉溺式状况导航栏需求几步

  1. 状况栏导航栏底色通明

  2. 依据当时页面的布景色,给状况栏字体和导航栏按钮(或导航条)设置亮色或暗色

  3. 状况栏导航栏设置通明后,咱们页面的布局会延伸到原本状况栏导航栏的位置,这时候需求一些手法将咱们需求显现的正文内容回缩到其正确的显现范围内

    这儿我给咱们供给以下几种思路,咱们能够依据实际状况自行选择:

    • 设置fitsSystemWindows特点
    • 依据状况栏导航栏的高度,给根布局设置相应的paddingToppaddingBottom
    • 依据状况栏导航栏的高度,给需求移位的控件设置相应的marginTopmarginBottom
    • 在顶部和底部增加两个占位的View,高度别离设置成状况栏和导航栏的高度
    • 针对滑动视图,巧用clipChildrenclipToPadding特点(可参照高能链藏品详情页款式)

沉溺式状况栏

思路说完了,咱们现在开端进入实战,沉溺式状况栏比较简略,没什么坑

状况栏通明

首要第一步,咱们需求将状况栏的布景设置为通明,这儿我直接放代码

fun transparentStatusBar(window: Window) {
    window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
    window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
    var systemUiVisibility = window.decorView.systemUiVisibility
    systemUiVisibility =
        systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
    window.decorView.systemUiVisibility = systemUiVisibility
    window.statusBarColor = Color.TRANSPARENT
    //设置状况栏文字色彩
    setStatusBarTextColor(window, NightMode.isNightMode(window.context))
}

首要,咱们需求将FLAG_TRANSLUCENT_STATUS这个windowFlag换成FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS,否则状况栏不会完全通明,会有一个半通明的灰色蒙层

FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS这个flag表明体系Bar的布景将交给当时window绘制

SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN这个flag表明Activity全屏显现,但状况栏不会被躲藏,仍然可见

SYSTEM_UI_FLAG_LAYOUT_STABLE这个flag表明保持整个View稳定,使View不会由于体系UI的改变而从头layout

SYSTEM_UI_FLAG_LAYOUT_FULLSCREENSYSTEM_UI_FLAG_LAYOUT_STABLE这两个flag一般是一起运用的,咱们设置这两个flag,然后再将statusBarColor设置为通明,就达成了状况栏布景通明的作用

状况栏文字色彩

接着咱们就该设置状况栏文字色彩了,细心的小伙伴们应该现已留意到了,我在transparentStatusBar办法的结尾加了一个setStatusBarTextColor的办法调用,一般状况下,假如是日间形式,页面布景一般都是亮色,所以此刻状况栏文字色彩设置为黑色比较合理,而在夜间形式下,页面布景一般都是暗色,此刻状况栏文字色彩设置为白色比较合理,对应代码如下

fun setStatusBarTextColor(window: Window, light: Boolean) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        var systemUiVisibility = window.decorView.systemUiVisibility
        systemUiVisibility = if (light) { //白色文字
            systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
        } else { //黑色文字
            systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
        }
        window.decorView.systemUiVisibility = systemUiVisibility
    }
}

Android 8.0以上才支撑导航栏文字色彩的修正,SYSTEM_UI_FLAG_LIGHT_STATUS_BAR这个flag表明亮色状况栏,即黑色状况栏文字,所以假如希望状况栏文字为黑色,就设置这个flag,假如希望状况栏文字为白色,就将这个flagsystemUiVisibility中除掉

可能有小伙伴不太了解kotlin中的位运算,kotlin中的orandinv别离对应着或、与、取反运算

所以

systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()

翻译成java即为

systemUiVisibility & ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR

在原生体系上,这么设置就能够成功设置状况栏文字色彩,但我发现,在某些体系上,这样设置后的作用是不可预期的,譬如MIUI体系的状况栏文字色彩似乎是依据状况栏布景色彩自适应的,且日间形式和黑夜形式下的自适应战略还略有不同。不过在大多数状况下,它自适应的色彩都是正常的,咱们就按照咱们希望的成果设置就能够了。

纠正显现区域

fitsSystemWindows

纠正状况栏显现区域最简略的办法便是设置fitsSystemWindows特点,设置了该特点的View的一切padding特点都将失效,而且体系会主动为其增加paddingTop(设置了通明状况栏的状况下)和paddingBottom(设置了通明导航栏的状况下)

我个人是不必这种办法的,首要它会掩盖你设置的padding,其次,假如你一起设置了通明状况栏和通明导航栏,这个特点没有办法分开来处理,很不灵敏

获取状况栏高度

除了fitsSystemWindows这种办法外,其他的办法都得依托获取状况栏高度了,这儿直接上代码

fun getStatusBarHeight(context: Context): Int {
    val resId = context.resources.getIdentifier(
        "status_bar_height", "dimen", "android"
    )
    return context.resources.getDimensionPixelSize(resId)
}

状况栏不像导航栏那样多变,所以直接这样获取高度就能够了,导航栏的高度飘忽不定才是真实的噩梦

这儿再给两个设置View marginpadding的东西办法吧,帮助咱们快速运用

fun fixStatusBarMargin(vararg views: View) {
    views.forEach { view ->
        (view.layoutParams as? ViewGroup.MarginLayoutParams)?.let { lp ->
            lp.topMargin = lp.topMargin + getStatusBarHeight(view.context)
            view.requestLayout()
        }
    }
}
fun paddingByStatusBar(view: View) {
    view.setPadding(
        view.paddingLeft,
        view.paddingTop + getStatusBarHeight(view.context),
        view.paddingRight,
        view.paddingBottom
    )
}

沉溺式导航栏

沉溺式导航栏比较沉溺式状况栏坑会多许多,详细原因咱们后边再说

导航栏通明

和沉溺式状况栏相同,第一步咱们需求将导航栏的布景设置为通明

fun transparentNavigationBar(window: Window) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
        window.isNavigationBarContrastEnforced = false
    }
    window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_NAVIGATION)
    window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
    var systemUiVisibility = window.decorView.systemUiVisibility
    systemUiVisibility =
        systemUiVisibility or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
    window.decorView.systemUiVisibility = systemUiVisibility
    window.navigationBarColor = Color.TRANSPARENT
    //设置导航栏按钮或导航条色彩
    setNavigationBarBtnColor(window, NightMode.isNightMode(window.context))
}

Android 10以上,当设置了导航栏栏布景为通明时,isNavigationBarContrastEnforced假如为true,则体系会主动绘制一个半通明布景来供给对比度,所以咱们要将这个特点设为false

ps:状况栏其实也有对应的特点isStatusBarContrastEnforced,只不过这个特点默许即为false,咱们不需求特意去设置

导航栏按钮或导航条色彩

和设置状况栏文字色彩相同,我这儿就不多介绍了

fun setNavigationBarBtnColor(window: Window, light: Boolean) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
        var systemUiVisibility = window.decorView.systemUiVisibility
        systemUiVisibility = if (light) { //白色按钮
            systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR.inv()
        } else { //黑色按钮
            systemUiVisibility or View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR
        }
        window.decorView.systemUiVisibility = systemUiVisibility
    }
}

纠正显现区域

fitsSystemWindows

和状况栏运用相同,我就不重复阐明晰

获取导航栏高度

自从全面屏手势开端流行,导航栏也从原先的三键式,变成了三键式、导航条、全躲藏这三种状况,这三种状况下的高度也是互不相同的

三键式和导航条这两种状况咱们都能够通过android.R.dimen.navigation_bar_height这个资源获取到精确高度,但现在许多体系都支撑躲藏导航栏的功用,在这种状况下,虽然实际导航栏的高度应该是0,但是通过资源获取到的高度却为三键式或导航条的高度,这就给咱们沉溺式导航栏的适配带来了很大困难

经过我的各种测验,我发现只有一种办法能够精确的获取到当时导航栏的高度,那便是WindowInsets,至于WindowInsets是什么我就不多介绍了,咱们直接看代码

/**
* 仅当view attach window后生效
*/
private fun getRealNavigationBarHeight(view: View): Int {
    val insets = ViewCompat.getRootWindowInsets(view)
        ?.getInsets(WindowInsetsCompat.Type.navigationBars())
    //WindowInsets为null则默许通过资源获取高度
    return insets?.bottom ?: getNavigationBarHeight(view.context)
}

这儿需求留意到我在办法上写的注释,只有当ViewWindow attach 后,才能获得到WindowInsets,否则为null,所以我一开端的想法是先查看View是否 attach 了Window,假如有的话则直接调用getRealNavigationBarHeight办法,假如没有的话,调用View.addOnAttachStateChangeListener办法,当出发attach回调后,再调用getRealNavigationBarHeight办法获取高度

这种办法在大部分状况下运行良好,但在我一次无意中切换了体系夜间形式后发现,获取到的导航栏高度变成了0,而且这仍是一个偶现的问题,所以我测验运用View.setOnApplyWindowInsetsListener,监听WindowInsets的改变发现,这个回调有可能会触发屡次,在触发屡次的状况下,前几次的值都为0,只有最后一次的值为真实的导航栏高度

所以我准备用View.setOnApplyWindowInsetsListener代替View.addOnAttachStateChangeListener,但毕竟一个是setListener,一个是addListener,setListener有可能会把之前设置好的Listener掩盖,或者被其他Listener掩盖掉,再考虑到之后会提到的底部Dialog沉溺式导航栏适配的问题,我折中了一下,决议只对Activity下的rootView设置回调

以下是完好代码

private class NavigationViewInfo(
    val hostRef: WeakReference<View>,
    val viewRef: WeakReference<View>,
    val rawBottom: Int,
    val onNavHeightChangeListener: (View, Int, Int) -> Unit
)
private val navigationViewInfoList = mutableListOf<NavigationViewInfo>()
private val onApplyWindowInsetsListener = View.OnApplyWindowInsetsListener { v, insets ->
    val windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(insets, v)
    val navHeight =
        windowInsetsCompat.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
    val it = navigationViewInfoList.iterator()
    while (it.hasNext()) {
        val info = it.next()
        val host = info.hostRef.get()
        val view = info.viewRef.get()
        if (host == null || view == null) {
            it.remove()
            continue
        }
        if (host == v) {
            info.onNavHeightChangeListener(view, info.rawBottom, navHeight)
        }
    }
    insets
}
private val actionMarginNavigation: (View, Int, Int) -> Unit =
    { view, rawBottomMargin, navHeight ->
        (view.layoutParams as? ViewGroup.MarginLayoutParams)?.let {
            it.bottomMargin = rawBottomMargin + navHeight
            view.requestLayout()
        }
    }
private val actionPaddingNavigation: (View, Int, Int) -> Unit =
    { view, rawBottomPadding, navHeight ->
        view.setPadding(
            view.paddingLeft,
            view.paddingTop,
            view.paddingRight,
            rawBottomPadding + navHeight
        )
    }
fun fixNavBarMargin(vararg views: View) {
    views.forEach {
        fixSingleNavBarMargin(it)
    }
}
private fun fixSingleNavBarMargin(view: View) {
    val lp = view.layoutParams as? ViewGroup.MarginLayoutParams ?: return
    val rawBottomMargin = lp.bottomMargin
    val viewForCalculate = getViewForCalculate(view)
    if (viewForCalculate.isAttachedToWindow) {
        val realNavigationBarHeight = getRealNavigationBarHeight(viewForCalculate)
        lp.bottomMargin = rawBottomMargin + realNavigationBarHeight
        view.requestLayout()
    } else {
        val hostRef = WeakReference(viewForCalculate)
        val viewRef = WeakReference(view)
        val info = NavigationViewInfo(hostRef, viewRef, rawBottomMargin, actionMarginNavigation)
        navigationViewInfoList.add(info)
        viewForCalculate.setOnApplyWindowInsetsListener(onApplyWindowInsetsListener)
    }
}
fun paddingByNavBar(view: View) {
    val rawBottomPadding = view.paddingBottom
    val viewForCalculate = getViewForCalculate(view)
    if (viewForCalculate.isAttachedToWindow) {
        val realNavigationBarHeight = getRealNavigationBarHeight(viewForCalculate)
        view.setPadding(
            view.paddingLeft,
            view.paddingTop,
            view.paddingRight,
            rawBottomPadding + realNavigationBarHeight
        )
    } else {
        val hostRef = WeakReference(viewForCalculate)
        val viewRef = WeakReference(view)
        val info =
            NavigationViewInfo(hostRef, viewRef, rawBottomPadding, actionPaddingNavigation)
        navigationViewInfoList.add(info)
        viewForCalculate.setOnApplyWindowInsetsListener(onApplyWindowInsetsListener)
    }
}
/**
* Dialog下的View在低版本机型中获取到的WindowInsets值有误,
* 所以测验去获得Activity的contentView,通过Activity的contentView获取WindowInsets
*/
@SuppressLint("ContextCast")
private fun getViewForCalculate(view: View): View {
    return (view.context as? ContextWrapper)?.let {
        return@let (it.baseContext as? Activity)?.findViewById<View>(android.R.id.content)?.rootView
    } ?: view.rootView
}
/**
* 仅当view attach window后生效
*/
private fun getRealNavigationBarHeight(view: View): Int {
    val insets = ViewCompat.getRootWindowInsets(view)
        ?.getInsets(WindowInsetsCompat.Type.navigationBars())
    return insets?.bottom ?: getNavigationBarHeight(view.context)
}

我简略解释一下这段代码:为一切需求沉溺的页面的根View设置同一个回调,并将待适配导航栏高度的View增加到列表中,当WindowInsets回调触发后,遍历这个列表,判别触发回调的Viewhost是否与待适配导航栏高度的View对应,对应的话则处理View适配导航栏高度

这儿我也测验了内存走漏状况,承认无内存走漏,咱们能够放心食用

底部Dialog适配沉溺式

底部Dialog适配沉溺式要比正常的Activity更费事一些,首要问题也是会集在沉溺式导航栏上

获取导航栏高度

细心的小伙伴们能够现已留意到了我在沉溺式导航栏获取高度那里代码中的注释,Dialog下的View在低版本机型(经测验,Android 9一下就会有这个问题)中获取到的WindowInsets值有误,所以测验去获得ActivitycontentView,通过ActivitycontentView获取WindowInsets

LayoutParams导致的反常

在某些体系上(比方MIUI),当我window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)时,沉溺式会出现问题,状况栏会被蒙层盖住,Dialog底部的内容也会被一个不可思议的东西遮挡住

我的解决方案是,window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT),然后布局最外层全部占满,内部留一个底部容器

<!-- dialog_pangu_bottom_wrapper -->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="@android:color/transparent">
    <FrameLayout
        android:id="@+id/pangu_bottom_dialog_container"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom"
        android:clickable="true"
        android:focusable="true" />
</FrameLayout>

然后在代码中重写setContentView办法

private var canceledOnTouchOutside = true
override fun setContentView(layoutResID: Int) {
    setContentView(
        LayoutInflater.from(context).inflate(layoutResID, null, false)
    )
}
override fun setContentView(view: View) {
    setContentView(
        view,
        ViewGroup.LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.WRAP_CONTENT
        )
    )
}
override fun setContentView(view: View, params: ViewGroup.LayoutParams?) {
    val root =
        LayoutInflater.from(context).inflate(R.layout.dialog_pangu_bottom_wrapper, null, false)
    root.setOnClickListener {
        if (canceledOnTouchOutside) {
            dismiss()
        }
    }
    val container = root.findViewById<ViewGroup>(R.id.pangu_bottom_dialog_container)
    container.addView(view, params)
    super.setContentView(
        root,
        ViewGroup.LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.MATCH_PARENT
        )
    )
}
override fun setCanceledOnTouchOutside(cancel: Boolean) {
    super.setCanceledOnTouchOutside(cancel)
    canceledOnTouchOutside = cancel
}

这样的话视觉作用就和一般的底部Dialog相同了,为了进一步减小底部Dialog显现躲藏动画之间的差异,我将动画插值器从linear_interpolator换成了decelerate_interpolatoraccelerate_interpolator

<!-- dialog_enter_from_bottom_to_top -->
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="300"
    android:fromYDelta="100%"
    android:interpolator="@android:anim/decelerate_interpolator"
    android:toYDelta="0" />
<!-- dialog_exit_from_top_to_bottom -->
<translate xmlns:android="http://schemas.android.com/apk/res/android"
    android:duration="300"
    android:fromYDelta="0"
    android:interpolator="@android:anim/accelerate_interpolator"
    android:toYDelta="100%" />

尾声

自此,目前沉溺式遇到的问题全部都解决了,假如以后发现了什么新的问题,我会在这篇文章中弥补阐明,假如还有什么不明白的当地能够评论,我考虑要不要拿几个详细的场景实战解说,各位看官老爷费事点个赞收个藏不走失