挖一挖 fitsSystemWindows 的奥秘

前言

最近新接手维护一个组件,需要解决组件的一些遗留历史问题,其中有一个问题就是页面的 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 就是 DecorViewDecorView 没有重写 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 的一些个人解析啦,有任何问题或者不足之处欢迎大家相互交流~

参考

medium.com/androiddeve...

nich.work/2017/window...

Android Detail:Window 篇------WindowInsets 与 fitsSystemWindow

Android 沉浸式状态栏必知必会

相关推荐
Estar.Lee1 小时前
时间操作[计算时间差]免费API接口教程
android·网络·后端·网络协议·tcp/ip
找藉口是失败者的习惯2 小时前
从传统到未来:Android XML布局 与 Jetpack Compose的全面对比
android·xml
Jinkey3 小时前
FlutterBasic - GetBuilder、Obx、GetX<Controller>、GetxController 有啥区别
android·flutter·ios
大白要努力!4 小时前
Android opencv使用Core.hconcat 进行图像拼接
android·opencv
天空中的野鸟5 小时前
Android音频采集
android·音视频
小白也想学C6 小时前
Android 功耗分析(底层篇)
android·功耗
曙曙学编程7 小时前
初级数据结构——树
android·java·数据结构
闲暇部落9 小时前
‌Kotlin中的?.和!!主要区别
android·开发语言·kotlin
诸神黄昏EX11 小时前
Android 分区相关介绍
android
大白要努力!12 小时前
android 使用SQLiteOpenHelper 如何优化数据库的性能
android·数据库·oracle