带你彻底弄懂状态栏透明的细节 —— 深入分析 fitsSystemWindows

6,638 阅读8分钟

引入

透明状态栏是 Android apps 中经常需要实现的一种效果,很长一段时间,开发者都要为不同版本的适配而头痛,自 Android 4.4 KitKat 以来,系统中就已经提供修改状态栏(SystemUI)显示行为的选项了。其中带来的一个最令人困惑的问题就是 fitsSystemWindows 这个属性究竟该如何使用。

我们知道,给 Activity 设置透明状态栏十分简单,使用下面的 code snippet 就可以了:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
    Window window = getWindow();
    window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
    window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
    window.getDecorView().setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
            | View.SYSTEM_UI_FLAG_LAYOUT_STABLE);
    window.setStatusBarColor(Color.TRANSPARENT);
}

它相较于直接在 style.xml 中定义样式的好处就是不会有一个 scrim(不知道怎么翻译好,就是那个半透明的遮罩)。但只做这个工作就会导致下面这个情况:

Figure 1.

内容与状态栏区域重叠了!通常,大多数人会在布局中加一个:

android:fitsSystemWindows="true"

然后状态栏就显示正常了,但这还取决于布局,有的布局类直接加这个属性可能就不 work,尤其是 CoordinatorLayout 相关的布局,让人感觉这个属性很迷。确实,没有分析源码的时候我也很困惑。但经过简单的分析,一切都不是秘密。

初步分析

首先要想搞清楚这个属性的作用,我们就要到类中看看设置相关属性后到底会发生什么变化,于是找到 View 类的 setFitsSystemWindows 方法:

public void setFitsSystemWindows(boolean fitSystemWindows) {
    setFlags(fitSystemWindows ? FITS_SYSTEM_WINDOWS : 0, FITS_SYSTEM_WINDOWS);
}

额,好吧,其实经过继续跟踪之后,改变这个 flag 根本不会造成 View 的重新布局和 invalidate,所以这个属性一定是要在布局发生之前设置好的。但是看方法文档可以发现,这个属性与一个名为 fitSystemWindows 的方法密切相关,看一下:

protected boolean fitSystemWindows(Rect insets) {
    if ((mPrivateFlags3 & PFLAG3_APPLYING_INSETS) == 0) {
        if (insets == null) {
            // Null insets by definition have already been consumed.
            // This call cannot apply insets since there are none to apply,
            // so return false.
            return false;
        }
        // If we're not in the process of dispatching the newer apply insets call,
        // that means we're not in the compatibility path. Dispatch into the newer
        // apply insets path and take things from there.
        try {
            mPrivateFlags3 |= PFLAG3_FITTING_SYSTEM_WINDOWS;
            return dispatchApplyWindowInsets(new WindowInsets(insets)).isConsumed();
        } finally {
            mPrivateFlags3 &= ~PFLAG3_FITTING_SYSTEM_WINDOWS;
        }
    } else {
        // We're being called from the newer apply insets path.
        // Perform the standard fallback behavior.
        return fitSystemWindowsInt(insets);
    }
}

这里面涉及一个转发修正的问题,我们这里先不去管它,直接看真正的实现fitSystemWindowsInt

private boolean fitSystemWindowsInt(Rect insets) {
    if ((mViewFlags & FITS_SYSTEM_WINDOWS) == FITS_SYSTEM_WINDOWS) {
        mUserPaddingStart = UNDEFINED_PADDING;
        mUserPaddingEnd = UNDEFINED_PADDING;
        Rect localInsets = sThreadLocal.get();
        if (localInsets == null) {
            localInsets = new Rect();
            sThreadLocal.set(localInsets);
        }
        boolean res = computeFitSystemWindows(insets, localInsets);
        mUserPaddingLeftInitial = localInsets.left;
        mUserPaddingRightInitial = localInsets.right;
        internalSetPadding(localInsets.left, localInsets.top,
                localInsets.right, localInsets.bottom);
        return res;
    }
    return false;
}

可以看到,fitsSystemWindows 这个属性在这发挥了用武之地,如果设置了这个属性,那么就会有一个 padding 的设置,那这个 padding 来自哪,它是什么,现在还不得而知,这里就预计是状态栏的区域吧。padding 有什么用呢,ViewGroup 的一些子类在 measure 和 layout 的时候会获取 super 中与 padding 相关的成员变量来做布局上的调整,这就可以实现避开状态栏的问题了。

但是,上述方法的调用时机究竟是什么时候呢,我们可以通过 IDE 中强大的 Find Usages 来反向推导一下。最后发现它是由一个名为 dispatchApplyWindowInsets 的方法调用的,而且通过参数传了一个 WindowInsets 对象,这是什么鬼,我们后面就会讲到。在此之前我们断点打一下,看看这个方法是怎么被调用起来的:

Figure 2.

原来是 ViewRootImpl 发起的,这个类很重要,实现了很多 View 与 WindowManager 的交互,这里 ViewRootImpl somehow 拿到了一个 WindowInsets 对象,这个对象大家可以看看文档,就是包含了一些系统所占用的区域,这些区域可以被消耗掉,并且消耗之后返回的是一个全新的对象,这句话请谨记

有关状态栏的高度包含在这个对象中无疑了,为了日后的扩展性,这个对象可能还会新增更多的 insets 类型,但就目前而言,仅限于状态栏和圆形手表上的一些特殊模式。

好,我们继续分析上面提到的那个方法:

public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {
    try {
        mPrivateFlags3 |= PFLAG3_APPLYING_INSETS;
        if (mListenerInfo != null && mListenerInfo.mOnApplyWindowInsetsListener != null) {
            return mListenerInfo.mOnApplyWindowInsetsListener.onApplyWindowInsets(this, insets);
        } else {
            return onApplyWindowInsets(insets);
        }
    } finally {
        mPrivateFlags3 &= ~PFLAG3_APPLYING_INSETS;
    }
}

可以看到,这里不管设没设置 fitsSystemWindows 属性,都会激发一个 onApplyWindowInsets 回调,并且这个回调还可以通过 Listener 设置,有点意思。当然了,默认的回调实现的功能上面已经分析过了。

到现在为止貌似就可以解释为什么设置 fitsSystemWindows 属性后,绝大部分布局就可以避开状态栏了。但是不知道你有没有发现 CoordinatorLayout 会在状态栏下面画一个底色?FrameLayout 就没有这个特技,看来 CoordinatorLayout 的处理方式并非一个简单的 padding,肯定有自己的实现逻辑。

我们去它的源码找找看:

@Override
    public void setFitsSystemWindows(boolean fitSystemWindows) {
    super.setFitsSystemWindows(fitSystemWindows);
    setupForInsets();
}

直奔 setupForInsets

private void setupForInsets() {
    if (Build.VERSION.SDK_INT < 21) {
        return;
    }

    if (ViewCompat.getFitsSystemWindows(this)) {
        if (mApplyWindowInsetsListener == null) {
            mApplyWindowInsetsListener =
                    new android.support.v4.view.OnApplyWindowInsetsListener() {
                        @Override
                        public WindowInsetsCompat onApplyWindowInsets(View v,
                                WindowInsetsCompat insets) {
                            return setWindowInsets(insets);
                        }
                    };
        }
        // First apply the insets listener
        ViewCompat.setOnApplyWindowInsetsListener(this, mApplyWindowInsetsListener);

        // Now set the sys ui flags to enable us to lay out in the window insets
        setSystemUiVisibility(View.SYSTEM_UI_FLAG_LAYOUT_STABLE
                | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN);
    } else {
        ViewCompat.setOnApplyWindowInsetsListener(this, null);
    }
}

这段代码可以说就是 View 需要自定义 fitsSystemWindows 行为的标准范式。核心的处理逻辑就在 setWindowInsets 这个方法中:

final WindowInsetsCompat setWindowInsets(WindowInsetsCompat insets) {
    if (!objectEquals(mLastInsets, insets)) {
        mLastInsets = insets;
        mDrawStatusBarBackground = insets != null && insets.getSystemWindowInsetTop() > 0;
        setWillNotDraw(!mDrawStatusBarBackground && getBackground() == null);

        // Now dispatch to the Behaviors
        insets = dispatchApplyWindowInsetsToBehaviors(insets);
        requestLayout();
    }
    return insets;
}

知道状态栏下面的背景怎么来的了吧。

到这里理顺一下思路: fitsSystemWindowsonApplyWindowInsets 关系十分密切,后者将系统给出的 WindowInsets 派发给 View 让其根据前者这个属性来做自己的布局和绘制逻辑。

进阶应用

这一部分我们来讨论一下 WindowInsets 这个类,它有一个很重要的概念:consume。

这个概念重要到什么程度呢?如果你搞不懂 consume 和其 immutability,你自己的布局或者自定义 View 基本就爆炸了。

当一个 ViewdispatchApplyWindowInsets 被调用时,它需要对 WindowInsets 对象作出响应,然后将处理的结果返回,处理结果基本就两种:

  1. 你消耗了这个 insets,这时其它 View 收到的 insets 就是 0。
  2. 你不想消耗 insets,那么其它 View 将继续响应一开始的 insets 值。

还有一种特殊的情况:你返回了消耗过的 insets,但保存了一份原始 insets 引用,这时这个视图的兄弟视图和其兄弟视图的子视图就会收到值为 0 的 insets,而这个视图可以根据情况让它的子视图收到一个原始未消耗的 insets,这也是 DrawerLayout 所做的事情,想搞清它这么做的原因,本文就讲不完了,我后期可能会再开一篇文章分析。

讲这么多有没有 🌰 呢?当然有,先看下面的效果:

Figure 3.

显然,这是 CoordinatorLayout 配合 CollapsingToolbarLayout 实现的,但是这里给 CoordinatorLayoutfitsSystemWindows 就不灵了,它会吃掉状态栏的位置,然后画个背景色,我们的图片就不能垫在状态栏底下了,我通过分析各个类(这块真是花了很多时间),发现 AppBarLayout 也实现了 fitsSystemWindows 的自定义行为(毕竟放在它里面的 CollapsingToolbarLayout 有一个 statusBarScrim 属性),但是给它加上这个属性以后,图片依然会被挤下去。

怎么办呢?就在我扫荡 CollapsingToolbarLayout 的源码的时候发现了下面这段逻辑:

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    super.onLayout(changed, left, top, right, bottom);

    if (mLastInsets != null) {
        // Shift down any views which are not set to fit system windows
        final int insetTop = mLastInsets.getSystemWindowInsetTop();
        for (int i = 0, z = getChildCount(); i < z; i++) {
            final View child = getChildAt(i);
            if (!ViewCompat.getFitsSystemWindows(child)) {
                if (child.getTop() < insetTop) {
                    // If the child isn't set to fit system windows but is drawing within
                    // the inset offset it down
                    ViewCompat.offsetTopAndBottom(child, insetTop);
                }
            }
        }
    }

    ...
}

这就是说,如果 CollapsingToolbarLayout 的某个子视图开启了 fitsSystemWindows 这个属性,那么它就会被填满父视图,否则,它就会被下移 top inset 的距离。那这个问题的解决方法就很明显了,直接给 ImageView 加一个 fitsSystemWindows,完事了。

不得不感叹 Android 设计的精巧。

在我这么做之前,我看了市面上 99% 的 app 都是用了很“暴力”的方式解决,强行算状态栏高度,然后设置 margin,很不优雅,实际上 Android 已经为我们考虑地十分周全了,很多效果基本都可以用原生的方式实现,就看你会不会做了,如何发现这些小技巧,还是要靠源码分析。

那么最后给大家留一个小小的 homework,可否给我们的图片在状态栏的位置加一个 scrim?(hint:可以参考 NavigationView

推广信息

如果你对我的 Android 源码分析系列文章感兴趣,可以点个 star 哦,我会持续不定期更新文章。