前言
最近新接手维护一个组件,需要解决组件的一些遗留历史问题,其中有一个问题就是页面的 UI 被底部虚拟导航栏盖住了。一般遇到这种情况,我们只需要设置一下 fitsSystemWindows=true
就可以了。然而正当我以为这个问题就这么简单的解决了时,我惊讶的发现,该页面的代码已经设置过了 fitsSystemWindows=true
,还是被遮挡了。顿时觉得十分费解,看来自己对 fitsSystemWindows
属性还是没有理解透彻呀,趁此机会来了解一下 fitsSystemWindows
。
1. 什么时候需要使用 fitSystemWindows 这个属性呢?
回忆一下,你是否曾经遇到过底部导航栏遮盖了布局 UI 的情况出现呢?当我们设置了和沉浸式相关的属性,像 SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
(视图延伸至状态栏区域,状态栏上浮于视图之上)或 SYSTEM_UI_FLAG_HIDE_NAVIGATION
(视图延伸至导航栏区域,导航栏上浮于视图之上)。这个时候就可能会出现布局 UI 放到了导航栏或状态栏下方,给用户一种界面被遮盖的感觉。
为了解决上面的问题,Android 便提供了 fitSystemWindows
这个属性,简单来说该属性可以帮助 UI 保持正常的位置,避免被窗口中其他元素遮盖,也就是自动帮 UI 处理了边距。
那么,为什么会出现已经设置了 fitSystemWindows
,但是 UI 还是被遮盖的情况呢?
2. 先了解一下 WindowInsets 的概念
为了让更好的理解,先来看看 WindowInsets
的概念。WindowInsets
是 Window Content 的插入物。例如状态栏、导航栏、键盘等等,当它们显示时,会被插入到 Window 窗口的显示区域。一个 inset
对象中包括 left、top、right、bottom 的 4 个 int 偏移值。和 View 的事件分发机制一样,WindowInset
也是从父 View 开始分发的,也被称为深度优先。
这里简单捋一下,Activity 创建的时候,DecorView 也会被创建,但是此时 DecorView 还没有被加载到 Window 中,当处理 Activity 的 Resume 时,会把 DecorView 加载到 WindowManager 中。最后通过 ViewRootImpl 的 setView()
方法来执行加载 View 的逻辑。在这个方法中,会执行到大家熟知的 requestLayout
方法,兜兜转转最后来到 performTraversals
,这也是一个大家熟悉的方法,是 View 的三大工作流程的入口方法。不懂的可以看这篇文章 View 系列 ------ 浅谈 View 的三大工作流程。
当然了,这些内容对于理解本文的重点只是起到一个辅助作用,你只需要知道在处理 View 的过程中,会执行到一个方法 dispatchApplyInsets
,看名字就能知道这是一个分发 WindowInsets
的方法。
3. WindowInsets 的分发
java
public void dispatchApplyInsets(View host) {
...
host.dispatchApplyWindowInsets(insets);
...
}
我只保留了该方法中重要的一句代码,这个 host
就是 DecorView
,DecorView
没有重写 dispatchApplyWindowInsets
方法,所以直接执行基类 ViewGroup 的 dispatchApplyWindowInsets
。
java
@Override
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {
insets = super.dispatchApplyWindowInsets(insets);
// 是否已经消耗过了该inset
if (insets.isConsumed()) {
return insets;
}
// 这个if判断不重要,只要知道没有消耗过inset就继续往下分发就好
if (View.sBrokenInsetsDispatch) {
return brokenDispatchApplyWindowInsets(insets);
} else {
return newDispatchApplyWindowInsets(insets);
}
}
private WindowInsets brokenDispatchApplyWindowInsets(WindowInsets insets) {
final int count = getChildCount();
for (int i = 0; i < count; i++) {
// 分发给子View,看子View是否要消耗inset
insets = getChildAt(i).dispatchApplyWindowInsets(insets);
// 如果子View消耗了,跳出循环
if (insets.isConsumed()) {
break;
}
}
return insets;
}
ViewGroup 的 dispatchApplyWindowInsets
方法首先会判断当前的 inset 有没有被消耗,如果没有被消耗,就遍历子 View,执行 View 的 dispatchApplyWindowInsets
。
Java
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {
try {
mPrivateFlags3 |= PFLAG3_APPLYING_INSETS;
// 如果设置了OnApplyWindowInsetsListener,直接回调给listener的onApplyWindowInsets方法
if (mListenerInfo != null && mListenerInfo.mOnApplyWindowInsetsListener != null) {
return mListenerInfo.mOnApplyWindowInsetsListener.onApplyWindowInsets(this, insets);
} else {
// 没有设置过listener的话,执行View的onApplyWindowInsets方法
return onApplyWindowInsets(insets);
}
} finally {
mPrivateFlags3 &= ~PFLAG3_APPLYING_INSETS;
}
}
View 的处理也很简单,如果我们自己设置了 OnApplyWindowInsetsListener
,就直接回调给我们自己来处理 inset,如果没有设置 listener,就走 View 的默认方法 onApplyWindowInsets
方法。那默认肯定是走 onApplyWindowInsets
了,接着看:
Java
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
if ((mPrivateFlags4 & PFLAG4_FRAMEWORK_OPTIONAL_FITS_SYSTEM_WINDOWS) != 0
&& (mViewFlags & FITS_SYSTEM_WINDOWS) != 0) {
return onApplyFrameworkOptionalFitSystemWindows(insets);
}
if ((mPrivateFlags3 & PFLAG3_FITTING_SYSTEM_WINDOWS) == 0) {
if (fitSystemWindows(insets.getSystemWindowInsetsAsRect())) {
return insets.consumeSystemWindowInsets();
}
} else {
// 默认走这个逻辑,直接看这个方法
if (fitSystemWindowsInt(insets.getSystemWindowInsetsAsRect())) {
return insets.consumeSystemWindowInsets();
}
}
return insets;
}
private boolean fitSystemWindowsInt(Rect insets) {
// 如果设置了 fitSystemWindows == true,就会设置 FITS_SYSTEM_WINDOWS 标记位
if ((mViewFlags & FITS_SYSTEM_WINDOWS) == FITS_SYSTEM_WINDOWS) {
Rect localInsets = sThreadLocal.get();
boolean res = computeFitSystemWindows(insets, localInsets);
// 设置了 fitSystemWindows 属性后,重新处理 padding
applyInsets(localInsets);
// View 消耗了 inset 就返回 true
return res;
}
return false;
}
简单来说,进入 onApplyWindowInsets
后,默认会执行 fitSystemWindowsInt
方法,这个方法会判断有没有设置 FITS_SYSTEM_WINDOWS
这个标记位,设置了才是执行 fitSystemWindowsInt
内部逻辑的前提。看来这个标记位也要明白是个啥东西,这里我们先接着往下看。
Java
private void applyInsets(Rect insets) {
mUserPaddingStart = UNDEFINED_PADDING;
mUserPaddingEnd = UNDEFINED_PADDING;
mUserPaddingLeftInitial = insets.left;
mUserPaddingRightInitial = insets.right;
// 重新设置padding
internalSetPadding(insets.left, insets.top, insets.right, insets.bottom);
}
如果设置了 FITS_SYSTEM_WINDOWS
这个标记位,会通过 applyInsets()
方法处理 padding,并设置 View 已经消耗了该 inset。
4. FITS_SYSTEM_WINDOWS 这个标记位是怎么设置的呢?
上一个问题中我们说了如果想让系统帮我们重新设置 padding,前提是设置了 FITS_SYSTEM_WINDOWS
这个标记位,这样当分发 inset 的逻辑走到 fitSystemWindowsInt
方法中的时候,才会执行设置 padding 的操作。那么 FITS_SYSTEM_WINDOWS
这个标记位是怎么设置的呢?
揭晓答案,当设置了 View 的 fitsSystemWindows
属性为 true 的时候,就相当于设置了 FITS_SYSTEM_WINDOWS
这个标记位。
java
// View.java
public void setFitsSystemWindows(boolean fitSystemWindows) {
// fitSystemWindows 属性的设置,主要就是设置了一个标记位
setFlags(fitSystemWindows ? FITS_SYSTEM_WINDOWS : 0, FITS_SYSTEM_WINDOWS);
}
5. 总结
梳理了一遍 fitsSystemWindows
之后,我们来总结一下。
首先,fitsSystemWindows
属性通常和沉浸式相关,我们的 UI 可能会被状态栏或者导航栏遮盖住,这时候就需要我们给布局加一个 padding,比如说这个 padding 的大小是状态栏或导航栏的高度,这样就能避免布局被盖住。Android 已经提供给我们这个功能了,就是 fitsSystemWindows
。当设置该属性为 true 后,标记位 FITS_SYSTEM_WINDOWS
被设置,代表该 View 可以消耗 inset。
WindowInsets
也是像事件分发机制一样,在 performTraversals
的时候进行分发,通过 DecorView、ViewGroup 最后到达 View 的 dispatchApplyWindowInsets
。
需要注意的是,如果我们在自定义 View 中重写了 onApplyWindowInsets()
方法或者是设置了 setOnApplyWindowInsetsListener()
来监听 WindowInsets
的变化,那么在 View 的 dispatchApplyWindowInsets
方法中,就不会走系统默认的 onApplyWindowInsets()
方法了,而是由业务自己来处理,自己通过 inset 设置 padding。
setOnApplyWindowInsetsListener()
的优先级更高,当存在 OnApplyWindowInsetsListener
时不会执行 onApplyWindowInsets
。而我们设置的 fitSystemWindows == true
其实就是通过系统默认的 onApplyWindowInsets
方法来实现的。即设置了 fitSystemWindows == true
后,onApplyWindowInsets
才会去自动调整 padding。
6. 最后回到最开始的问题
了解完了 fitsSystemWindows
后,回到我们前言里提到的问题,为啥已经设置过了 fitsSystemWindows=true
,UI 还是被遮挡了。看了代码之后发现,原来有个地方偷偷使用了setOnApplyWindowInsetsListener
,所以设置了 fitsSystemWindows
也没啥用,因为根本不会走系统的 onApplyWindowInsets
方法帮我自动调整 padding 了。所以我就改成统一使用 setOnApplyWindowInsetsListener
来处理 padding 了。之所以不用 fitsSystemWindows
属性,是因为这个属性用不好也会有坑的,毕竟它是深度优先,第一个设置 fitSystemWindows == true
的 View 消费完 inset,后面的 View 就不会再消费了,难保不会在某些情况下出现适配问题。所以果断使用 setOnApplyWindowInsetsListener
。
以上就是我对 fitsSystemWindows 的一些个人解析啦,有任何问题或者不足之处欢迎大家相互交流~
参考
Android Detail:Window 篇------WindowInsets 与 fitsSystemWindow