前言
最近我在小破站開發(fā)一款新App,叫高能鏈。我是一個完美主義者,所以不管對架構(gòu)還是UI,我都是比較摳細(xì)節(jié)的,在狀態(tài)欄和導(dǎo)航欄沉浸式這一塊,我還是踩了挺多坑,費(fèi)了挺多精力的。這次我將我踩坑,適配各機(jī)型總結(jié)出來的史上最完美的Android沉浸式狀態(tài)導(dǎo)航欄攻略分享給大家,大家也可以去 高能鏈官網(wǎng) 下載體驗(yàn)一下我們的App,實(shí)際感受一下沉浸式狀態(tài)導(dǎo)航欄的效果(登錄,實(shí)名等賬號相關(guān)頁面由于不是我開發(fā)的,就沒有適配沉浸式導(dǎo)航欄啦,嘻嘻)
注:此攻略只針對 Android 5.0 及以上機(jī)型,即 minSdkVersion >= 21
實(shí)際效果
在開始攻略之前,我們先看看完美的沉浸式狀態(tài)導(dǎo)航欄效果
傳統(tǒng)三鍵式導(dǎo)航欄
全面屏導(dǎo)航條
理論分析
在上具體實(shí)現(xiàn)代碼之前,我們先分析一下,實(shí)現(xiàn)沉浸式狀態(tài)導(dǎo)航欄需要幾步
-
狀態(tài)欄導(dǎo)航欄底色透明
-
根據(jù)當(dāng)前頁面的背景色,給狀態(tài)欄字體和導(dǎo)航欄按鈕(或?qū)Ш綏l)設(shè)置亮色或暗色
-
狀態(tài)欄導(dǎo)航欄設(shè)置透明后,我們頁面的布局會延伸到原本狀態(tài)欄導(dǎo)航欄的位置,這時候需要一些手段將我們需要顯示的正文內(nèi)容回縮到其正確的顯示范圍內(nèi)
這里我給大家提供以下幾種思路,大家可以根據(jù)實(shí)際情況自行選擇:
- 設(shè)置
fitsSystemWindows
屬性 - 根據(jù)狀態(tài)欄導(dǎo)航欄的高度,給根布局設(shè)置相應(yīng)的
paddingTop
和paddingBottom
- 根據(jù)狀態(tài)欄導(dǎo)航欄的高度,給需要移位的控件設(shè)置相應(yīng)的
marginTop
和marginBottom
- 在頂部和底部增加兩個占位的
View
,高度分別設(shè)置成狀態(tài)欄和導(dǎo)航欄的高度 - 針對滑動視圖,巧用
clipChildren
和clipToPadding
屬性(可參照高能鏈藏品詳情頁樣式)
- 設(shè)置
沉浸式狀態(tài)欄
思路說完了,我們現(xiàn)在開始進(jìn)入實(shí)戰(zhàn),沉浸式狀態(tài)欄比較簡單,沒什么坑
狀態(tài)欄透明
首先第一步,我們需要將狀態(tài)欄的背景設(shè)置為透明,這里我直接放代碼
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
//設(shè)置狀態(tài)欄文字顏色
setStatusBarTextColor(window, NightMode.isNightMode(window.context))
}
首先,我們需要將FLAG_TRANSLUCENT_STATUS
這個windowFlag
換成FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS
,否則狀態(tài)欄不會完全透明,會有一個半透明的灰色蒙層
FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS
這個flag
表示系統(tǒng)Bar
的背景將交給當(dāng)前window
繪制
SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
這個flag
表示Activity
全屏顯示,但狀態(tài)欄不會被隱藏,依然可見
SYSTEM_UI_FLAG_LAYOUT_STABLE
這個flag
表示保持整個View
穩(wěn)定,使View
不會因?yàn)橄到y(tǒng)UI的變化而重新layout
SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
和SYSTEM_UI_FLAG_LAYOUT_STABLE
這兩個flag
通常是一起使用的,我們設(shè)置這兩個flag
,然后再將statusBarColor
設(shè)置為透明,就達(dá)成了狀態(tài)欄背景透明的效果
狀態(tài)欄文字顏色
接著我們就該設(shè)置狀態(tài)欄文字顏色了,細(xì)心的小伙伴們應(yīng)該已經(jīng)注意到了,我在transparentStatusBar
方法的末尾加了一個setStatusBarTextColor
的方法調(diào)用,一般情況下,如果是日間模式,頁面背景通常都是亮色,所以此時狀態(tài)欄文字顏色設(shè)置為黑色比較合理,而在夜間模式下,頁面背景通常都是暗色,此時狀態(tài)欄文字顏色設(shè)置為白色比較合理,對應(yīng)代碼如下
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
以上才支持導(dǎo)航欄文字顏色的修改,SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
這個flag
表示亮色狀態(tài)欄,即黑色狀態(tài)欄文字,所以如果希望狀態(tài)欄文字為黑色,就設(shè)置這個flag
,如果希望狀態(tài)欄文字為白色,就將這個flag
從systemUiVisibility
中剔除
可能有小伙伴不太了解kotlin
中的位運(yùn)算,kotlin
中的or
、and
、inv
分別對應(yīng)著或、與、取反運(yùn)算
所以
systemUiVisibility and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
翻譯成java
即為
systemUiVisibility & ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
在原生系統(tǒng)上,這么設(shè)置就可以成功設(shè)置狀態(tài)欄文字顏色,但我發(fā)現(xiàn),在某些系統(tǒng)上,這樣設(shè)置后的效果是不可預(yù)期的,譬如MIUI
系統(tǒng)的狀態(tài)欄文字顏色似乎是根據(jù)狀態(tài)欄背景顏色自適應(yīng)的,且日間模式和黑夜模式下的自適應(yīng)策略還略有不同。不過在大多數(shù)情況下,它自適應(yīng)的顏色都是正常的,我們就按照我們希望的結(jié)果設(shè)置就可以了。
矯正顯示區(qū)域
fitsSystemWindows
矯正狀態(tài)欄顯示區(qū)域最簡單的辦法就是設(shè)置fitsSystemWindows
屬性,設(shè)置了該屬性的View
的所有padding
屬性都將失效,并且系統(tǒng)會自動為其添加paddingTop
(設(shè)置了透明狀態(tài)欄的情況下)和paddingBottom
(設(shè)置了透明導(dǎo)航欄的情況下)
我個人是不用這種方式的,首先它會覆蓋你設(shè)置的padding
,其次,如果你同時設(shè)置了透明狀態(tài)欄和透明導(dǎo)航欄,這個屬性沒有辦法分開來處理,很不靈活
獲取狀態(tài)欄高度
除了fitsSystemWindows
這種方法外,其他的方法都得依靠獲取狀態(tài)欄高度了,這里直接上代碼
fun getStatusBarHeight(context: Context): Int {
val resId = context.resources.getIdentifier(
"status_bar_height", "dimen", "android"
)
return context.resources.getDimensionPixelSize(resId)
}
狀態(tài)欄不像導(dǎo)航欄那樣多變,所以直接這樣獲取高度就可以了,導(dǎo)航欄的高度飄忽不定才是真正的噩夢
這里再給兩個設(shè)置View
margin
或padding
的工具方法吧,幫助大家快速使用
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
)
}
沉浸式導(dǎo)航欄
沉浸式導(dǎo)航欄相比沉浸式狀態(tài)欄坑會多很多,具體原因我們后面再說
導(dǎo)航欄透明
和沉浸式狀態(tài)欄一樣,第一步我們需要將導(dǎo)航欄的背景設(shè)置為透明
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
//設(shè)置導(dǎo)航欄按鈕或?qū)Ш綏l顏色
setNavigationBarBtnColor(window, NightMode.isNightMode(window.context))
}
在Android 10
以上,當(dāng)設(shè)置了導(dǎo)航欄欄背景為透明時,isNavigationBarContrastEnforced
如果為true
,則系統(tǒng)會自動繪制一個半透明背景來提供對比度,所以我們要將這個屬性設(shè)為false
ps:狀態(tài)欄其實(shí)也有對應(yīng)的屬性isStatusBarContrastEnforced
,只不過這個屬性默認(rèn)即為false
,我們不需要特意去設(shè)置
導(dǎo)航欄按鈕或?qū)Ш綏l顏色
和設(shè)置狀態(tài)欄文字顏色一樣,我這里就不多介紹了
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
}
}
矯正顯示區(qū)域
fitsSystemWindows
和狀態(tài)欄使用一樣,我就不重復(fù)說明了
獲取導(dǎo)航欄高度
自從全面屏手勢開始流行,導(dǎo)航欄也從原先的三鍵式,變成了三鍵式、導(dǎo)航條、全隱藏這三種情況,這三種情況下的高度也是互不相同的
三鍵式和導(dǎo)航條這兩種情況我們都可以通過android.R.dimen.navigation_bar_height
這個資源獲取到準(zhǔn)確高度,但現(xiàn)在很多系統(tǒng)都支持隱藏導(dǎo)航欄的功能,在這種情況下,雖然實(shí)際導(dǎo)航欄的高度應(yīng)該是0,但是通過資源獲取到的高度卻為三鍵式或?qū)Ш綏l的高度,這就給我們沉浸式導(dǎo)航欄的適配帶來了很大困難
經(jīng)過我的各種嘗試,我發(fā)現(xiàn)只有一種方式可以準(zhǔn)確的獲取到當(dāng)前導(dǎo)航欄的高度,那就是WindowInsets
,至于WindowInsets
是什么我就不多介紹了,我們直接看代碼
/**
* 僅當(dāng)view attach window后生效
*/
private fun getRealNavigationBarHeight(view: View): Int {
val insets = ViewCompat.getRootWindowInsets(view)
?.getInsets(WindowInsetsCompat.Type.navigationBars())
//WindowInsets為null則默認(rèn)通過資源獲取高度
return insets?.bottom ?: getNavigationBarHeight(view.context)
}
這里需要注意到我在方法上寫的注釋,只有當(dāng)View
和Window
attach 后,才能獲得到WindowInsets
,否則為null
,所以我一開始的想法是先檢查View
是否 attach 了Window
,如果有的話則直接調(diào)用getRealNavigationBarHeight
方法,如果沒有的話,調(diào)用View.addOnAttachStateChangeListener
方法,當(dāng)出發(fā)attach
回調(diào)后,再調(diào)用getRealNavigationBarHeight
方法獲取高度
這種方式在大部分情況下運(yùn)行良好,但在我一次無意中切換了系統(tǒng)夜間模式后發(fā)現(xiàn),獲取到的導(dǎo)航欄高度變成了0,并且這還是一個偶現(xiàn)的問題,于是我嘗試使用View.setOnApplyWindowInsetsListener
,監(jiān)聽WindowInsets
的變化發(fā)現(xiàn),這個回調(diào)有可能會觸發(fā)多次,在觸發(fā)多次的情況下,前幾次的值都為0,只有最后一次的值為真正的導(dǎo)航欄高度
于是我準(zhǔn)備用View.setOnApplyWindowInsetsListener
代替View.addOnAttachStateChangeListener
,但畢竟一個是setListener,一個是addListener,setListener有可能會把之前設(shè)置好的Listener覆蓋,或者被別的Listener覆蓋掉,再考慮到之后會提到的底部Dialog
沉浸式導(dǎo)航欄適配的問題,我折中了一下,決定只對Activity
下的rootView
設(shè)置回調(diào)
以下是完整代碼
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()
}
//isAttachedToWindow方法并不能保證此時的WindowInsets是正確的,仍然需要添加監(jiān)聽
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
)
}
//isAttachedToWindow方法并不能保證此時的WindowInsets是正確的,仍然需要添加監(jiān)聽
val hostRef = WeakReference(viewForCalculate)
val viewRef = WeakReference(view)
val info =
NavigationViewInfo(hostRef, viewRef, rawBottomPadding, actionPaddingNavigation)
navigationViewInfoList.add(info)
viewForCalculate.setOnApplyWindowInsetsListener(onApplyWindowInsetsListener)
}
/**
* Dialog下的View在低版本機(jī)型中獲取到的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
}
/**
* 僅當(dāng)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
設(shè)置同一個回調(diào),并將待適配導(dǎo)航欄高度的View
添加到列表中,當(dāng)WindowInsets
回調(diào)觸發(fā)后,遍歷這個列表,判斷觸發(fā)回調(diào)的View
的host
是否與待適配導(dǎo)航欄高度的View
對應(yīng),對應(yīng)的話則處理View
適配導(dǎo)航欄高度
這里需要注意,WindowInsets
的分發(fā)其實(shí)是在dispatchAttachedToWindow
之后的,所以isAttachedToWindow
方法并不能保證此時的WindowInsets
是正確的,具體可以去看ViewRootImpl
中的源碼,關(guān)鍵方法:dispatchApplyInsets
,這里判斷isAttachedToWindow
并設(shè)置高度是為了防止出現(xiàn)View
已經(jīng)完全布局完成,之后再也不會觸發(fā)OnApplyWindowInsets
的情況
這里我也測試了內(nèi)存泄漏情況,確認(rèn)無內(nèi)存泄漏,大家可以放心食用
底部Dialog適配沉浸式
底部Dialog
適配沉浸式要比正常的Activity
更麻煩一些,主要問題也是集中在沉浸式導(dǎo)航欄上
獲取導(dǎo)航欄高度
仔細(xì)的小伙伴們可以已經(jīng)注意到了我在沉浸式導(dǎo)航欄獲取高度那里代碼中的注釋,Dialog
下的View
在低版本機(jī)型(經(jīng)測試,Android 9
一下就會有這個問題)中獲取到的WindowInsets
值有誤,所以嘗試去獲得Activity
的contentView
,通過Activity
的contentView
獲取WindowInsets
LayoutParams導(dǎo)致的異常
在某些系統(tǒng)上(比如MIUI),當(dāng)我window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
時,沉浸式會出現(xiàn)問題,狀態(tài)欄會被蒙層蓋住,Dialog
底部的內(nèi)容也會被一個莫名其妙的東西遮擋住
我的解決方案是,window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
,然后布局最外層全部占滿,內(nèi)部留一個底部容器
<!-- 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
一樣了,為了進(jìn)一步減小底部Dialog
顯示隱藏動畫之間的差異,我將動畫插值器從linear_interpolator
換成了decelerate_interpolator
和accelerate_interpolator
文章來源:http://www.zghlxwxcb.cn/news/detail-425283.html
<!-- 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%" />
尾聲
自此,目前沉浸式遇到的問題全部都解決了,如果以后發(fā)現(xiàn)了什么新的問題,我會在這篇文章中補(bǔ)充說明,如果還有什么不明白的地方可以評論,我考慮要不要拿幾個具體的場景實(shí)戰(zhàn)講解,各位看官老爺麻煩點(diǎn)個贊收個藏不迷路??文章來源地址http://www.zghlxwxcb.cn/news/detail-425283.html
到了這里,關(guān)于史上最完美的Android沉浸式狀態(tài)導(dǎo)航欄攻略的文章就介紹完了。如果您還想了解更多內(nèi)容,請?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!