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: BaseFragment () { 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
,可为什么添加之后反而会出现共享元素过渡动画错误呢?希望知道原因的小伙伴告知我。
结语
这一次的经历,让我初次解到了Transition Framework
这个组件,同时对ViewPager
和Navigation
的使用也更得心应手,也更能熟练的运用MVVM,同时翻阅Adnroid Developers和Android Jetpack,就愈发让人着迷。接下来的一个计划是实现一个懒加载的ViewPager
。我个人认为(一般我们只需要在ViewPager
中的Fragment
去支持懒加载,自然他应该由ViewPager
控制,而不是Fragment
)
泠音 写于2018/11/02