Fragment中ViewPager嵌套Fragment,共享元素錯位解決方案
前言
前事告一段落,在新的項目中,覺得采用ViewPager
+Fragment
的方案作爲主界面,創建的過程很快,也沒有遇到什麼問題。但在實現界面跳轉的時候,才發現單Activity
多Fragment
結構的坑點還是有很多。這次採用了Google最新發布的Android Jetpack組件中的Navigation
來控制Fragment
跳轉,使用途中有優點,也有缺點,不過在整體算來,還是極大打簡化了我們需要編寫的代碼。例如:NavHostFragment.findNavController(this).navigate(...)
默認是利用FragmentManager.FragmentTransaction.replace()
來進行導航,我們我無法通過干預其過程來使用hide()
和show()
,不過從某種程度上看來也不算是缺點,相反,我覺得這正好統一了Fragment
的使用吧,比較通過hide()
和show()
來展示Fragment
有可能會出發Fragment
重疊問題,故我們還需要手動去解決這個問題。
App概覽
談完Navigation
,就讓我們先來看一下App簡化後的層級:
可以看到Activity
只是Fragment
的一個載體,所有界面的跳轉均有Fragment
完成,均由Navigation
控制。在第一次跳轉發生後,發現了一個問題:
具體分析
問題一:跳轉返回主界面發現回到初始狀態
即本來跳轉之前,我們的RecyclerView
是滾動到自定義的位置的,但是在跳轉之後,再進行了返回之後,RecyclerView
回到了頂部,也就是默認位置。初步猜想,該問題是Fragment
重新創建了佈局導致的,經過在Fragment各個生命週期回調方法內部打印log發現在跳轉發生的時候,的確導致了Fragment1
的view
銷燬,回調了onDestroyView
方法,而在跳轉返回的時候也確實回調了onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?)
方法。既然這樣,那麼爲什麼返回之後主界面即view
爲什麼沒有保存返回前的狀態也就不難理解,原來是每次返回之後我們所見到的主界面其實是一個新的view
,而不是跳轉之前的那個view
實例,自然也就沒有跳轉的狀態了。
- 這裏插入一點多餘的話語:有些同學可能會問爲什麼
Fragment
只回調了onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?)
到onDestroyView
生命週期之間的方法,原因是我們所使用的ViewPager
的Adapter
是FragmentPagerAdapter
而不是FragmentStatePagerAdapter
,通過查閱兩者的源碼可以知道FragmentPagerAdapter
在銷燬起子項時即調用destroyItem()
時調用了FragmentManager.FragmentTransaction.detach()
而不是remove()
,故Fragment只是銷燬了視圖,其實例依然存在;而FragmentStatePagerAdapter
則在銷燬子項時即destroyItem()
時調用了FragmentManager.FragmentTransaction.remove()
故而徹底移除了Fragment
。
繼續回到問題,既然我們已經知道了問題出在了哪裏,那麼現在就需要着手解決問題了。首先我想到的方案是是這樣的:
方案一
既然重新返回導致重新創建的view使其回到了初始狀態那麼我們只需要在跳轉之前保存view
的相關狀態與viewModel
中即可,初期需要保存的狀態並不多,暫時只需要RecyclerView
滾動位置即可,並且在onDestroyView()
中調用即可。具體代碼如下:
private fun getPositionAndOffset() {
val topView = recyclerView.gridLayoutManager.getChildAt(0)
if (topView != null) {
stateViewModel.lastOffset = topView.top
stateViewModel.lastPosition = recyclerView.gridLayoutManager.getPosition(topView)
}
}
private fun scrollToPositionWithOffset() {
if (recyclerView.layoutManager != null && viewModel.lastPosition >= 0) {
recyclerView.gridLayoutManager.scrollToPositionWithOffset(viewModel.lastPosition, viewModel.lastOffset)
}
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
···
scrollToPositionWithOffset()
···
}
override fun onDestroyView() {
super.onDestroyView()
···
getPositionAndOffset()
···
}
如此修改之後,返回之後發現view
狀態的確跟跳轉之前相同的,此時,我以爲問題到這兒就算是結束了,可是隨後新的問題,確又令人煞費苦心。
問題二:共享元素退出動畫失效
因爲RecyclerView
中的Item
都設定了ItemClick
點擊事件,點擊之後跳轉到相應詳情頁,爲了使跳轉不那麼生硬,這裏採用了共享元素+其他動畫的方式來實現過渡,但就是在應用共享元素動畫的時候又出現了新的問題:具體表現爲,共享元素在跳轉發生後的進入動畫完全正常,但是點擊Back
返回的時候,生硬的切回了主界面。我的共享元素的返回動畫呢???文檔不是說設定了共享元素進入動畫後,可以不設定返回動畫,系統會按照和進入相反的動畫進行過渡。我以爲是我沒有給共享元素設定返回動畫的原因,於是又加上了設定返回動畫的代碼。這次我滿懷期待的重新構建了一遍項目,期望它能如我所願,可惜世事總不如意,納尼?我的返回動畫呢,爲什麼還不出來。在經歷了各種嘗試無果之後,沒辦法只能先給Fragemnt1
這個整體加了一個退出動畫來暫時頂替。雖然視覺上是不那麼生硬了,但是由於進入和退出動畫沒有聯繫,在感知上,總有一種不合理的感覺。就這麼過去了一天,可還是一點頭緒也沒有。在第二天的時候突然想到了一個問題,因爲在之前使用Fragment
+ViewPager
+FragmentStatePagerAdapter
的時候遇到過返回後ViewPager
不顯示的問題,那個時候查閱資料,最後找的的解決辦法是在ViewPager
所在的Fragment
的onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?)
方法中判斷一次rootView
是否爲空,如果爲空,則inflate
一個新的view
否則就將rootView
從它的父視圖中移除(如果有的話),然後return rootView
,即:
private var rootView: View? = null
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val layoutId = getLayoutId() ?: return null
if (rootView == null) {
rootView = inflater.inflate(layoutId, container, false)
}
return rootView
}
這個時候想到了可能是新的view沒有設置transitionName
所致,Transition Framework
自然也無法生成相應的過渡動畫,這個時候我的腦海中就浮現出了另一個方案:
方案二
這次我們既然瞭解到了共享元素返回動畫失效的原因,是由於view
是新創建的,並且系統找不到對應的transitionName
,那麼我們可以換一個角度去思考,結合我前一次解決ViewPager
不顯示的案例,很容易想到,複用已經生成的view
。這樣帶來了一些意想不到的好處:首先,因爲複用view
的原因,不需要每次去重新初始化view
了,這不經意間提升了我們主界面恢復的時間(Navigation
內部是通過一個FrameLayout
作爲Container
,對需要導航的Fragment
通過replace來實現界面跳轉的);其次,由於複用的關係,view
的狀態都還在,也就不需要我們手動去保存和恢復狀態了;同時,省去了將數據重新填充到視圖上的過程。這個時候我們需要處理一下數據初始化的問題,一般是不需要重新填充數據的。重新填充之後可能還會引發新的問題(比如我,→_→)。具體情況是這樣子滴:在initView階段我們只是綁定了數據和視圖的關係,並沒有填充數據,所以重複initView之後,雖然視圖和邏輯不會發生變化,但是由於這個時候,RecyclerView
其實數據還未加載完全,導致Transition Framework
無法找到匹配的transitionName
,這就又回到了之前的問題。所以在下面的基類裏面避規了重複初始化的問題:
abstract class KeepViewFragment<VM : ViewModel> : BaseFragment<VM>() {
protected var rootView: View? = null
private var needInitView = false
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
addBackPressedListener()
val layoutId = getLayoutId() ?: return null
if (rootView == null) {
rootView = inflater.inflate(layoutId, container, false)
needInitView = true
}
return rootView
}
override fun init() {
if (needInitView)
initView(view!!)
}
override fun onDestroyView() {
super.onDestroyView()
needInitView = false
}
}
在將MainFragment
的基類改爲KeepViewFragment
之後,共享元素動畫終於恢復了正常(騙你的,←_←),心想,終於可以鬆一口氣了。可一番演示之後,定睛一看,這返回過渡動畫參數不正確吧(你唬誰呢,→_→),額,真是好事多磨,怎麼就又出現新的問題了呢?
問題三:共享元素退出動畫參數錯誤
不囉嗦了,具體錯誤描述如下:
退出動畫的起點位置始終爲endView
(imageView
)的左上角(這裏使用的共享元素動畫爲android.R.transition.move
)
這裏也直接給出解決辦法:即自定義transitionSet
,把move中包涵的changeImageTransform
去除就可以了,從字面上看來這就是爲ImageView
量身定製的Transition
,可爲什麼添加之後反而會出現共享元素過渡動畫錯誤呢?希望知道原因的小夥伴告知我。
<?xml version="1.0" encoding="utf-8"?>
<transitionSet>
<changeTransform />
<changeClipBounds />
<changeBounds />
<!--<changeImageTransform />-->
</transitionSet>
結語
這一次的經歷,讓我初次解到了Transition Framework
這個組件,同時對ViewPager
和Navigation
的使用也更得心應手,也更能熟練的運用MVVM,同時翻閱Adnroid Developers和Android Jetpack,就愈發讓人着迷。接下來的一個計劃是實現一個懶加載的ViewPager
。我個人認爲(一般我們只需要在ViewPager
中的Fragment
去支持懶加載,自然他應該由ViewPager
控制,而不是Fragment
)
泠音 寫於2018/11/02