[Digging] android:fitsSystemWindows

cover

原文鏈接

Android Translucent Status Bar 系列中,我基于“給哪個(gè)View設(shè)置fitsSystemWindows屬性”的角度分析了Android對(duì)fitsSystemWindows的處理;這篇文章,我們把這個(gè)屬性的處理過程詳細(xì)分析一下,同時(shí)解決這個(gè)屬性在另一個(gè)場(chǎng)景中的問題——ViewPager。

如果你不知道這個(gè)屬性或者不知道WindowInsets是什么,推薦看一下Why would I want to fitsSystemWindows?

處理流程

我們知道View樹的根節(jié)點(diǎn)是DecorView,而DecorView又是由ViewRootImpl管理的。ViewRootImpl負(fù)責(zé)View樹和Window之間的消息發(fā)送和事件傳遞,ViewRootImpl通過Stub接收Window的消息。

WindowInsets是Window在大小發(fā)生變化的時(shí)候,回調(diào)傳遞給ViewRootImpl的。ViewRootImpl會(huì)保存WindowInsets的值,在performTraversal方法中,如果mApplyInsetsRequested標(biāo)記為true,則執(zhí)行WindowInsets的分發(fā),具體為調(diào)用dispatchApplyInsets方法。

Android Translucent Status Bar 系列對(duì)WindowInsets分發(fā)的總結(jié):

深度遍歷,從上至下依次消費(fèi)Insets,直到WindowInsets的isConsumed方法返回true

這個(gè)遍歷就是dispatchApplyInsets方法觸發(fā)的(API v25):

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

也就是說DecorView的dispatchApplyWindowInsets就是整個(gè)遍歷分發(fā)的入口,而DecorView的實(shí)現(xiàn)也是繼承自ViewGroup的實(shí)現(xiàn)。

Android的UI框架中,對(duì)WindowInsets的處理基本都使用View和ViewGroup的默認(rèn)實(shí)現(xiàn):

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方法會(huì)直接嘗試自己處理,先判斷是否有OnApplyWindowInsetsListener,有的話調(diào)用OnApplyWindowInsetsListener的處理方法,否則調(diào)用onApplyWindowInsets方法。

<span id="PFLAG3_APPLYING_INSETS">PFLAG3_APPLYING_INSETS</span>表示正在分發(fā)Windowinsets處理,防止循環(huán)調(diào)用。

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標(biāo)記表示正在處理SystemWindowInsets。如果當(dāng)前沒有在處理SystemWindowInsets,調(diào)用fitSystemWindows方法處理;否則調(diào)用fitSystemWindowsInt方法直接設(shè)置padding;如果這兩個(gè)方法返回true,消費(fèi)SystemWindowInsets。

SystemWindowInsets是WindowInsets的最常見一種,另外還有StableInsets(API v21)和WindowDecorInsets。

StableInsets和SystemWindowInsets類似,表示被StatusBar等遮蓋的區(qū)域,不同的是StableInsets不會(huì)隨著StatusBar的隱藏和顯示變化。沉浸式全屏下,StatusBar可以通過手勢(shì)呼出,StableInsets不會(huì)發(fā)生變化。

WindowDecorInsets為預(yù)留屬性,忽略。

消費(fèi)SystemWindowInsets是將SystemWindowInsets屬性置為空,并將已消費(fèi)的標(biāo)記為置為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);
  }
}

這個(gè)方法是API v20開始已經(jīng)標(biāo)記過時(shí)的方法,調(diào)用這個(gè)方法是為了保證基于之前版本開發(fā)的邏輯能夠正常運(yùn)行。

首先判斷是否有PFLAG3_APPLYING_INSETS標(biāo)記,前文提到該標(biāo)記位表示正在分發(fā)WindowInsets處理,如果正在分發(fā),那么就直接調(diào)用fitSystemWindowsInt方法;否則針對(duì)SystemWindowInsets進(jìn)行分發(fā),并設(shè)置PFLAG3_FITTING_SYSTEM_WINDOWS標(biāo)記。View.onApplyWindowInsets(WindowInsets)方法判斷如果存在該標(biāo)記,就直接調(diào)用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;
}

這個(gè)方法就是真正消費(fèi)SystemWindowInsets的地方。首先判斷是否設(shè)置了fitsSystemWindows屬性,最終使用internalSetPadding方法設(shè)置padding,注意這里會(huì)直接覆蓋已經(jīng)設(shè)置好的padding。當(dāng)然這樣也可能導(dǎo)致一些問題,后面我們會(huì)說到。

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分發(fā)給子View的。在遍歷子View分發(fā)之前,首先調(diào)用了super.dispatchApplyWindowInsets方法,這實(shí)際上就是調(diào)用了View.dispatchApplyWindowInsets方法,通過前文的分析,View的實(shí)現(xiàn)就是自己處理,最終調(diào)用fitSystemWindowsInt,而View是否處理的唯一條件,就是是否設(shè)置了fitsSystemWindows屬性

這里可以得出一個(gè)結(jié)論:如果ViewGroup設(shè)置了fitsSystemWindows屬性,那么將自己消費(fèi)WindowInsets,而不會(huì)向下分發(fā)

流程圖

下面是整個(gè)分發(fā)過程的流程圖。通常起始點(diǎn)在ViewRootImpl的dispatchApplyInsets方法,View的onApplyWindowInsets方法依然調(diào)用了廢棄的fitSystemWindow方法是為了兼容有些覆寫該方法的自定義View(兼容真是很麻煩),而API v21以后的fitSystemWindow方法再次調(diào)用了dispatchApplyWindowInsets方法,這樣保證無論從dispatchApplyWindowInsets方法還是fitSystemWindow方法進(jìn)入的處理流程,都可以完整調(diào)用onApplyWindowInsets方法和fitSystemWindow方法

另外,onApplyWindowInsets方法也是public的,所以可以跳過dispatchApplyWindowInsets直接調(diào)用onApplyWindowInsets,為了保證分發(fā)過程的完整性,直接調(diào)用onApplyWindowInsets,也會(huì)在當(dāng)前View中執(zhí)行一次完整的dispatch -> apply流程。

overall

自定義View、ViewGroup

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

  1. 并不是每次布局都會(huì)分發(fā)WindowInsets,只有當(dāng)WindowInsets發(fā)生變化時(shí),ViewRootImpl才會(huì)主動(dòng)分發(fā)。如果子View需要更新WindowInsets,調(diào)用ViewCompaxt.requestApplyInsets()方法。
  2. dispatchApplyWindowInsets方法用于分發(fā)。
  3. 消費(fèi)WindowInsets的方法有兩個(gè):OnApplyWindowInsetsListener或者覆寫onApplyWindowInsets方法,因?yàn)楹笳呤茿PI v20才添加的,所以通常使用前者。
  4. 如果不希望執(zhí)行默認(rèn)的消費(fèi)方式(padding),覆寫前文的兩個(gè)方法自行處理。
  5. 對(duì)于ViewGroup,如果設(shè)置了fitsSystemWindows屬性,就一定會(huì)消費(fèi)WindowInsets(不考慮overscan邏輯)。
  6. ViewGroup會(huì)優(yōu)先嘗試自己消費(fèi)WindowInsets,然后才進(jìn)行分發(fā)。

下面討論幾個(gè)自定義View的例子,看下Android官方的實(shí)現(xiàn)中是怎么處理WindowInsets的。當(dāng)我們需要自定義實(shí)現(xiàn)WindowInsets的處理時(shí),也可以參考。

CoordinatorLayout

CoordinatorLayout覆寫了默認(rèn)實(shí)現(xiàn),最終通過dispatchApplyWindowInsetsToBehaviors方法將WindowInsets分發(fā)給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會(huì)緩存WindowInsets,當(dāng)Attach到window上時(shí),如果沒有緩存,就請(qǐng)求刷新WindowInsets,如果CoordinatorLayout并不是在頁面初次加載就被添加到View上,比如在ViewPager中,這個(gè)方法就能保證及時(shí)更新WindowInsets。

通過上面我們分析的結(jié)論,CoordinatorLayout只有當(dāng)被設(shè)置fitsSystemWindows屬性時(shí)才會(huì)執(zhí)行自己的分發(fā)邏輯。所以對(duì)于需要消費(fèi)WindowInsets的直接子View,有兩種處理WindowInsets的方式:

  1. 給CoordinatorLayout設(shè)置fitsSystemWindows,子View不設(shè)置fitsSystemWindows屬性,然后自定義Behavior實(shí)現(xiàn)onApplyWindowInsets方法處理。
  2. CoordinatorLayout不設(shè)置fitsSystemWindows屬性,子View通過上面第3點(diǎn)結(jié)論處理。

CollapsingToolbarLayout

CollapsingToolbarLayout同樣使用OnApplyWindowInsetsListener處理WindowInsets,在回調(diào)中CollapsingToolbarLayout緩存WindowInsets,在onLayout、OffsetUpdateListener、繪制scrim中計(jì)算偏移量。具體可在源碼中搜索mLastInsets

有一個(gè)額外邏輯是:

@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屬性。你可能會(huì)問如果父View設(shè)置了fitsSystemWindows屬性,那CollapsingToolbarLayout即便也設(shè)置了這個(gè)屬性,不也拿不到消費(fèi)的機(jī)會(huì)了嗎?

答案是不會(huì)。AppBarLayout、CollapsingToolbarLayout是關(guān)聯(lián)使用的,所以耦合性很高。AppBarLayout中對(duì)WindowInsets的處理僅僅是記錄和使用,并沒有消費(fèi),真正消費(fèi)是在CollapsingToolbarLayout中。感興趣可以查看AppBarLayout的代碼。

[Digging] Android Translucent StatusBar 3這篇文章中提到了CollapsingToolbarLayout處理WindowInsets引入的一個(gè)問題,雖然在onLayout中正確處理了偏移,但是onMeasure中沒有根據(jù)WindowInsets擴(kuò)大View尺寸,導(dǎo)致本來夠大的View尺寸在設(shè)置Padding后放不下子View了。這在自定義ViewGroup處理WindowInsets的時(shí)候要特別注意。

ViewPager

如果一個(gè)ViewGroup有多個(gè)子View需要處理WindowInsets,應(yīng)該怎么處理?這就是ViewPager面臨的問題。我們知道如果要給子View分發(fā)WindowInsets,只需要調(diào)用子View的dispatchApplyWindowInsets方法即可。如果多個(gè)子View需要處理,那么相同一份WindowInsets,分發(fā)多次即可,當(dāng)然要分發(fā)副本,否則就被子View消費(fèi)了。

下面是ViewPager的實(shí)現(xiàn):

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

源碼的注釋也說明了這個(gè)問題,為了讓每個(gè)子View都收到WindowInsets事件,需要逐個(gè)分發(fā)。

問題

但是這個(gè)實(shí)現(xiàn)有個(gè)小問題,你發(fā)現(xiàn)了嗎?

最后的return語句,返回了一個(gè)未被消費(fèi)的,但是值為0的insets。ViewPager也是ViewGroup的子類,在dispatchApplyWindowInsets(WindowInsets)方法中,super.dispatchApplyWindowInsets(insets)方法的調(diào)用會(huì)觸發(fā)上文的OnApplyWindowInsetsListener.onApplyWindowInsets(View, WindowInsetsCompat),但是之后的邏輯:

if (!insets.isConsumed()) {
  // 分發(fā)給子View
}

會(huì)使得判斷條件為真(沒有被消費(fèi)),進(jìn)而繼續(xù)分發(fā),這樣第一個(gè)子View(getChildAt(0),而不一定是Adapter中的index)就會(huì)再收到一次WindowInsets,而這個(gè)WindowInsets不再是副本,子View消費(fèi)掉之后,會(huì)直接跳出循環(huán)并返回。(完整代碼見上文

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

導(dǎo)致的結(jié)果,就是第一個(gè)子View的WindowInsets被重置為0。

源碼注釋還說了是 "the consumed window insets"……

解決

解決方法,就是重新實(shí)現(xiàn)這個(gè)listener,在最后返回一個(gè)被消費(fèi)的insets:

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

是的,多一個(gè)方法調(diào)用即可。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,283評(píng)論 6 530
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 97,947評(píng)論 3 413
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,094評(píng)論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,485評(píng)論 1 308
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,268評(píng)論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 54,817評(píng)論 1 321
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,906評(píng)論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 42,039評(píng)論 0 285
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,551評(píng)論 1 331
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,502評(píng)論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,662評(píng)論 1 366
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,188評(píng)論 5 356
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 43,907評(píng)論 3 345
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,304評(píng)論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,563評(píng)論 1 281
  • 我被黑心中介騙來泰國(guó)打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,255評(píng)論 3 389
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,637評(píng)論 2 370