解決 ViewPager 巢狀導致的 Fragment 選單錯亂

TiouLims發表於2017-10-20

以下圖巢狀的 ViewPager 為例,它是一個兩層巢狀的 ViewPager,也就是說 ViewPager 裡面是 Fragment ,每個 Fragment 裡面又是一個 ViewPager。在下面的例子中,每個 Fragment 都有一個相同名字的選單項,可以看到不在當前頁顯示的 Fragment 它的選單項也顯示出來了。使用者滑動到 B ,ViewPager 能正確處理第一層的選單,顯示 B 的時候同時預載入 A、C 兩個 Fragment,而選單裡只顯示 B 的選單項。到第二層就有問題了, BA 是第二層當前的 Fragment,它的選單項也能顯示出來,這沒問題。但卻多出來 AA 和 CA,這是因為 ViewPager 預載入了 A,A 裡面的 ViewPager 把 AA 當成是當前 Fragment,把它的選單項也顯示出來了。CA 也是同樣道理。

決定是否顯示選單的程式碼是由 PagerAdapter#setPrimaryItem 實現的,屬於主項(primary item)的 fragment 才會顯示選單項。以 FragmentPagerState 為例,具體程式碼如下:

Fragment fragment = (Fragment)object;
if (fragment != mCurrentPrimaryItem) {
    if (mCurrentPrimaryItem != null) {
        mCurrentPrimaryItem.setMenuVisibility(false);
        mCurrentPrimaryItem.setUserVisibleHint(false);
    }
    if (fragment != null) {
        fragment.setMenuVisibility(true);
        fragment.setUserVisibleHint(true);
    }
    mCurrentPrimaryItem = fragment;
}複製程式碼

AA、CA 的顯示就很好理解了,因為它們各自是 A 和 C 的主項(primary item),所以都呼叫了 setMenuVisibility(true)

要修復這個問題,一開始想到的是覆蓋父 fragment 的 setMenuVisibility 方法,把值傳遞到當前子 fragment

@Override public void setMenuVisibility(boolean menuVisible) {
    if (isAdded() && getChildFragmentManager().getFragments() != null) {
      Fragment f = getChildFragmentManager().findFragmentByTag(
          "android:switcher:" + mPager.getId() + ":" + mPager.getCurrentItem());// 不支援 FragmentStatePagerAdapter
      if (f != null) {
        f.setMenuVisibility(menuVisible);
      }
    }
    super.setMenuVisibility(menuVisible);
  }複製程式碼

這樣從 A 滑到 B 時,AA 能隱藏了。但仍然不能解決問題,從 A 滑到 B 時離屏載入 C,並設定 C 的 MenuVisibility 為 false。FragmentPagerAdapter 幾次 setMenuVisibility 都在 finishUpdate 之前,所以此時 C 還未新增到 Activity,CA 更不存在。等到 CA 載入時,已經不會再觸發 C 的 MenuVisibility 了。

考慮自定義 FragmentPagerAdapter,主項(primary item)的 fragment 的 menuVisibility 同步父 Fragment 的狀態,mParent 是介面卡建構函式傳入的 ViewPager 宿主 Fragment。

@Override public void setPrimaryItem(ViewGroup container, int position, Object object) {
  super.setPrimaryItem(container, position, object);
  if (mParent != null) ((Fragment) object).setMenuVisibility(mParent.isMenuVisible());
}複製程式碼

這樣的問題是,從 A 滑到 B 時,只是根 ViewPager 的當前主項(primary item)發生變化,A 介面卡和 B 介面卡的主項不會發生變化,所以 setPrimaryItem 不會被觸發,AA 的選單仍然可見,而 BA 的選單則仍然不可見。

幸運的是把修改兩個地方合併起來,這樣就覆蓋了各種可能了。但未免過於繁瑣,把問題重新整理一遍,建立模型,才是優雅的解決方法:

  1. 給介面卡(Adapter)引入是否可視(visible)屬性,不再是主項(primary item)的 Fragment 就顯示選單,而是隻有當前介面卡是可視的情況下才可以顯示選單。
  2. 是否可視的遞迴定義:父介面卡(管理宿主 Fragment 的 Adapter)是可視的 ,且宿主 Fragment 是主項,介面卡才是可視的。
  3. 父介面卡的可視狀態和宿主 Fragment 主項狀態發生改變,要傳遞到其子介面卡。
  4. 子介面卡初始化要正確初始化他的可視狀態。

這樣就能設計一個新的 PagerAdapter,把棘手的問題都放在 PagerAdapter 來做。

要實現第三點,介面卡需要獲得指向其子介面卡的引用,介面卡是 Fragments 的管理者,這些 Fragments 又是子介面卡的宿主,只要讓 Fragment 實現介面來獲取其內部的介面卡便行。

public interface AdapterHolder {
    HierarchyFragmentPagerAdapter getAdapter();
  }

/**
 * 通知子 Adapter(宿主是 holder) ,父 Adapter(當前的 Adapter) visible 發生了變化。
 * 或者通知子 Adapter,父 Adapter 希望它的 visible 發生變化
 */
private void notifyChildVisibleChanged(boolean visible, Fragment holder) {
  if (holder instanceof AdapterHolder) {
    HierarchyFragmentPagerAdapter adapter = ((AdapterHolder) holder).getAdapter();
    if (adapter != null) {
      adapter.setVisible(visible);
    }
  }
}複製程式碼

沒有繼承 AdapterHolder 都會被介面卡當成沒有子 Adapter。

相比第三點,第四點反而更麻煩。在 finishUpdate 之前,Fragment 是不知道它在樹中的位置的。這時如果嘗試用 getParentFragment() 是返回空,

    Fragment parent = fragment.getParentFragment();
    if (parent == null || !(parent instanceof AdapterHolder)) {
      // 拿不到 parent 有兩種情況
      // Adapter 在根 Pager 裡
      // 也有可能是第一次初始化,當前 Fragment 還未和其父 Fragment 建立連結
      setVisible(isVisible());
    } else {
      // 否則,只有父 Adapter 是 visible primary,當前 primary item 才可能是 visible primary.
      setVisible(((AdapterHolder) parent).getAdapter().isVisible());
    }複製程式碼

為了能夠正確初始化,需要在建構函式做個 hack。

public HierarchyFragmentPagerAdapter(PagerAdapter adapter, AdapterHolder holder) {
   mAdapter = adapter;
   mVisible = true;
   if (holder != null) {
     if (holder instanceof Fragment) {
       // 一個 hack,初始化 的 visible 狀態
       // holder 不是 Fragment 表示 Adapter 為根 Adapter
       // menu visible 為 true,便斷言宿主 Fragment 是 primary item.
       mVisible = ((Fragment) holder).isMenuVisible();
     }
   }
 }複製程式碼

剩下的便沒什麼,鑑於 PagerAdapter 有兩個, FragmentPagerAdapter 和 FragmentStatePagerAdapter。所以介面卡的設計便用代理模式比較合適,實現起來比想象中的簡潔,用起來也簡單,只需將實際的 PagerAdapter 外面包一層 HierarchyFragmentPagerAdapter 就行,具體的程式碼見:HierarchyFragmentPagerAdapter

相關文章