[Digging] android:fitsSystemWindows

cover

原文链接

Android Translucent Status Bar 系列中,我基于“给哪个View设置fitsSystemWindows属性”的角度分析了Android对fitsSystemWindows的处理;这篇文章,我们把这个属性的处理过程详细分析一下,同时解决这个属性在另一个场景中的问题——ViewPager。

如果你不知道这个属性或者不知道WindowInsets是什么,推荐看一下Why would I want to fitsSystemWindows?

处理流程

我们知道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):

void dispatchApplyInsets(View host) {
    host.dispatchApplyWindowInsets(getWindowInsets(true /* forceConstruct */));
}

也就是说DecorView的dispatchApplyWindowInsets就是整个遍历分发的入口,而DecorView的实现也是继承自ViewGroup的实现。

Android的UI框架中,对WindowInsets的处理基本都使用View和ViewGroup的默认实现:

View.dispatchApplyWindowInsets(WindowInsets)

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的dispatchApplyWindowInsets方法会直接尝试自己处理,先判断是否有OnApplyWindowInsetsListener,有的话调用OnApplyWindowInsetsListener的处理方法,否则调用onApplyWindowInsets方法。

<span id="PFLAG3_APPLYING_INSETS">PFLAG3_APPLYING_INSETS</span>表示正在分发Windowinsets处理,防止循环调用。

View.onApplyWindowInsets(WindowInsets)

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

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)

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

这个方法是API v20开始已经标记过时的方法,调用这个方法是为了保证基于之前版本开发的逻辑能够正常运行。

首先判断是否有PFLAG3_APPLYING_INSETS标记,前文提到该标记位表示正在分发WindowInsets处理,如果正在分发,那么就直接调用fitSystemWindowsInt方法;否则针对SystemWindowInsets进行分发,并设置PFLAG3_FITTING_SYSTEM_WINDOWS标记。View.onApplyWindowInsets(WindowInsets)方法判断如果存在该标记,就直接调用fitSystemWindowsInt方法。

View.fitSystemWindowsInt(Rect)

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

这个方法就是真正消费SystemWindowInsets的地方。首先判断是否设置了fitsSystemWindows属性,最终使用internalSetPadding方法设置padding,注意这里会直接覆盖已经设置好的padding。当然这样也可能导致一些问题,后面我们会说到。

ViewGroup.dispatchApplyWindowInsets(WindowInsets)

@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能处理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流程。

overall

自定义View、ViewGroup

通过上面的分析,我们可以得到一些有用的信息:

  1. 并不是每次布局都会分发WindowInsets,只有当WindowInsets发生变化时,ViewRootImpl才会主动分发。如果子View需要更新WindowInsets,调用ViewCompaxt.requestApplyInsets()方法。
  2. dispatchApplyWindowInsets方法用于分发。
  3. 消费WindowInsets的方法有两个:OnApplyWindowInsetsListener或者覆写onApplyWindowInsets方法,因为后者是API v20才添加的,所以通常使用前者。
  4. 如果不希望执行默认的消费方式(padding),覆写前文的两个方法自行处理。
  5. 对于ViewGroup,如果设置了fitsSystemWindows属性,就一定会消费WindowInsets(不考虑overscan逻辑)。
  6. ViewGroup会优先尝试自己消费WindowInsets,然后才进行分发。

下面讨论几个自定义View的例子,看下Android官方的实现中是怎么处理WindowInsets的。当我们需要自定义实现WindowInsets的处理时,也可以参考。

CoordinatorLayout

CoordinatorLayout覆写了默认实现,最终通过dispatchApplyWindowInsetsToBehaviors方法将WindowInsets分发给Behavior的onApplyWindowInsets方法。

在onAttachToWindow方法中,CoordinatorLayout包含如下逻辑:

if (mLastInsets == null && ViewCompat.getFitsSystemWindows(this)) {
  // We're set to fitSystemWindows but we haven't had any insets yet...
  // We should request a new dispatch of window insets
  ViewCompat.requestApplyInsets(this);
}

CoordinatorLayout会缓存WindowInsets,当Attach到window上时,如果没有缓存,就请求刷新WindowInsets,如果CoordinatorLayout并不是在页面初次加载就被添加到View上,比如在ViewPager中,这个方法就能保证及时更新WindowInsets。

通过上面我们分析的结论,CoordinatorLayout只有当被设置fitsSystemWindows属性时才会执行自己的分发逻辑。所以对于需要消费WindowInsets的直接子View,有两种处理WindowInsets的方式:

  1. 给CoordinatorLayout设置fitsSystemWindows,子View不设置fitsSystemWindows属性,然后自定义Behavior实现onApplyWindowInsets方法处理。
  2. CoordinatorLayout不设置fitsSystemWindows属性,子View通过上面第3点结论处理。

CollapsingToolbarLayout

CollapsingToolbarLayout同样使用OnApplyWindowInsetsListener处理WindowInsets,在回调中CollapsingToolbarLayout缓存WindowInsets,在onLayout、OffsetUpdateListener、绘制scrim中计算偏移量。具体可在源码中搜索mLastInsets

有一个额外逻辑是:

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

    // ...
  }
}

如果父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的实现:

ViewCompat.setOnApplyWindowInsetsListener(this,
      new android.support.v4.view.OnApplyWindowInsetsListener() {
        private final Rect mTempRect = new Rect();

        @Override
        public WindowInsetsCompat onApplyWindowInsets(final View v,
                final WindowInsetsCompat originalInsets) {
          // First let the ViewPager itself try and consume them...
          final WindowInsetsCompat applied =
                  ViewCompat.onApplyWindowInsets(v, originalInsets);
          if (applied.isConsumed()) {
            // If the ViewPager consumed all insets, return now
            return applied;
          }

          // Now we'll manually dispatch the insets to our children. Since ViewPager
          // children are always full-height, we do not want to use the standard
          // ViewGroup dispatchApplyWindowInsets since if child 0 consumes them,
          // the rest of the children will not receive any insets. To workaround this
          // we manually dispatch the applied insets, not allowing children to
          // consume them from each other. We do however keep track of any insets
          // which are consumed, returning the union of our children's consumption
          final Rect res = mTempRect;
          res.left = applied.getSystemWindowInsetLeft();
          res.top = applied.getSystemWindowInsetTop();
          res.right = applied.getSystemWindowInsetRight();
          res.bottom = applied.getSystemWindowInsetBottom();

          for (int i = 0, count = getChildCount(); i < count; i++) {
            final WindowInsetsCompat childInsets = ViewCompat
                    .dispatchApplyWindowInsets(getChildAt(i), applied);
            // Now keep track of any consumed by tracking each dimension's min
            // value
            res.left = Math.min(childInsets.getSystemWindowInsetLeft(), res.left);
            res.top = Math.min(childInsets.getSystemWindowInsetTop(), res.top);
            res.right = Math.min(childInsets.getSystemWindowInsetRight(), res.right);
            res.bottom = Math.min(childInsets.getSystemWindowInsetBottom(), res.bottom);
          }

          // Now return a new WindowInsets, using the consumed window insets
          return applied.replaceSystemWindowInsets(
                  res.left, res.top, res.right, res.bottom);
        }
  });

源码的注释也说明了这个问题,为了让每个子View都收到WindowInsets事件,需要逐个分发。

问题

但是这个实现有个小问题,你发现了吗?

最后的return语句,返回了一个未被消费的,但是值为0的insets。ViewPager也是ViewGroup的子类,在dispatchApplyWindowInsets(WindowInsets)方法中,super.dispatchApplyWindowInsets(insets)方法的调用会触发上文的OnApplyWindowInsetsListener.onApplyWindowInsets(View, WindowInsetsCompat),但是之后的逻辑:

if (!insets.isConsumed()) {
  // 分发给子View
}

会使得判断条件为真(没有被消费),进而继续分发,这样第一个子View(getChildAt(0),而不一定是Adapter中的index)就会再收到一次WindowInsets,而这个WindowInsets不再是副本,子View消费掉之后,会直接跳出循环并返回。(完整代码见上文

if (insets.isConsumed()) {
  break;
}

导致的结果,就是第一个子View的WindowInsets被重置为0。

源码注释还说了是 "the consumed window insets"……

解决

解决方法,就是重新实现这个listener,在最后返回一个被消费的insets:

// ...
return applied.replaceSystemWindowInsets(
  res.left, res.top, res.right, res.bottom).consumeSystemWindowInsets();

是的,多一个方法调用即可。

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