在 Android Translucent Status Bar 系列中,我基于“给哪个View设置fitsSystemWindows属性”的角度分析了Android对fitsSystemWindows的处理;这篇文章,我们把这个属性的处理过程详细分析一下,同时解决这个属性在另一个场景中的问题——ViewPager。
如果你不知道这个属性或者不知道WindowInsets是什么,推荐看一下Why would I want to fitsSystemWindows?
处理流程(API20)
我们知道View树的根节点是DecorView,而DecorView又是由ViewRootImpl管理的。ViewRootImpl负责View树和Window之间的消息发送和事件传递,ViewRootImpl通过Stub接收Window的消息。
WindowInsets是Window在大小发生变化的时候,回调传递给ViewRootImpl的。ViewRootImpl会保存WindowInsets的值,在performTraversal方法中,如果mApplyInsetsRequested标记为true,则执行WindowInsets的分发,具体为调用dispatchApplyInsets方法。
Android Translucent Status Bar 系列对WindowInsets分发的总结:
深度遍历,从上至下依次消费Insets,直到WindowInsets的isConsumed方法返回true
这个遍历就是dispatchApplyInsets方法触发的(API v25):
1 | void dispatchApplyInsets(View host) { |
也就是说DecorView的dispatchApplyWindowInsets就是整个遍历分发的入口,而DecorView的实现也是继承自ViewGroup的实现。
Android的UI框架中,对WindowInsets的处理基本都使用View和ViewGroup的默认实现:
View.dispatchApplyWindowInsets(WindowInsets)
1 | public WindowInsets dispatchApplyWindowInsets(WindowInsets insets) { |
View的dispatchApplyWindowInsets方法会直接尝试自己处理,先判断是否有OnApplyWindowInsetsListener
,有的话调用OnApplyWindowInsetsListener的处理方法,否则调用onApplyWindowInsets方法。
PFLAG3_APPLYING_INSETS
表示正在分发Windowinsets处理,防止循环调用。
View.onApplyWindowInsets(WindowInsets)
1 | public WindowInsets onApplyWindowInsets(WindowInsets insets) { |
PFLAG3_FITTING_SYSTEM_WINDOWS
标记表示正在处理SystemWindowInsets。如果当前没有在处理SystemWindowInsets,调用fitSystemWindows方法处理;否则调用fitSystemWindowsInt方法直接设置padding;如果这两个方法返回true,消费SystemWindowInsets。
SystemWindowInsets是WindowInsets的最常见一种,另外还有StableInsets(API v21)和WindowDecorInsets。
StableInsets和SystemWindowInsets类似,表示被StatusBar等遮盖的区域,不同的是StableInsets不会随着StatusBar的隐藏和显示变化。沉浸式全屏下,StatusBar可以通过手势呼出,StableInsets不会发生变化。
WindowDecorInsets为预留属性,忽略。
消费SystemWindowInsets是将SystemWindowInsets属性置为空,并将已消费的标记为置为true。
View.fitSystemWindow(Rect)
1 | protected boolean fitSystemWindows(Rect insets) { |
这个方法是API v20开始已经标记过时的方法,调用这个方法是为了保证基于之前版本开发的逻辑能够正常运行。
首先判断是否有PFLAG3_APPLYING_INSETS
标记,前文提到该标记位表示正在分发WindowInsets处理,如果正在分发,那么就直接调用fitSystemWindowsInt方法;否则针对SystemWindowInsets进行分发,并设置PFLAG3_FITTING_SYSTEM_WINDOWS
标记。View.onApplyWindowInsets(WindowInsets)方法判断如果存在该标记,就直接调用fitSystemWindowsInt方法。
View.fitSystemWindowsInt(Rect)
1 | private boolean fitSystemWindowsInt(Rect insets) { |
这个方法就是真正消费SystemWindowInsets的地方。首先判断是否设置了fitsSystemWindows属性,最终使用internalSetPadding方法设置padding,注意这里会直接覆盖已经设置好的padding。当然这样也可能导致一些问题,后面我们会说到。
ViewGroup.dispatchApplyWindowInsets(WindowInsets)
1 |
|
上面的一系列方法都是保证View能处理WindowInsets,最后看下ViewGroup是如何将WindowInsets分发给子View的。在遍历子View分发之前,首先调用了super.dispatchApplyWindowInsets方法,这实际上就是调用了View.dispatchApplyWindowInsets方法,通过前文的分析,View的实现就是自己处理,最终调用fitSystemWindowsInt,而View是否处理的唯一条件,就是是否设置了fitsSystemWindows属性。
这里可以得出一个结论:如果ViewGroup设置了fitsSystemWindows属性,那么将自己消费WindowInsets,而不会向下分发。
流程图
下面是整个分发过程的流程图。通常起始点在ViewRootImpl的dispatchApplyInsets方法,View的onApplyWindowInsets方法依然调用了废弃的fitSystemWindow方法是为了兼容有些覆写该方法的自定义View(兼容真是很麻烦),而API v21以后的fitSystemWindow方法再次调用了dispatchApplyWindowInsets方法,这样保证无论从dispatchApplyWindowInsets方法还是fitSystemWindow方法进入的处理流程,都可以完整调用onApplyWindowInsets方法和fitSystemWindow方法。
另外,onApplyWindowInsets方法也是public的,所以可以跳过dispatchApplyWindowInsets直接调用onApplyWindowInsets,为了保证分发过程的完整性,直接调用onApplyWindowInsets,也会在当前View中执行一次完整的dispatch -> apply
流程。
自定义View、ViewGroup
通过上面的分析,我们可以得到一些有用的信息:
- 并不是每次布局都会分发WindowInsets,只有当WindowInsets发生变化时,ViewRootImpl才会主动分发。如果子View需要更新WindowInsets,调用
ViewCompaxt.requestApplyInsets()
方法。 dispatchApplyWindowInsets
方法用于分发。- 消费WindowInsets的方法有两个:
OnApplyWindowInsetsListener
或者覆写onApplyWindowInsets
方法,因为后者是API v20才添加的,所以通常使用前者。 - 如果不希望执行默认的消费方式(padding),覆写前文的两个方法自行处理。
- 对于ViewGroup,如果设置了fitsSystemWindows属性,就一定会消费WindowInsets(不考虑overscan逻辑)。
- ViewGroup会优先尝试自己消费WindowInsets,然后才进行分发。
下面讨论几个自定义View的例子,看下Android官方的实现中是怎么处理WindowInsets的。当我们需要自定义实现WindowInsets的处理时,也可以参考。
CoordinatorLayout
CoordinatorLayout覆写了默认实现,最终通过dispatchApplyWindowInsetsToBehaviors
方法将WindowInsets分发给Behavior的onApplyWindowInsets
方法。
在onAttachToWindow方法中,CoordinatorLayout包含如下逻辑:
1 | if (mLastInsets == null && ViewCompat.getFitsSystemWindows(this)) { |
CoordinatorLayout会缓存WindowInsets,当Attach到window上时,如果没有缓存,就请求刷新WindowInsets,如果CoordinatorLayout并不是在页面初次加载就被添加到View上,比如在ViewPager中,这个方法就能保证及时更新WindowInsets。
通过上面我们分析的结论,CoordinatorLayout只有当被设置fitsSystemWindows属性时才会执行自己的分发逻辑。所以对于需要消费WindowInsets的直接子View,有两种处理WindowInsets的方式:
- 给CoordinatorLayout设置fitsSystemWindows,子View不设置fitsSystemWindows属性,然后自定义Behavior实现
onApplyWindowInsets
方法处理。 - CoordinatorLayout不设置fitsSystemWindows属性,子View通过上面第3点结论处理。
CollapsingToolbarLayout
CollapsingToolbarLayout同样使用OnApplyWindowInsetsListener处理WindowInsets,在回调中CollapsingToolbarLayout缓存WindowInsets,在onLayout、OffsetUpdateListener、绘制scrim中计算偏移量。具体可在源码中搜索mLastInsets
。
有一个额外逻辑是:
1 |
|
如果父View是AppBarLayout,CollapsingToolbarLayout跟随父View的fitsSystemWindows属性。你可能会问如果父View设置了fitsSystemWindows属性,那CollapsingToolbarLayout即便也设置了这个属性,不也拿不到消费的机会了吗?
答案是不会。AppBarLayout、CollapsingToolbarLayout是关联使用的,所以耦合性很高。AppBarLayout中对WindowInsets的处理仅仅是记录和使用,并没有消费,真正消费是在CollapsingToolbarLayout中。感兴趣可以查看AppBarLayout的代码。
[Digging] Android Translucent StatusBar 3这篇文章中提到了CollapsingToolbarLayout处理WindowInsets引入的一个问题,虽然在onLayout中正确处理了偏移,但是onMeasure中没有根据WindowInsets扩大View尺寸,导致本来够大的View尺寸在设置Padding后放不下子View了。这在自定义ViewGroup处理WindowInsets的时候要特别注意。
ViewPager
如果一个ViewGroup有多个子View需要处理WindowInsets,应该怎么处理?这就是ViewPager面临的问题。我们知道如果要给子View分发WindowInsets,只需要调用子View的dispatchApplyWindowInsets方法即可。如果多个子View需要处理,那么相同一份WindowInsets,分发多次即可,当然要分发副本,否则就被子View消费了。
下面是ViewPager的实现:
1 | ViewCompat.setOnApplyWindowInsetsListener(this, |
源码的注释也说明了这个问题,为了让每个子View都收到WindowInsets事件,需要逐个分发。
问题
但是这个实现有个小问题,你发现了吗?
最后的return语句,返回了一个未被消费的,但是值为0的insets。ViewPager也是ViewGroup的子类,在dispatchApplyWindowInsets(WindowInsets)
方法中,super.dispatchApplyWindowInsets(insets)
方法的调用会触发上文的OnApplyWindowInsetsListener.onApplyWindowInsets(View, WindowInsetsCompat)
,但是之后的逻辑:
1 | if (!insets.isConsumed()) { |
会使得判断条件为真(没有被消费),进而继续分发,这样第一个子View(getChildAt(0),而不一定是Adapter中的index)就会再收到一次WindowInsets,而这个WindowInsets不再是副本,子View消费掉之后,会直接跳出循环并返回。(完整代码见上文))
1 | if (insets.isConsumed()) { |
导致的结果,就是第一个子View的WindowInsets被重置为0。
源码注释还说了是 “the consumed window insets”……
解决
解决方法,就是重新实现这个listener,在最后返回一个被消费的insets:
1 | // ... |
是的,多一个方法调用即可。
API 19
update 2017-11-15
ViewCompat
并不能解决所有版本的兼容性问题,在API 19上,ViewCompat.setOnApplyWindowInsetsListener
方法并不会产生任何效果,所以类似上面ViewPager的实现方式,在API 19上就不会有预期的适配效果。那怎么处理API 19上的兼容问题呢?
如果没有特别有力的理由(很可能没有),在API 19上就不要设置透明导航栏了:P。
万一老板非要有,有没有办法?有。
在没有dispatch/apply逻辑之前,也就是API 19上,View通过fitSystemWindows(Rect)
方法由上至下应用WindowInsets。那么我们也就通过这个方法来处理类似ViewPager这种需要分发给多个子View的情况。
这个解决方法并不只针对ViewPager,只要是需要干预WindowInsets的分发流程,在某一级将同一WindowInsets同时分发给多个View处理,都可以使用本文提到的方法。
1 |
|
因为API 19上View只通过这个方法分发Insets,我们也就只能通过这个方法来干预分发的过程,遗憾的是因为fitSystemWindows
为protected
方法,所以只能通过反射来调用。
还有一点要注意,API 20开始我们可以通过Listener来控制是否分发,但是API 19上,必须显示声明fitSystemWindow属性,所以还要加这一行代码:
1 | setFitsSystemWindows(Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT); |