[Digging] Android Translucent Status Bar

cover
cover

原文链接:https://blog.kyleduo.com/2017/05/02/digging-translucentstatusbar/

Material Design将Immersive(沉浸感)这个词带到了我们面前。相比于全屏沉浸感,我们见到更多的,是在4.4及以上版本的半透明状态栏(translucent statusBar)效果。如下:

translucent_status_bar
translucent_status_bar

注意:

半透明状态栏状态栏着色是两个不同的概念。半透明状态栏并不是手动设置状态栏的颜色,而是通过设置Theme的属性,让内容(content)显示在状态栏下面。

网上提到这个效果的不少,但是真的讲明白的不多。下面我来带你一起digging~

效果和问题

上面示例图是5.0及以上系统(API v21)的效果,因为Android版本的关系,在4.4(API v19)上,相同的属性会导致不同的效果。下面是对比:

preview
preview

可以看到,4.4上的效果相对于5.0及以上(下面说的5.0都指5.0及以上)的半透明,4.4上是一个渐变的效果。不去讨论孰优孰劣,暂时记住这个效果。

上面就是Demo的效果,Demo只有一个页面,层级如下:

structs
structs

但是4.4和5.0还有更大的不同,下面提到的实现方式的不同将导致编码的差异。

实现和差异

半透明状态栏是在4.4版本引入的,但是fitsSystemWindows这个属性在API 16就已经引入了,用来让View根据Window的缩进(WindowInsets)进行响应处理——设置padding或者offset;不只是用在状态栏上,NavigationBar对Content的影响,也通过这个标记位传递到View上。

回顾一下通常我们看到的实现方法:

  1. 在styles.xml中设置android:windowTranslucentStatus属性为true
  2. 在布局文件中设置android:fitsSystemWindows属性为true

如果你真的查到了这些方法并且按照上面写的做了,那么极大可能你做出的效果在4.4和5.0上是不一样的,或者是需要再次调整和尝试修改。

上面提到的步骤中,第一步是没有问题的,4.4及以上的styles.xml文件加上这个属性即可。

关键在于第二步,fitsSystemWindows属性应该设置到哪个/些View上?

这个问题引出了这篇博客的关键,4.4和5.0系统对于fitsSystemWindows属性解析的不同实现。我也是遇到了设置这个属性的问题,才开始进行实现原理的分析,发现之前的理解和使用,根本就是错的。

4.4 KitKat v19

两个系统的处理逻辑的相同点是,都是从ViewTree的顶端开始,向下进行深度优先遍历,让各级子View进行处理。不同点在于派发的方法和逻辑。

要看的方法是View.fitSystemWindows方法和ViewGroup.fitSystemWindows方法(v19 SDK),方法下面有解释。

View.fitSystemWindows

protected boolean fitSystemWindows(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;
}

根据自己的标记为判断是否要响应insets。如果需要的话,调用internalSetPadding方法设置padding。

ViewGroup.fitSystemWindows

@Override
protected boolean fitSystemWindows(Rect insets) {
    boolean done = super.fitSystemWindows(insets);
    if (!done) {
        final int count = mChildrenCount;
        final View[] children = mChildren;
        for (int i = 0; i < count; i++) {
            done = children[i].fitSystemWindows(insets);
            if (done) {
                break;
            }
        }
    }
    return done;
}

深度遍历子View,依次调用自己和子ViewfitSystemWindows方法,一旦fitSystemWindows方法返回true,停止遍历,完成处理。

总结一下就是:深度遍历,直到给第一个设置标记的View设置padding。

5.0 Lollipop v21

这是说是v21,其实源码里很多地方是按照v20作为分界点,但是里面的逻辑并不会执行(因为外层有v21判断),所以这里可以按照v21来区分。

我们在v21的SDK中可以看到,上面的View.fitSystemWindows已经过时了:

This method was deprecated in API level 20.
As of API 20 use dispatchApplyWindowInsets(WindowInsets) to apply insets to views. Views should override onApplyWindowInsets(WindowInsets) or usesetOnApplyWindowInsetsListener(android.view.View.OnApplyWindowInsetsListener) to implement handling their own insets.

依然看一下方法实现:

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);
    }
}

else分支的fitSystemWindowsInt方法就是v19的实现。

PFLAG3_APPLYING_INSETS标记表明了正在处理dispatchApplyWindowInsets遍历。

v21开始使用dispatch/apply逻辑,类似TouchEvent事件处理。父控件依次调用dispatchApplyWindowInsets方法,而View类的dispatchApplyWindowInsets方法中使用onApplyWindowInsets方法或者OnApplyWindowInsetsListener对象进行处理。

下面看一下主要的三个方法。

ViewGroup.dispatchApplyWindowInsets

@Override
public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) {
    insets = super.dispatchApplyWindowInsets(insets);
    if (!insets.isConsumed()) {
        final int count = getChildCount();
        for (int i = 0; i < count; i++) {
            insets = getChildAt(i).dispatchApplyWindowInsets(insets);
            if (insets.isConsumed()) {
                break;
            }
        }
    }
    return insets;
}

如果自己没有消费并且子View也没有消费,交给父View处理。

遍历的结束条件就是insets对象的isConsumed标记为true,因为把insets返回给了父View,父View的相同方法也会停止遍历,依次向上。

View.dispatchApplyWindowInsets

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;
    }
}

View.onApplyWindowInsets

public WindowInsets onApplyWindowInsets(WindowInsets insets) {
    if ((mPrivateFlags3 & PFLAG3_FITTING_SYSTEM_WINDOWS) == 0) {
        // We weren't called from within a direct call to fitSystemWindows,
        // call into it as a fallback in case we're in a class that overrides it
        // and has logic to perform.
        if (fitSystemWindows(insets.getSystemWindowInsets())) {
            return insets.consumeSystemWindowInsets();
        }
    } else {
        // We were called from within a direct call to fitSystemWindows.
        if (fitSystemWindowsInt(insets.getSystemWindowInsets())) {
            return insets.consumeSystemWindowInsets();
        }
    }
    return insets;
}

consumeSystemWindowInsets方法将消费掉所有Inset并将isConsumed()标记置为true。

总结一下:深度遍历,从上至下依次消费Insets,直到WindowInsets的isConsumed方法返回true,通常是调用过consumeSystemWindowInsets方法。

分析和实例

下面讨论上面例子中出现的ViewGroup的实现以及不同参数设置的情况,为了节省空间,方便输入,关键的四个View:AppBarLayoutCollapsingToolbarLayoutToolbarImageView(Toolbar同级)分别简写为ABL、CTL、TB、IV,然后用T和F表示fitsSystemWindows属性的值。

ABL、CTL等Support包中的View,使用通过ViewCompact设置OnApplyWindowInsetsListener的方式,处理,通常写法如下:

ViewCompat.setOnApplyWindowInsetsListener(this,
        new android.support.v4.view.OnApplyWindowInsetsListener() {
            @Override
            public WindowInsetsCompat onApplyWindowInsets(View v,
                    WindowInsetsCompat insets) {
                return onWindowInsetChanged(insets);
            }
        });

onWindowInsetChanged是核心方法;但要注意setOnApplyWindowInsetsListener只在5.0及以上SDK生效,也就是说onWindowInsetChanged方法在5.0以下版本不会被调用。

AppBarLayout.onWindowInsetChanged()

WindowInsetsCompat onWindowInsetChanged(final WindowInsetsCompat insets) {
    WindowInsetsCompat newInsets = null;

    if (ViewCompat.getFitsSystemWindows(this)) {
        // If we're set to fit system windows, keep the insets
        newInsets = insets;
    }

    // If our insets have changed, keep them and invalidate the scroll ranges...
    if (!objectEquals(mLastInsets, newInsets)) {
        mLastInsets = newInsets;
        invalidateScrollRanges();
    }

    return insets;
}

invalidateScrollRanges将重置ScrollRange相关的标记位,同时记录insets的值,其他位置通过getTopInset()获取insets对顶部偏移量的影响(只有insets的top影响布局)。

ABL不消费Insets。

CollapsingToolbarLayout.onWindowInsetChanged()

WindowInsetsCompat onWindowInsetChanged(final WindowInsetsCompat insets) {
    WindowInsetsCompat newInsets = null;

    if (ViewCompat.getFitsSystemWindows(this)) {
        // If we're set to fit system windows, keep the insets
        newInsets = insets;
    }

    // If our insets have changed, keep them and invalidate the scroll ranges...
    if (!objectEquals(mLastInsets, newInsets)) {
        mLastInsets = newInsets;
        requestLayout();
    }

    // Consume the insets. This is done so that child views with fitSystemWindows=true do not
    // get the default padding functionality from View
    return insets.consumeSystemWindowInsets();
}

可以看到,只要CTL的标记为true,是一定消费Insets的。

mLastInsets属性在onLayout方法中用到,下面是相关实现:

@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);
                }
            }
        }
    }
  
    ////////////////
    //   其他无关实现
    ////////////////
}

这个逻辑是:

遍历所有子View,如果子View没有设置fitsSystemWindows标记,只要getTop()的值小于insetTop,就将其偏移到insetTop。

换句话说:设置了标记的子View会在StatusBar下面(under)绘制,没有设置标记的子View会被挤下去(down)

同时,CTL还有一个有意思的逻辑:

@Override
protected void onAttachedToWindow() {
    super.onAttachedToWindow();

    // Add an OnOffsetChangedListener if possible
    final ViewParent parent = getParent();
    if (parent instanceof AppBarLayout) {
        // Copy over from the ABL whether we should fit system windows
        ViewCompat.setFitsSystemWindows(this, ViewCompat.getFitsSystemWindows((View) parent));

        if (mOnOffsetChangedListener == null) {
            mOnOffsetChangedListener = new OffsetUpdateListener();
        }
        ((AppBarLayout) parent).addOnOffsetChangedListener(mOnOffsetChangedListener);

        // We're attached, so lets request an inset dispatch
        ViewCompat.requestApplyInsets(this);
    }
}

如果CTL的直接父View是ABL,会同步ABL的fitsSystemWindows属性的值。

下面开始讨论各种不同参数的情况。

均为true: ABL = CTL = TB = IV = T

结果:

try_01
try_01

错误原因:

4.4:深度遍历,第一个遇到的View是ABL,执行View的默认逻辑,设置paddingTop。所以露出了背景颜色,同时子View都被挤到了下面。

5.0:因为CTL设置了true,而且子View也都设置了true,所以TB和IV都在StatusBar下面绘制。

先保证5.0能显示,根据上面的错误,我们应该把TB设置为false,IV设置为true。这样5.0会显示正常,而4.4,情况和上面一样。

结果:

try_02
try_02

符合预期(4.4没有换图,一模一样,偷懒了),5.0正常,原因其实上面也说了,这里不再重复。只要清楚了原理,结果就显而易见了。

如果到这里你不太理解,再看看上面源码。

下面轮到4.4了。回头看一下ViewTree的层级关系:ABL -> CTL -> IV、TB。从上到下,我们期望的是TB执行View默认的逻辑(设置padding),所以应该是F -> F -> F、T。(IV应该是false,因为要出现在StatusBar下面)。

结果:

try_03
try_03

符合预期,4.4显示正确,但是5.0回到了第一次尝试的结果。原因就留给你啦,相信你一定能解释。

正确做法

经过几次尝试,我们发现要使得4.4和5.0都正确显示,需要给View的属性根据SDK版本设置不同的值,我们可以通过styles.xml简化这个操作。

<!-- v19 -->
<?xml version="1.0" encoding="utf-8"?>
<resources>

    <style name="AppTheme" parent="AppBaseTheme">
        <item name="android:windowTranslucentStatus">true</item>
    </style>


    <style name="TSB.ABL" parent="TSB">
        <item name="android:fitsSystemWindows">false</item>
    </style>

    <style name="TSB.CTL" parent="TSB">
        <item name="android:fitsSystemWindows">false</item>
    </style>

    <style name="TSB.IV" parent="TSB">
        <item name="android:fitsSystemWindows">false</item>
    </style>

    <style name="TSB.TB" parent="TSB">
        <item name="android:fitsSystemWindows">true</item>
    </style>

</resources>


<!-- v21 -->
<?xml version="1.0" encoding="utf-8"?>
<resources>

    <style name="AppTheme" parent="AppBaseTheme">
        <item name="android:windowTranslucentStatus">true</item>
    </style>


    <style name="TSB.ABL" parent="TSB">
        <item name="android:fitsSystemWindows">true</item>
    </style>

    <style name="TSB.CTL" parent="TSB">
        <item name="android:fitsSystemWindows">false</item>
    </style>

    <style name="TSB.IV" parent="TSB">
        <item name="android:fitsSystemWindows">true</item>
    </style>

    <style name="TSB.TB" parent="TSB">
        <item name="android:fitsSystemWindows">false</item>
    </style>

</resources>

v21的CTL可以设置为false,是因为CTL的值会同步ABL的值,所以这里的值没有作用,T或者F的结果是一样的。

layout长这样(省略了无关属性)

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.DrawerLayout>

    <android.support.design.widget.CoordinatorLayout>
      
        <android.support.design.widget.AppBarLayout style="@style/TSB.ABL">
          
            <android.support.design.widget.CollapsingToolbarLayout style="@style/TSB.CTL">
                <ImageView style="@style/TSB.IV"/>
                <android.support.v7.widget.Toolbar style="@style/TSB.TB"/>
            </android.support.design.widget.CollapsingToolbarLayout>
          
        </android.support.design.widget.AppBarLayout>
      
        <android.support.v7.widget.RecyclerView />
      
    </android.support.design.widget.CoordinatorLayout>

    <LinearLayout >
        <ImageView />
    </LinearLayout>

</android.support.v4.widget.DrawerLayout>

更多

以上,我们可以实现适配4.4和5.0的半透明状态栏效果,通知知晓了其中的原理。下面再讨论两个场景:

普通布局

上面的实例中使用到的布局都是Support包中的,还有一种情况是我们使用的是普通的布局,比如LinearlayoutRelativeLayout等。使用这些布局,应该怎么实现呢?

normal_layout
normal_layout

普通布局都使用默认实现,不管是4.4和5.0都将进行深度优先遍历,直到WindowInsets被消费。所以对于普通布局的做法是,只给ToolBar设置fitsSystemWindows=true属性。

不仅仅是ToolBar,任何布局都直接给期望的那个View设置fitsSystemWindows=true属性即可。

4.4 效果优化

4.4上的渐变效果,会显得ToolBar很高,视觉效果并不好。我们可以针对4.4做一些优化,在上面覆盖显示一个半透明View来模拟5.0上的效果,以第一个页面为例:

kitkat_opt
kitkat_opt

act_main.xml

<!-- others -->
<android.support.design.widget.CollapsingToolbarLayout>
    <!-- others -->
    <ViewStub
        android:id="@+id/main_status_bar_stub"
        android:layout_width="match_parent"
        android:layout_height="24dp"
        app:layout_collapseMode="pin"
        android:layout="@layout/stub_kitkat_statusbar"/>
</android.support.design.widget.CollapsingToolbarLayout>
<!-- others -->

@layout/stub_kitkat_statusbar只包含一个背景为半透明的View。

MainActivity.java

if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) {
    mStatusBarStub.inflate();
    mStatusBarOverlay = findViewById(R.id.main_status_bar_overlay);
    mStatusBarOverlay.getViewTreeObserver().addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
        @Override
        public void onGlobalLayout() {
            mStatusBarOverlay.getViewTreeObserver().removeGlobalOnLayoutListener(this);
            ViewGroup.LayoutParams layoutParams = mStatusBarOverlay.getLayoutParams();
            layoutParams.height = mToolbar.getPaddingTop();
        }
    });
}

这里获取ToolBar的PaddingTop并设置为Overlay的高度。

总结

4.4和5.0处理WindowInsets的逻辑,相同点是都进行深度优先遍历。不同点是4.4逐级调用fitSystemWindows方法,第一个带有fitsSystemWindows属性的View处理之后,整个流程结束;5.0通过类似Touch事件的dispatch和apply逻辑完成对WindowInsets的消费,消费可以通过onApplayWindowInsets方法或者Listener的方式,直到消费完成,流程结束。

fitsSystemWindows属性表明该View将根据Window的Insets进行适应,这个“适应”,一般来说是设置padding,CollapsingToolbarLayout的处理方法是对子View进行offset偏移。共同点是:表明该View的内容或者子View要向下移出状态栏的区域。一般情况下,只需要给一个View设置该属性。

为了实现半透明状态栏效果,需要做两件事:

  1. 在主题中设置android:windowTranslucentStatus值为true
  2. 给恰当的View设置android:fitsSystemWindows属性为true

以Toolbar为例,如果Toolbar在普通布局中,直接给Toolbar设置以上属性即可;如果是Demo中那种Material Design嵌套结构,就需要根据4.4和5.0的实现逻辑进行适配,方法这里就不赘述了。

Demo源码:Github

后续踩坑记录:

[Digging] Android Translucent Status Bar 2

[Digging] Android Translucent Status Bar 3

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,117评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,328评论 1 293
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,839评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,007评论 0 206
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,384评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,629评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,880评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,593评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,313评论 1 243
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,575评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,066评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,392评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,052评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,082评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,844评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,662评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,575评论 2 270

推荐阅读更多精彩内容